Архитектура · PHP · Yii2 / Laravel
Архитектура PHP-приложения: усложнять, когда есть причина
Выросло из боли на проектах. Слоёв столько, сколько нужно задаче — не больше, и короткий путь до релиза. Усложняем, только если можно назвать причину.
Усложнять, если
Я начинаю с минимума: понятны вход, что с ним делать, результат. Новый класс появляется, когда можно ткнуть в боль: проверки разъехались, один и тот же вход собирают в трёх местах, в одном Service смешались create и rename и правка одного тащит зависимости другого, в JSON уходят колонки таблицы и новое поле БД сразу торчит наружу.
Три роли
Контроллер, консольная команда и Job принимают вход и отдают результат. На границе проверяют формат. «Можно ли», порядок записей и транзакция — в Service. Кто это — аргумент метода, не «текущий пользователь» внутри сервиса. Доступ к данным — ORM или Repository.
Проверка уникальности перед записью не заменяет UNIQUE в БД. Если операция зависит от изменяемого состояния — остаток, слот, лимит, — нужен UPDATE с условием вроде qty >= 1 и проверка, что строка изменилась, либо блокировка. Одна транзакция не закрывает все гонки.
Пока сценарий короткий и нужен только из API, его можно оставить в action контроллера. В Service — когда тот же код зовут из консоли или очереди, или когда action стал толстым: проверка полей, «можно ли», три таблицы, письмо.
-
HTTP CLI JobТочка входаПринять вход, проверить поля, отдать результат
- ОперацияТот же сценарий из HTTP, CLI и Job. Кто это — аргумент метода
- БазаЧтение и запись. ORM, scope или Repository — если уже есть зачем
В Yii2 Form проверяет входные поля; Search собирает фильтры списка, это не валидация POST. Ту же Form можно load() из CLI. В Laravel FormRequest — только HTTP; в команде те же rules() через Validator, без фейкового Request. Action в таблице ниже — класс сценария, не actionCreate и не invokable-контроллер Laravel. Имена разные, роли те же.
Когда хватает и когда нет
Таблица — ориентир. Нет строки про ваш случай — всё равно нужно назвать, какую боль закрывает новый класс.
| Задача | Хватает | Усложняем, когда |
|---|---|---|
| Создать или обновить с API | Form / FormRequest → метод сервиса со скалярами | Набор полей уже неудобен или тот же вход собирают в нескольких местах → DTO |
| Список с фильтрами | Search или scopes и пагинация | Одни и те же фильтры и ограничение выборки копируются → общий scope или Repository |
| Кусок входа со своим правилом | rules() или общий валидатор | Нужен тип со своим допустимым состоянием → VO. Повтор правил — повод заметить, не обязанность завести VO |
| Несколько операций одной области | Один Service с короткими методами | Один сценарий оброс зависимостями и ломает остальные методы → Action |
| Ответ API | fields(), Resource или явный массив | Публичный ответ начинает жить отдельно от хранения → Resource или массив, собранный в адаптере |
| Очередь | Job вызывает тот же Service | Результат можно отдать позже. Повтор — брокер отдал тот же Job снова. Частичный успех — часть уже сделана, повтор всего ломает. Push после коммита; если Job нельзя потерять — запись в той же транзакции и отправитель (outbox). DTO и Action — не потому что это очередь |
| Кеш | Нет кеша, читаем из БД | Измерили дорогое повторяющееся чтение и допустимую свежесть. Инвалидация после успешного коммита, не в контроллере |
Пока хватает скаляров
Простой create: Form проверила поля, контроллер взял userId из сессии и вызвал сервис. Отдельный DTO здесь не нужен. Ошибки входа отдаёт адаптер, не сервис.
$form->load($request->post(), '');
if (!$form->validate()) {
Yii::$app->response->statusCode = 422;
return ['errors' => $form->getErrors()];
}
$item = $this->items->create(
userId: $userId,
name: $form->name,
);
load($data, '') — плоское тело. Без второго аргумента Yii ждёт ключ по имени формы. В Laravel для запроса, ожидающего JSON, FormRequest сам отвечает 422. Второй адаптер сам по себе не повод заводить объект: два ясных аргумента можно передать и из CLI.
Когда набор полей неудобен
Неудобно таскать — когда сигнатура перестала читаться, а не когда стало «много аргументов». Тогда один объект. Это не Form: нет rules(), в Service его можно передать.
final class CreateItemInput
{
public function __construct(
public readonly int $userId,
public readonly string $name,
public readonly ?string $note,
) {}
}
Когда правило начинает жить отдельно
Форма сказала OK — и на этом проверка кончилась. Дальше данные едут по коду, и их уже можно изменить так, что правило больше не выполняется. Класс значения такое не создаёт: инвариант в конструкторе, поля нельзя поменять. Дальше передаём VO, не скаляры.
VO вводим, когда нужно самостоятельное значение и его допустимое состояние дальше по коду. Повтор правил в API, CLI и сервисе помогает это заметить; сам по себе повтор ещё не требует VO — иногда хватает общего валидатора. Один сценарий тоже может оправдать тип, если у значения свои правила и операции.
Значение из запроса удобно собрать до вызова сервиса: ошибку проще привязать к полю. Результат вычисления внутри операции собирают в сервисе. Ожидаемую ошибку входа превращаем в ответ сами. Где бросили исключение, HTTP-статус не задаёт. Оборачивать каждое поле не нужно.
Чего не делать
Если паттерн не упрощает чтение и не убирает дублирование — он не нужен сейчас. Ниже — соглашения подхода. Form в Service технически передать можно; тогда сервис зависит от модели валидации фреймворка и её изменяемого состояния. FormRequest — уже от HTTP.
- Form и FormRequest в Service не передаём.
- Класс extends Model с rules(), названный *Dto, — это Form под чужим именем. Переименовать.
- Repository с теми же find/save, что у ORM, — не слой, а переименование.
- Service, который только проксирует Repository и ничего не добавляет, не нужен.
- Не копировать все rules() формы во входной DTO «на всякий случай».
Чей это ресурс — не из входных данных
В HTTP клиент шлёт JSON и query. Кто он — из сессии или токена. Этот id передаём в Service аргументом. Id в запросе — заявка, не право. Список без id всё равно режется по тому, кто залогинен, не по тому, что прислали. Совпадение id в if не заменяет ограничение выборки в запросе. Проверенный int ничего не меняет.
В Job в payload кладём, от чьего имени запрошена операция. Это не право действовать от этого имени. Права проверяем при постановке, при execute или на обоих этапах — это нужно выбрать. Ставят не только из HTTP: cron, событие, другой Job. Worker — консоль, сессии HTTP нет. «Текущий пользователь» приложения будет пустой. Берём то, что записали в Job. requestId с границы — в Job и в лог; execute пишет его, не «текущий запрос» воркера.
В консоли параметр задаёт, от чьего имени запрошена операция; право действовать от этого имени проверяется отдельно. Cron может идти под служебной ролью, без пользователя.
Проверку «можно ли» нельзя оставить только в контроллере: консоль и очередь этот код не выполнят. Если сценарий общий — проверка живёт рядом с ним, в Service. Кто это — из сессии, из payload Job, из параметра cron или служебная роль, аргументом метода.
public readonly int $userId;
// Yii2 Queue
public function execute($queue): void
{
Yii::$container->get(ReportService::class)
->export($this->userId);
}
// Laravel
public function handle(ReportService $reports): void
{
$reports->export($this->userId);
}
Слои и тесты
Упростили сценарий — Action и DTO можно убрать. Снимать слой, который уже зовут из трёх мест, больнее, чем не заводить.
В тестах слои не смешиваю. Инвариант значения — создаю объект, без HTTP: если для этого нужен запрос, правило сидит не в VO. Ограничение выборки и запись — на БД; мок репозитория не доказывает WHERE и unique. Статус и текст ошибки — в контроллере или команде: сервис говорит «нельзя», 422 или код выхода ставит адаптер.
AGENTS.md для проекта
Два самостоятельных шаблона инструкций для AI-агента: общие принципы этой статьи и особенности выбранного фреймворка. Оба файла на английском; каждый скачивается под именем AGENTS.md.
Перед использованием объедините шаблон с существующими инструкциями проекта, проверьте соглашения и укажите реальные команды проверок. Шаблон не задаёт обязательную структуру каталогов и не требует внедрять все перечисленные классы.