# Архитектура

## Что это за система

Одна установка Laravel обслуживает сотни сайтов. У сайтов общий код, общая база
и общие таблицы; принадлежность строки сайту определяет колонка `site_id`.
Каждый сайт при этом должен уметь выглядеть и вести себя по-своему — вплоть до
переопределения логики платформы программистом.

Три требования задают почти все решения:

1. **Скорость при 300–500 сайтах.** Страница не должна платить за то, что
   в системе много арендаторов.
2. **Расширяемость.** Новые модули, компоненты и шаблоны компонентов должны
   добавляться без правки ядра.
3. **Выгрузка отдельного сайта.** Любой сайт можно выгрузить в автономную
   копию — урезанный бэкенд плюс дамп только его строк — и запустить на обычном
   php/mysql-хостинге.

Третье требование — самое жёсткое: именно оно диктует инварианты ниже.

## Слои

```
src/Core/            ядро платформы, ничего не знает о модулях
modules/site/{key}/  модули, которые едут в автономную копию сайта
modules/platform/    только платформа; сборщик выгрузки их физически удаляет
themes/{key}/        вёрстка сайтов (Blade + Alpine)
overrides/{key}/     персайтовые переопределения (аналог легаси /b/{login})
resources/views/platform/   платформенные страницы: 404, заглушка, служебные блоки
```

Зависимости направлены строго внутрь: модуль знает про ядро, ядро про модули —
нет. Site-модуль не имеет права ссылаться на platform-модуль.

Подсистемы ядра:

