Василий Кривопляс
← На главную

Архитектура · PHP · Yii2 / Laravel

Архитектура PHP-приложения: усложнять, когда есть причина

Выросло из боли на проектах. Слоёв столько, сколько нужно задаче — не больше, и короткий путь до релиза. Усложняем, только если можно назвать причину.

Усложнять, если

Я начинаю с минимума: понятны вход, что с ним делать, результат. Новый класс появляется, когда можно ткнуть в боль: проверки разъехались, один и тот же вход собирают в трёх местах, в одном Service смешались create и rename и правка одного тащит зависимости другого, в JSON уходят колонки таблицы и новое поле БД сразу торчит наружу.

Три роли

Контроллер, консольная команда и Job принимают вход и отдают результат. На границе проверяют формат. «Можно ли», порядок записей и транзакция — в Service. Кто это — аргумент метода, не «текущий пользователь» внутри сервиса. Доступ к данным — ORM или Repository.

Проверка уникальности перед записью не заменяет UNIQUE в БД. Если операция зависит от изменяемого состояния — остаток, слот, лимит, — нужен UPDATE с условием вроде qty >= 1 и проверка, что строка изменилась, либо блокировка. Одна транзакция не закрывает все гонки.

Пока сценарий короткий и нужен только из API, его можно оставить в action контроллера. В Service — когда тот же код зовут из консоли или очереди, или когда action стал толстым: проверка полей, «можно ли», три таблицы, письмо.

  1. HTTP CLI Job
    Точка входаПринять вход, проверить поля, отдать результат
  2. ОперацияТот же сценарий из HTTP, CLI и Job. Кто это — аргумент метода
  3. БазаЧтение и запись. 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 здесь не нужен. Ошибки входа отдаёт адаптер, не сервис.

PHP 8.1+ · Form проверила, сервис получил скаляры
$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 его можно передать.

PHP 8.1+ · поля одним объектом, без rules()
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 или служебная роль, аргументом метода.

PHP 8.1+ · userId с Job; Yii2 execute($queue), Laravel handle()
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.

Перед использованием объедините шаблон с существующими инструкциями проекта, проверьте соглашения и укажите реальные команды проверок. Шаблон не задаёт обязательную структуру каталогов и не требует внедрять все перечисленные классы.

← На главную