Реализация автоматического перенаправления по региону на сайте
Автоматический редирект по региону — логичное продолжение GeoIP-определения. Пользователь открывает site.ru, система определяет его город и перенаправляет на site.ru/spb/ или spb.site.ru. Задача простая, но у неё есть несколько острых углов: петля редиректов, боты, пользователи из-за рубежа, кэширование на CDN.
Базовая логика редиректа
// app/Http/Controllers/RegionRedirectController.php
class RegionRedirectController
{
public function __invoke(Request $request): RedirectResponse|Response
{
// Боты и поисковики не редиректим — они обходят конкретные URL
if ($this->isCrawler($request->userAgent())) {
return app(HomeController::class)->index($request);
}
// Если пользователь уже выбирал регион — уважаем его выбор
if ($preferred = $request->cookie('region')) {
return $this->redirectToRegion($preferred, $request);
}
// GeoIP определение
$geo = app(GeoIpService::class)->lookupCached($request->ip());
$region = $this->matchRegion($geo['city'], $geo['region_name'], $geo['country_code']);
// Редирект 302 (не 301 — регион может измениться)
return $this->redirectToRegion($region->slug, $request)
->withCookie(cookie('region', $region->slug, 60 * 24 * 30)); // 30 дней
}
private function redirectToRegion(string $slug, Request $request): RedirectResponse
{
$path = $request->getPathInfo(); // '/' на главной
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;
}
}
Защита от петли редиректов
Самая распространённая ошибка — редирект срабатывает на уже региональных URL. Middleware защищает:
// app/Http/Middleware/SkipRegionRedirect.php
public function handle(Request $request, Closure $next): Response
{
// URL уже содержит slug региона — пропускаем
$regions = Region::pluck('slug')->toArray();
$firstSeg = explode('/', trim($request->getPathInfo(), '/'))[0] ?? '';
if (in_array($firstSeg, $regions, true)) {
return $next($request);
}
// Корневой URL — выполняем редирект
return app(RegionRedirectController::class)($request);
}
Применяется только к корневому маршруту:
Route::get('/', RegionRedirectController::class)
->middleware(SkipRegionRedirect::class);
Сопоставление города с регионом сайта
GeoIP возвращает название города на английском — нужно сопоставить с внутренним slug:
private function matchRegion(
?string $city,
?string $region,
?string $country
): Region
{
// Сначала по городу
if ($city) {
$match = RegionGeoMapping::where('city_en', $city)->first();
if ($match) return $match->region;
}
// По области/региону
if ($region) {
$match = RegionGeoMapping::where('region_en', $region)->first();
if ($match) return $match->region;
}
// По стране — базовый регион для страны
if ($country) {
$match = Region::where('country_code', $country)
->where('is_country_default', true)
->first();
if ($match) return $match;
}
// Глобальный дефолт
return Region::where('is_default', true)->firstOrFail();
}
Таблица маппинга:
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'
);
Кнопка смены региона
Пользователь должен иметь возможность изменить автоопределённый регион. При смене — обновляем куку:
// 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 год
});
На фронтенде — при клике на регион в шапке или попапе:
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 и кэширование
CDN (Cloudflare, Fastly) кэширует страницы. Если CDN кэширует корень /, все пользователи получат одинаковый редирект — тот, что был закэширован первым. Варианты:
Вариант A: исключить / из кэша CDN (Cache-Control: no-store на корневом URL).
Вариант B: использовать Cloudflare Workers для редиректа на 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);
}
Вариант C: редирект на клиенте через JS — самый простой, но пользователь видит мигание страницы.
Поведение для иностранных пользователей
Если GeoIP вернул страну, которой нет в списке регионов, — нужно решение:
- Показать попап «Выберите регион» без автоматического редиректа
- Перенаправить на дефолтный регион (
msk) с попапом «Вы из другой страны?» - Перенаправить на отдельную страницу
site.ru/international/
Решение зависит от доли иностранных пользователей в аудитории.
Отладка
Для тестирования редиректов с разных IP-адресов удобен query-параметр в dev-окружении:
if (app()->isLocal() && $testIp = $request->query('test_ip')) {
$geo = app(GeoIpService::class)->lookup($testIp);
}
https://site.dev/?test_ip=77.109.0.1 — симулирует пользователя с конкретным IP.