| Пакет | Ответственность | Документация |
|---|---|---|
| `Core\Module` | обнаружение и загрузка модулей | [modules.md](modules.md) |
| `Core\Site` | сайты, домены, резолв хоста, контекст арендатора | [multitenancy.md](multitenancy.md) |
| `Core\Cache` | кэш на счётчиках версий | [cache.md](cache.md) |
| `Core\Settings` | типизированные настройки и их схемы | [settings.md](settings.md) |
| `Core\Structure` | разделы, пути, редиректы, резолв URL | [structure.md](structure.md) |
| `Core\Component` | компоненты и шаблоны компонентов | [components.md](components.md) |
| `Core\Field` | типы полей объектов | [components.md](components.md#типы-полей) |
| `Core\Layout` | зоны, блоки, видимость, сетка | [layout.md](layout.md) |
| `Core\Geo` | города сайта и город запроса | [catalog.md](catalog.md) |
| `Core\Media` | медиатека, относительные пути, привязки | [admin.md](admin.md#медиатека) |
| `Core\Admin` | админка: гвард, меню, экраны | [admin.md](admin.md) |
| `Core\Tenancy` | реестр таблиц по принадлежности арендатору | [database.md](database.md#реестр-таблиц) |

## Инварианты

Это не рекомендации. Нарушение любого из них ломает либо изоляцию арендаторов,
либо возможность выгрузить сайт.

### 1. `site_id` в каждой пользовательской таблице

Каждая таблица объявляется в `TenantTables` как одна из пяти категорий:

- `tenant` — строки принадлежат сайту, выгружаются по `WHERE site_id = ?`;
- `root` — первичный ключ и есть идентификатор сайта (`sites`);
- `global` — платформенный справочник, выгружается целиком;
- `ignore` — инфраструктура (очереди, кэш, миграции, сессии): в выгрузку едет
  схема, но не строки — копии нужна таблица `jobs`, а задания платформы нет;
- `platform` — данные самой платформы (`platform_admins`): не едут вовсе,
  ни строками, ни схемой.

⚠️ Пятая категория появилась после живого прогона выгрузки: `platform_admins`
была объявлена `global` — «у неё нет `site_id`», — и почта с хэшем пароля
владельца установки уехали в дамп первого же сайта. `global` означает
«справочник, который копии **нужен**». Подробности — [export.md](export.md).

Тест `TenantTableInvariantTest` проверяет это **в обе стороны**: у объявленных
таблиц колонка существует, и ни одна таблица в базе не забыта в реестре.
Второе важнее: оно ловит модуль, который завёл таблицу и не объявил её.

> В легаси таблица видимости блоков `showing_blocks` не имела ни `Catalogue_ID`,
> ни первичного ключа. Из 14 008 строк к живым блокам относились 30. Таблица
> редиректов (26 465 строк) тоже была общей на всю платформу. Персайтовый дамп
> из такой схемы построить невозможно.

### 2. Пути к медиа — относительные

В базе хранится путь относительно корня диска сайта: `img/3f/2a/….webp`.
Логин арендатора подставляет диск Laravel, а не строка в базе.

> В легаси `Multifield.Path` содержал `/a/{login}/files/multifile/…` в 1.75 млн
> строк. Переименование арендатора или выгрузка сайта означали переписывание
> всех этих строк.

### 3. Site-модуль не ссылается на platform-модуль

Проверяет Deptrac в CI (`deptrac.yaml`). Именно зелёный гейт делает безопасным
физическое удаление каталога `modules/platform` сборщиком выгрузки: если ни один
выживший файл не ссылается на удалённое пространство имён, копия соберётся.

Тем же механизмом закреплена ещё одна односторонняя зависимость: **каталог
вынесен в собственный слой и не видит других модулей**. Магазин знает про
каталог, каталог про магазин — нет; сайт с витриной без корзины обязан работать
с выключенным `shop`. Кнопку покупки каталог спрашивает у своего реестра
([shop.md](shop.md)).

### Плюс: никакого `eval`

Ни в правах, ни в условиях видимости настроек, ни в манифестах.

> Легаси вычислял выражения видимости полей через `eval()` прямо над содержимым
> JSON-файла, то есть исполнял произвольный PHP из файла данных.

## Сквозные решения

### Границу арендатора задаёт `SiteContext`

Любой код, которому нужен `site_id`, берёт его из `SiteContext`, а не из
`request()` и не из глобальной переменной. Контекст зарегистрирован как
`scoped`, а не `singleton`, и сбрасывается по событию `JobProcessing`: иначе
долгоживущий воркер очередей утащил бы данные одного арендатора в обработку
другого.

### Кэш — на счётчиках версий, не на тегах

`Cache::tags()` в коде быть не должно. Автономная копия сайта работает на
файловом драйвере, который тегов не поддерживает, а это ограничение первого дня,
а не последующая доработка. Подробности — [cache.md](cache.md).

### Writer владеет инвалидацией

Всё, что меняет путь раздела, идёт через `SectionWriter`. Всё, что массово
меняет правила видимости или порядок блоков, — через `LayoutWriter`. Причина
конкретная: `Model::query()->delete()` не поднимает события Eloquent, поэтому
наблюдатель на модели не сработает и кэш останется протухшим.

### Middleware арендатора — глобальные

`ResolveSite`, `EnforceCanonicalHost` и `BootSiteContext` стоят в глобальном
стеке, а не в группе `web`. Если положить их в группу, они не отработают на
несовпавшем маршруте — и 404 не получит ни редиректа на основной домен, ни темы
сайта.

## Точки расширения

Модуль не патчит ядро — он регистрируется в реестрах:

| Реестр | Что добавляет |
|---|---|
| `ComponentRegistry` | компонент («инфоблок») — тип раздела |
| `ComponentTemplateRegistry` | вариант вёрстки компонента для конкретного контекста |
| `BlockContentTypeRegistry` | тип содержимого блока |
| `FieldTypeRegistry` | тип поля объекта |
| `SettingsSchemaRegistry` | схема настроек модуля |
| `TenantTables` | таблицы модуля и их категория |
| `AdminMenu` | пункты левого меню админки |
| `AdminSearchProviders` | источники глобального поиска админки |
| `PurchaseActions` | кнопка покупки товара для своего режима |
| `PaymentProviders`, `DeliveryCalculator` | платёжные системы и расчёт доставки |
| `SearchEngine` | движок поиска (замена MySQL FULLTEXT) |
| `ProvidesStructuredData` | описание объекта для schema.org |
| `SiteOverride` | персайтовый код клиента (`overrides/{ключ}/Booter.php`) |
| `ExtendsAdminMenu`, `DeclaresSettings`, `DeclaresAdminRoutes` | меню, экраны настроек и адреса админки персайтового кода — только своему сайту |
| `routeFiles` провайдера | собственные экраны модуля в админке |

Все реестры наполняются в фазе `boot()` провайдера модуля. Коллизия ключей —
ошибка на старте приложения, а не тихое переопределение.

⚠️ **Регистрация — не то же самое, что доступность.** Реестры наполняются
при загрузке приложения, когда сайт ещё неизвестен, а модуль может быть
**отключён конкретному сайту** ([platform.md](platform.md#подключение-модулей-сайту)).
Поэтому регистрация несёт ключ модуля, а спрашивают о нём в момент
использования. Живой прогон поймал ровно этот разрыв: адреса корзины уже
отвечали 404, а каталог всё ещё рисовал кнопку «в корзину».

Реестры, зависящие от арендатора (кнопки покупки, доступ к модулям), —
**scoped**, а не синглтоны: в воркере очередей за один процесс проходят
разные сайты.

### Резолвер страниц — фолбэк, а не catch-all

`/{path?}` перехватывал бы адрес раньше всего, что объявлено позже:
маршрута лениво подключённого модуля, персайтового переопределения.
`Route::fallback()` матчится последним независимо от порядка объявления —
и это ровно роль резолвера дерева: он разбирает то, что не разобрал
никто другой.

### Персайтовый код живёт в `overrides/{ключ}/`

Один вход (`SiteOverride::boot()`), явный контракт, предсказуемый момент
вызова — сразу после подъёма контекста сайта и до разбора адреса.
Никакого `eval`. Сбой переопределения не роняет установку: в продакшене
он уходит в журнал, на разработке пробрасывается.

Админку своего сайта переопределение расширяет необязательными интерфейсами
на том же `Booter` — это замена легаси-манифестов `/b/{login}/manifest/*.json`.
⚠️ Всё персайтовое, что попадает в общие на процесс реестры (меню, схемы
настроек, маршруты), помечено владельцем и спрашивается по ключу текущего
сайта. Самодельная подписка из `boot()` пережила бы запрос и показала бы пункт
клиента в админке соседа.

## Гейты качества

```bash
composer gate     # deptrac + pint + phpunit
```

Архитектурный гейт идёт первым: он дешевле тестов и падает раньше, если
site-модуль полез в платформенный.
