Skip to content

ТЗ — Мультиязычность (cms/multilang)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: referendum, universal Статус: ТЗ к разработке

Назначение и возможности

Модуль-реализация доменной модели мультиязычности ядра (/cms-v2/multilang): две стратегии перевода контента, резолв локали, fallback-цепочки, hreflang и админ-инструменты контроля переводов. Без модуля сайт работает в режиме single-locale на заготовке ядра.

  • Стратегия А — переводимые атрибуты (JSON-поля, spatie-подход) для типов контента
  • Стратегия Б — record-per-locale с translation_group_id (дефолт для страниц с блоками)
  • Резолв локали: сегмент URL → сессия/cookie → Accept-Language → дефолт
  • Fallback-цепочка локалей (например kk → ru → default), конфигурируемая per-проект
  • hreflang + x-default на всех индексируемых страницах
  • Переключатель локали (виджет) — только на существующие переводы группы
  • Матрица переводов в админке: что переведено, что устарело (по updated_at ревизий)
  • UI управления списком локалей сайта и строками UI-переводов (lang/)

Зависимости и выключение

requires: ядро

Поведение при выключении: сайт возвращается к single-locale (дефолтная локаль ядра), переключатель языка скрывается, hreflang не генерируется — переведённый контент не удаляется, но становится недоступен по языковым URL до повторного включения.

Модель данных

ТаблицаКлючевые поляПримечание
cms_localesid, code, title, is_default, fallback_code, sort_orderсписок активных локалей сайта
cms_translation_groupsid, entity_typeгруппа связанных переводов одной сущности
cms_translation_statusentity_type, entity_id, locale, status, source_updated_atстатус «переведено/устарело» по сравнению с исходником

Контентные сущности (стратегия Б) используют существующие колонки locale, translation_group_id из моделей ядра — своих дублирующих таблиц под них модуль не создаёт. entity_type + entity_id + locale — составной индекс на cms_translation_status; status — PHP Enum (translated/outdated/missing). Для сущностей на стратегии А (JSON-атрибуты) translation_group_id не применим — колонка nullable, обязательна только для записей стратегии Б; статус для стратегии А считается по паре (entity_id, locale) внутри одной записи (см. «Крайние случаи», ⚠️ Противоречие).

ПДн-паспорт: модуль не хранит персональные данные. cms_locales, cms_translation_groups, cms_translation_status содержат только технические метаданные (коды локалей, статусы переводов, ссылки на сущности) — ни имён, ни контактов, ни IP. Хуки ядра «выгрузить всё по субъекту» / «забыть по запросу» для модуля не применимы.

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
Форма CRUD локали (админка)code, title, is_default, fallback_code, sort_orderFormRequest whitelist, code — ISO 639-1/whitelist активных, manage-право
Действие «создать перевод» (карточка записи)entity_type, entity_id, target_localeFormRequest + проверка существования translation_group_id/исходника, право владельца сущности (pages.manage/content.{type}.manage)
Заголовок Accept-Language (публичный запрос)строка локалиwhitelist кодов из cms_locales (активные), иначе игнор → дефолт
URL-сегмент /{locale}/строка локалиwhitelist активных кодов; неизвестный код → 404, не подмена контента
Событие ядра PageSavedentity_id, updated_atвнутренний payload события (типизация класса события, не пользовательский ввод)
Событие ядра ContentEntrySavedentity_type, entity_id, updated_atто же
Импорт легаси (cms:multilang:import-legacy)external_id, locale, status, updated_atмаппер профиля источника + --dry-run отчёт расхождений

Выходы:

ПотребительДанныеФормат
Публичный фронт (виджет-переключатель, блок)список активных локалей + текущая + карта соответствий translation_group_idBlade-компонент / JSON пропсов блока
SEO-резолвер ядраhreflang/x-default ссылки для текущей группы переводамассив {locale, url} в <head>
GET /api/v1/localesкоды, названия, дефолт-флаг{data: [...]}
GET /api/v1/admin/multilang/translation-statusматрица переводов (сущность × локаль × статус){data: [...], meta} (keyset)
Подписчики событий TranslationCreated/TranslationOutdatedфакт создания/устаревания переводаpayload события
cms:multilang:status --jsonотчёт по непереведённому/устаревшему контентуJSON

Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта).

Настройки (группа multilang)

