Настройка мультидоменного сайта (разные домены по странам)
Когда бизнес работает в нескольких странах, каждая страна нередко получает собственный домен: company.ru, company.kz, company.by, company.ua. Один кодовый движок обслуживает все домены — разные языки, цены, юридические тексты, телефоны. Это сложнее поддиректорий, но даёт максимальный локальный SEO-сигнал и полную изоляцию контента.
Конфигурация доменов
Центральная таблица доменов связывает хост с настройками:
CREATE TABLE site_domains (
id SERIAL PRIMARY KEY,
host VARCHAR(253) UNIQUE NOT NULL, -- 'company.ru'
country_code CHAR(2) NOT NULL, -- 'RU', 'KZ', 'BY'
locale VARCHAR(10) NOT NULL, -- 'ru', 'kk', 'be'
currency CHAR(3) NOT NULL, -- 'RUB', 'KZT', 'BYN'
timezone VARCHAR(64) NOT NULL,
is_primary BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
meta JSONB DEFAULT '{}'
);
INSERT INTO site_domains VALUES
(DEFAULT, 'company.ru', 'RU', 'ru', 'RUB', 'Europe/Moscow', true, true, '{}'),
(DEFAULT, 'company.kz', 'KZ', 'kk', 'KZT', 'Asia/Almaty', false, true, '{}'),
(DEFAULT, 'company.by', 'BY', 'ru', 'BYN', 'Europe/Minsk', false, true, '{}');
Middleware определения домена
// app/Http/Middleware/ResolveSiteDomain.php
class ResolveSiteDomain
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost(); // 'company.kz'
$domain = SiteDomain::where('host', $host)
->where('is_active', true)
->first();
if (!$domain) {
// Неизвестный домен — редирект на основной
$primary = SiteDomain::where('is_primary', true)->firstOrFail();
return redirect("https://{$primary->host}" . $request->getRequestUri(), 301);
}
app()->instance('site.domain', $domain);
// Устанавливаем локаль и часовой пояс
App::setLocale($domain->locale);
Carbon::setlocale($domain->locale);
date_default_timezone_set($domain->timezone);
return $next($request);
}
}
Мультитенантная конфигурация Laravel
// app/Providers/DomainServiceProvider.php
public function boot(): void
{
$this->app->resolving('current.domain', function () {
return app('site.domain');
});
// Переопределяем mail from для каждого домена
$this->app['events']->listen(MessageSending::class, function ($event) {
$domain = app('site.domain');
config([
'mail.from.address' => "noreply@{$domain->host}",
'mail.from.name' => config('app.name') . ' ' . strtoupper($domain->country_code),
]);
});
}
Хранение контента по доменам
-- Переводимые тексты привязаны к домену
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL,
domain_id INTEGER REFERENCES site_domains(id),
-- NULL в domain_id = общий контент для всех доменов
UNIQUE (slug, domain_id)
);
CREATE TABLE page_translations (
page_id INTEGER REFERENCES pages(id),
locale VARCHAR(10) NOT NULL,
title TEXT,
body TEXT,
meta_title TEXT,
meta_desc TEXT,
PRIMARY KEY (page_id, locale)
);
Запрос контента с fallback на общий:
public function getPage(string $slug): Page
{
$domain = app('site.domain');
// Сначала ищем страницу, специфичную для домена
$page = Page::where('slug', $slug)
->where('domain_id', $domain->id)
->first();
// Fallback на общую страницу
$page ??= Page::where('slug', $slug)
->whereNull('domain_id')
->firstOrFail();
return $page;
}
Nginx: виртуальные хосты для PHP-FPM
# /etc/nginx/sites-available/multisite.conf
server {
listen 443 ssl http2;
server_name company.ru company.kz company.by;
ssl_certificate /etc/letsencrypt/live/company.ru/fullchain.pem;
# Wildcard сертификат или multi-domain SAN
root /var/www/company/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param HTTP_HOST $host; # Передаём реальный хост в PHP
include fastcgi_params;
}
}
Если домены находятся на разных серверах — добавляется балансировщик с заголовком X-Forwarded-Host, который Laravel читает через TrustProxies middleware.
SSL-сертификаты
Certbot с мультидоменным SAN:
certbot certonly --nginx \
-d company.ru -d www.company.ru \
-d company.kz -d www.company.kz \
-d company.by -d www.company.by \
--email [email protected] \
--agree-tos
Альтернатива — wildcard *.company.ru + DNS challenge (автоматизируется через Certbot + dns-01 плагин для вашего DNS-провайдера).
Crossdomain SEO
Каждый домен — самостоятельный сайт в глазах Google. Для связи между ними используют hreflang в <head>:
<!-- На company.ru -->
<link rel="alternate" hreflang="ru-RU" href="https://company.ru/products/item-1" />
<link rel="alternate" hreflang="ru-KZ" href="https://company.kz/products/item-1" />
<link rel="alternate" hreflang="ru-BY" href="https://company.by/products/item-1" />
Генерация через хелпер:
function hreflangTags(string $path): string
{
$domains = SiteDomain::where('is_active', true)->get();
return $domains->map(fn($d) =>
"<link rel=\"alternate\" hreflang=\"{$d->locale}-{$d->country_code}\" href=\"https://{$d->host}{$path}\" />"
)->join("\n");
}
Проблема с куками и сессиями
При переходе пользователя между доменами сессия теряется — куки не передаются между разными доменами. Варианты:
SSO через shared token: при клике «Перейти на сайт для Казахстана» генерируется одноразовый токен, пользователь переадресуется с ним, второй домен обменивает токен на сессию.
Общее хранилище сессий: Redis с одним и тем же SESSION_DOMAIN — но браузер всё равно не передаст куку с .ru на .kz. Этот подход работает только для поддоменов.
Очередь миграций при добавлении домена
Добавление нового домена занимает не больше часа:
- Регистрация домена, настройка DNS A-записей — от нескольких минут до суток (propagation)
- Добавление в
site_domains— 2 минуты - Добавление в SAN сертификата (
certbot --expand -d new.domain) — 5 минут - Добавление
server_nameв Nginx, reload — 1 минута - Импорт или создание контента для нового домена — зависит от объёма
Мониторинг
Отдельная health check страница /health на каждом домене. Uptime Robot или Checkly пингует все домены каждую минуту, алерт при недоступности любого из них. SSL expiry проверяется отдельно — через ssl_certificate_expire метрику в Prometheus или через сторонний сервис.







