Тема
ТЗ — Мультиязычность (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_locales | id, code, title, is_default, fallback_code, sort_order | список активных локалей сайта |
cms_translation_groups | id, entity_type | группа связанных переводов одной сущности |
cms_translation_status | entity_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_order | FormRequest whitelist, code — ISO 639-1/whitelist активных, manage-право |
| Действие «создать перевод» (карточка записи) | entity_type, entity_id, target_locale | FormRequest + проверка существования translation_group_id/исходника, право владельца сущности (pages.manage/content.{type}.manage) |
Заголовок Accept-Language (публичный запрос) | строка локали | whitelist кодов из cms_locales (активные), иначе игнор → дефолт |
URL-сегмент /{locale}/ | строка локали | whitelist активных кодов; неизвестный код → 404, не подмена контента |
Событие ядра PageSaved | entity_id, updated_at | внутренний payload события (типизация класса события, не пользовательский ввод) |
Событие ядра ContentEntrySaved | entity_type, entity_id, updated_at | то же |
Импорт легаси (cms:multilang:import-legacy) | external_id, locale, status, updated_at | маппер профиля источника + --dry-run отчёт расхождений |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Публичный фронт (виджет-переключатель, блок) | список активных локалей + текущая + карта соответствий translation_group_id | Blade-компонент / 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_default | string | record_per_locale | да | Стратегия по умолчанию для новых типов контента |
multilang.fallback_chain | array | ["ru"] | да | Цепочка fallback-локалей |
multilang.untranslated_policy | string | fallback | да | Поведение при отсутствии перевода (404/fallback) |
multilang.locale_in_url | bool | true | да | Локаль как сегмент URL (обязательно для SEO) |
multilang.switcher_widget_enabled | bool | true | нет | Показ виджета переключателя локали |
multilang.max_locales | int | 10 | да | Лимит числа активных локалей сайта (защита от разрастания вариантов page-cache) |
multilang.recompute_on_save_enabled | bool | true | нет | Kill-switch: пересчёт статуса перевода синхронно по PageSaved/ContentEntrySaved; выключение оставляет только плановый пересчёт по расписанию — без выключения модуля целиком |
Достижение max_locales — понятная ошибка в форме создания локали и метрика, не 500 и не тихий отказ.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/locales | public | Список активных локалей с кодами и дефолтом |
| GET | /api/v1/admin/multilang/translation-status | admin (multilang.view) | Матрица переводов (что не переведено/устарело) |
| GET/PUT | /api/v1/admin/multilang/locales | admin (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_id—nullable, статус считается по паре(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-locale | universal (пути не выданы — только имя проекта) |
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 + locale→external_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запрещены