КлючТипДефолтaffectsPageCacheОписание
multilang.strategy_defaultstringrecord_per_localeдаСтратегия по умолчанию для новых типов контента
multilang.fallback_chainarray["ru"]даЦепочка fallback-локалей
multilang.untranslated_policystringfallbackдаПоведение при отсутствии перевода (404/fallback)
multilang.locale_in_urlbooltrueдаЛокаль как сегмент URL (обязательно для SEO)
multilang.switcher_widget_enabledbooltrueнетПоказ виджета переключателя локали
multilang.max_localesint10даЛимит числа активных локалей сайта (защита от разрастания вариантов page-cache)
multilang.recompute_on_save_enabledbooltrueнетKill-switch: пересчёт статуса перевода синхронно по PageSaved/ContentEntrySaved; выключение оставляет только плановый пересчёт по расписанию — без выключения модуля целиком

Достижение max_locales — понятная ошибка в форме создания локали и метрика, не 500 и не тихий отказ.

API

МетодПутьДоступНазначение
GET/api/v1/localespublicСписок активных локалей с кодами и дефолтом
GET/api/v1/admin/multilang/translation-statusadmin (multilang.view)Матрица переводов (что не переведено/устарело)
GET/PUT/api/v1/admin/multilang/localesadmin (multilang.manage)CRUD списка локалей и fallback-цепочки

Компоненты

Блоки (BlockRegistry): «Переключатель языка» — demo-props показывают 3 демо-локали без ручного ввода, вставка не даёт CLS (резерв ширины под флаг/код), список локалей доступен с клавиатуры (role="listbox", стрелки/Enter), без тяжёлых ассетов — lazy-load не нужен. Filament: страница матрицы переводов (фильтр по типу/локали/статусу), редактор списка локалей, действие «создать перевод» (копия структуры записи в целевую локаль). Команды: cms:multilang:status --json (отчёт по непереведённому/устаревшему контенту).

Демо-контент: сидер создаёт 3 демо-локали (ru/en/kk) и по одному демо-переводу на каждую из существующих демо-страниц/записей ядра — playground и /_gallery показывают переключатель и матрицу переводов без ручного ввода.

События и обмен

СобытиеКогдаPayload
TranslationCreatedсоздан перевод записи в новую локальentity_type, entity_id, translation_group_id, locale
TranslationOutdatedисходник изменился позже переводаentity_type, entity_id, locale

Слушает: PageSaved, ContentEntrySaved из ядра — пересчитывает статус «устарело» для переводов группы. Provides-контрактов не реализует, FilterBus не использует.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро: PageSavedсобытие (канал 1)ядро → multilangПересчёт статуса перевода (translated/outdated) для группы страницы
Ядро: ContentEntrySavedсобытие (канал 1)ядро → multilangПересчёт статуса перевода для записи типа контента
Ядро: RequestContextсервисный контракт ядраmultilang → ядроЧтение текущей locale для резолва и скоупа кеша
Ядро: SettingsStoreсервисный контракт ядраmultilang → ядроЧтение/декларация группы настроек multilang.*
Ядро: SEO-резолверпотребление данных на рендереmultilang → ядроhreflang/x-default-ссылки, которые ядро вставляет в <head>
TranslationCreated/TranslationOutdatedсобытие (канал 1)multilang → подписчикиПрочие модули (например поиск) при подписке переиндексируют перевод
cms/multicity (если включён)совместное измерение (не канал обмена)locale и city_id одновременно участвуют в RequestContext и ключах кеша (граница)
cms/multilang:import-legacyкоманда (не канал обмена)оператор → multilangРазовый маппинг донорских данных, см. «Донорский код»

Фоновая работа

Джоба cms:multilang:status (очередь multilang) — пересчёт матрицы переводов по расписанию через ScheduleRegistrar ядра, дополнительно запускается слушателями PageSaved/ContentEntrySaved (пока включён multilang.recompute_on_save_enabled). Большие объёмы — чанками через Bus::batch, прогресс виден в Filament.

Ранбук (эксплуатация):

  • Метрики: длительность cms:multilang:status, число сущностей со статусом outdated, отставание очереди multilang.
  • Алерты: очередь multilang не разгребается > 1 часа; доля outdated растёт без снижения после ручного прогона.
  • Симптом → команда: переключатель показывает несуществующие переводы → cms:multilang:status --json (пересчёт вручную) → cms:doctor --json (проверка консистентности cms_locales); локаль «зависла» в матрице как pending → повторный прогон cms:multilang:status идемпотентен, безопасен на живом сайте.
  • Бэкап/рестор: в бэкап попадают все три таблицы модуля (cms_locales, cms_translation_groups, cms_translation_status); после рестора матрица переводов пересоздаётся командой cms:multilang:status, если рестор частичный (только контент без таблиц модуля) — рестор без этого прогона не считается завершённым.

