Implementing Automatic Region Redirect on Website
Automatic redirect by region — logical follow-up to GeoIP detection. User opens site.ru, system detects their city and redirects to site.ru/spb/ or spb.site.ru. Simple task but has sharp corners: redirect loops, bots, overseas users, CDN caching.
Basic Redirect Logic
// app/Http/Controllers/RegionRedirectController.php
class RegionRedirectController
{
public function __invoke(Request $request): RedirectResponse|Response
{
// Bots and search engines — don't redirect, they crawl specific URLs
if ($this->isCrawler($request->userAgent())) {
return app(HomeController::class)->index($request);
}
// If user already chose region — respect choice
if ($preferred = $request->cookie('region')) {
return $this->redirectToRegion($preferred, $request);
}
// GeoIP detection
$geo = app(GeoIpService::class)->lookupCached($request->ip());
$region = $this->matchRegion($geo['city'], $geo['region_name'], $geo['country_code']);
// Redirect 302 (not 301 — region can change)
return $this->redirectToRegion($region->slug, $request)
->withCookie(cookie('region', $region->slug, 60 * 24 * 30)); // 30 days
}
private function redirectToRegion(string $slug, Request $request): RedirectResponse
{
$path = $request->getPathInfo(); // '/' on homepage
return redirect("/{$slug}{$path}", 302);
}
private function isCrawler(?string $ua): bool
{
if (!$ua) return false;
$bots = ['Googlebot', 'bingbot', 'YandexBot', 'Baiduspider',
'DuckDuckBot', 'Slurp', 'facebookexternalhit'];
foreach ($bots as $bot) {
if (str_contains($ua, $bot)) return true;
}
return false;
}
}
Protection from Redirect Loops
Most common mistake — redirect triggers on already regional URLs. Middleware protects:
// app/Http/Middleware/SkipRegionRedirect.php
public function handle(Request $request, Closure $next): Response
{
// URL already contains region slug — skip
$regions = Region::pluck('slug')->toArray();
$firstSeg = explode('/', trim($request->getPathInfo(), '/'))[0] ?? '';
if (in_array($firstSeg, $regions, true)) {
return $next($request);
}
// Root URL — perform redirect
return app(RegionRedirectController::class)($request);
}
Applied only to root route:
Route::get('/', RegionRedirectController::class)
->middleware(SkipRegionRedirect::class);
Matching City to Region
GeoIP returns city name in English — need to match with internal slug:
private function matchRegion(
?string $city,
?string $region,
?string $country
): Region
{
// First by city
if ($city) {
$match = RegionGeoMapping::where('city_en', $city)->first();
if ($match) return $match->region;
}
// By region/oblast
if ($region) {
$match = RegionGeoMapping::where('region_en', $region)->first();
if ($match) return $match->region;
}
// By country — base region for country
if ($country) {
$match = Region::where('country_code', $country)
->where('is_country_default', true)
->first();
if ($match) return $match;
}
// Global default
return Region::where('is_default', true)->firstOrFail();
}
Mapping table:
CREATE TABLE region_geo_mappings (
id SERIAL PRIMARY KEY,
region_id INTEGER REFERENCES regions(id),
city_en VARCHAR(255), -- 'Saint Petersburg'
region_en VARCHAR(255), -- 'Saint Petersburg'
country CHAR(2) -- 'RU'
);
Region Change Button
User must be able to change auto-detected region. On change — update cookie:
// AJAX endpoint
Route::post('/set-region/{slug}', function (string $slug, Request $request) {
abort_unless(Region::where('slug', $slug)->exists(), 404);
$redirectTo = $request->input('redirect_to', "/{$slug}/");
return response()->json(['redirect' => $redirectTo])
->withCookie(cookie('region', $slug, 60 * 24 * 365)); // 1 year
});
Frontend — on click region in header or popup:
async function setRegion(slug) {
const res = await fetch(`/set-region/${slug}`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
body: JSON.stringify({ redirect_to: `/${slug}/` })
});
const { redirect } = await res.json();
window.location.href = redirect;
}
CDN and Caching
CDN (Cloudflare, Fastly) caches pages. If CDN caches root /, all users get same redirect — the one cached first. Solutions:
Option A: exclude / from CDN cache (Cache-Control: no-store on root URL).
Option B: use Cloudflare Workers for redirect on edge:
// Cloudflare Worker
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
const cookie = request.headers.get('Cookie') || '';
const region = cookie.match(/region=([a-z]+)/)?.[1];
if (url.pathname === '/' && !region) {
const country = request.cf?.country || 'RU';
const slug = countryToRegion[country] || 'msk';
return Response.redirect(`${url.origin}/${slug}/`, 302);
}
return fetch(request);
}
Option C: redirect on client via JS — simplest but user sees flicker.
Behavior for Overseas Users
If GeoIP returns country not in regions list — need decision:
- Show popup "Select region" without auto-redirect
- Redirect to default region with popup "You from another country?"
- Redirect to separate page
site.ru/international/
Decision depends on overseas users share in audience.
Debugging
For testing redirects from different IPs, convenient query parameter in dev:
if (app()->isLocal() && $testIp = $request->query('test_ip')) {
$geo = app(GeoIpService::class)->lookup($testIp);
}
https://site.dev/?test_ip=77.109.0.1 — simulates user with specific IP.