Производительность и кеш

Ожидаемые объёмы: активных локалей — единицы (лимит multilang.max_locales, дефолт 10); строк cms_translation_status — число переводимых сущностей × число локалей (десятки тысяч на крупном сайте). Горячий путь — резолв локали и рендер переключателя на каждом публичном запросе: резолв берёт locale из уже готового RequestContext (0 доп. запросов), переключатель читает список локалей и карту translation_group_id из кеша группы настроек/тега multilang (контрактный тест на 0 запросов). Критичные индексы: составной (entity_type, entity_id, locale) на cms_translation_status, уникальный индекс на cms_locales.code. Тег multilang — на переключателе локали и резолве fallback-цепочки, инвалидация по TranslationCreated/TranslationOutdated и по SettingChanged группы multilang. Ключи page-cache учитывают locale как измерение (performance).

Безопасность

Границы входа: FormRequest-whitelist на CRUD локалей (админка, multilang.manage). Публичный GET /api/v1/locales — только чтение, без пользовательского ввода. Резолв локали из Accept-Language и из URL-сегмента санитизируется whitelist-ом активных кодов локалей (защита от инъекции произвольного значения в URL/подмены контента — клоакинг); неизвестный код никогда не доходит до резолвера контента. Название локали (title) — обычная строка, выводится через , доверенного HTML модуль не хранит и не рендерит.

Матрица ролей:

ПравоАдминМенеджерРедакторStudio
multilang.view (матрица переводов, список локалей)
multilang.manage (CRUD локалей, fallback-цепочка, смена дефолтной локали)
Действие «создать перевод» записипо праву владельца записи (pages.manage/content.{type}.manage), не по multilang.*

Удаление локали и смена дефолтной локали — необратимые по влиянию на публичные URL операции, поэтому доступны только multilang.manage (админ/studio) и требуют подтверждения в UI (см. «Крайние случаи»).

UX-требования

Админ: пустое состояние списка локалей (только дефолтная) — подсказка «добавить локаль» вместо пустой таблицы; массовое действие «пересчитать статус» для выбранных типов контента в матрице переводов; человеческая ошибка при достижении multilang.max_locales («лимит локалей исчерпан, увеличьте в тарифе/настройках», не голый 422); подтверждение с указанием числа затронутых страниц перед удалением локали и перед сменой дефолтной локали.

Посетитель: резолв локали и рендер переключателя — без ощутимой задержки (0 дополнительных запросов на горячем пути, см. «Производительность»); переключатель ведёт на ту же страницу в целевой локали, если перевод существует, — не на главную; язык интерфейса переключателя обозначен нативным lang/hreflang, доступен с клавиатуры, у активной локали — aria-current.

Крайние случаи и типовые баги

  • Fallback-цепочка не находит перевод нигде (kk → ru → default, а ни у ru, ни у default перевода нет) → показывается контент дефолтной локали as is (это и есть конец цепочки), баннер «показан контент на языке по умолчанию» не показывается повторно — цепочка исчерпана легитимно, а не как ошибка.
  • Непереведённая сущность: untranslated_policy=404 → страница /kk/o-nas/ без перевода отдаёт 404 (с hreflang на реально существующие локали в <head> соседних версий, не на все активные локали сайта); untranslated_policy=fallback → та же страница отдаёт содержимое ru-версии по URL /kk/... с canonical на /ru/o-nas/ — без этого canonical дубль контента попадает в индекс дважды.
  • hreflang при частичном переводе группы: в <head> перечисляются только локали, реально входящие в translation_group_id текущей группы, не полный список активных локалей сайта — иначе поисковик получает hreflang-ссылки, ведущие на 404.
  • Переключение локали без перевода целевой страницы: при untranslated_policy=fallback переключатель остаётся на текущей странице (fallback-показ); при =404 переключатель ведёт на главную целевой локали с уведомлением «страница не переведена» — прямой переход на несуществующий перевод запрещён в обоих случаях.
  • ⚠️ Противоречие: стратегия А не имеет translation_group_id. Модуль ожидает translation_group_id в cms_translation_status (модель данных выше), но это поле существует только у записей стратегии Б (record-per-locale); тип контента на стратегии А — одна JSON-запись без группы записей. Разрешение: для стратегии А колонка translation_group_idnullable, статус считается по паре (entity_id, locale) внутри самой JSON-записи, группа не создаётся; действие «создать перевод» для стратегии А работает не копированием записи, а установкой недостающего JSON-ключа локали у существующей записи.
  • Смена дефолтной локали сайта задним числом: URL без сегмента, ранее принадлежавшие старому дефолту, конфликтуют с новым — обязателен массовый 301-редирект /{старый-дефолт}/... на прежние адреса контента; операция недоступна без подтверждения (см. UX) и логируется в аудит.
  • Удаление локали из активных при наличии переведённого контента: запрещено без явного подтверждения с числом затронутых страниц/записей; после подтверждения переводы не удаляются из БД (soft — недоступны по языковым URL), удаление данных — отдельный uninstall-сценарий модуля (§3 стандарта).
  • Гонка редактирования: исходник и перевод правят два админа одновременно — сущности несут lock_version (optimistic lock ядра), второе сохранение получает 409 с человеческим сообщением. Для стратегии А (один JSON-документ на все локали) конфликт наступает даже когда админы правят разные локали одной записи — известное ограничение стратегии, задокументировано как компромисс компактности.
  • Массовый пересчёт статуса на большом каталоге: cms:multilang:status обязан идти чанками (Bus::batch), не одной транзакцией — иначе блокирует очередь multilang на крупных сайтах.
  • Все локали, кроме дефолтной, удалены: сайт корректно откатывается к single-locale, виджет переключателя скрывается автоматически (не рендерит пустой список из одного пункта).

Донорский код

Что взятьПуть
Стратегия А (spatie-переводимые атрибуты)referendum (referendum/app/ — пути не выданы точечно)
Мультигородная модель как аналог record-per-localeuniversal (пути не выданы — только имя проекта)

Legacy-импорт. cms:multilang:import-legacy --source=<профиль> [--dry-run]:

  • профиль referendum — донор хранит переводы в JSON-колонках (фактически стратегия А) → прямой маппинг на cms_translation_status со status=translated, резолв сущности по slug; группы (translation_group_id) не создаются (см. ⚠️ выше);
  • профиль universal — мультигородная модель донора не содержит языковых переводов, маппер для контента неприменим; команда тем не менее регистрируется для будущих клиентских переездов с внешних CMS (WPML/Polylang-экспорт: post_id + localeexternal_id, дерево переводов по общему external_group_id);
  • идемпотентность — ключ external_id (повторный прогон обновляет статус, не дублирует запись); --dry-run — отчёт «прочитано/создано/обновлено/пропущено» с построчными расхождениями, без применения.

Тесты и приёмка

  • [ ] Контрактный тест резолвера локали: URL → сессия → Accept-Language → дефолт
  • [ ] При выключении модуля сайт корректно откатывается на single-locale без 500
  • [ ] hreflang/x-default присутствуют на всех индексируемых страницах с переводами, и только для реально существующих в группе локалей (не для всех активных сайта)
  • [ ] Матрица переводов в админке верно помечает «устарело» по сравнению updated_at
  • [ ] Права multilang.manage не позволяют публичному пользователю менять список локалей; менеджер/редактор не видят действий CRUD локалей (матрица ролей выше)
  • [ ] Ключи page-cache учитывают locale; инвалидация по тегу при TranslationCreated
  • [ ] Нет N+1 при построении переключателя локали (->with('translations'))
  • [ ] Fallback-цепочка, исчерпанная до конца (ни у промежуточной, ни у дефолтной локали перевода нет), не зацикливается и не 500-ит
  • [ ] Стратегия А: статус перевода считается без translation_group_id (поле nullable), контрактный тест на тип контента без группы записей
  • [ ] Смена дефолтной локали задним числом создаёт 301-редиректы со старых безсегментных URL
  • [ ] Удаление локали с переведённым контентом требует подтверждения и не удаляет данные физически (soft, до explicit uninstall)
  • [ ] Конкурентное редактирование исходника/перевода даёт 409 по lock_version, не «последний победил» молча
  • [ ] multilang.max_locales — превышение лимита даёт понятную ошибку, не 500
  • [ ] cms:multilang:import-legacy --dry-run прогнан на копии данных referendum, идемпотентность повторного прогона подтверждена тестом
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (/api/v1/locales, матрица переводов, CRUD локалей)
  • [ ] Тестовая БД только multilang_test; migrate:fresh/refresh/reset/db:wipe запрещены

Внутренняя база знаний студии