PHPArkitect: архитектурные правила как исполняемый код
1. Введение: почему архитектура умирает молча
Архитектура в PHP-проектах умирает не от одного фатального решения, а от тысячи мелких. Контроллер обратился к репозиторию напрямую — «ну, по-быстрому». Сервис затащил зависимость из чужого модуля — «оно же рядом лежит». Слой Domain начал знать про инфраструктуру — «потом разберёмся». Потом приходит новый разработчик, видит, что «так можно», и делает так же. Теперь это не исключение, а норма, и проект медленно превращается в большой комок связанности, который страшно менять.
Проблема не в том, что архитектуру не описывают. Её описывают: ADR, диаграммы, раздел в README. Проблема в том, что документация не исполняется. Запись «слой Domain не зависит от Infrastructure» никого не останавливает: в доменном классе спокойно появляется use App\Infrastructure\Mailer;. Ревью это не ловит: человек не может помнить все границы проекта и устаёт через двадцать минут. Когда нарушений набирается десяток, исправлять уже поздно: зависимости переплелись, и вытаскивать их — отдельный проект. Чем позже обнаружено нарушение, тем дороже оно обходится.
Единственный способ держать архитектуру живой — проверять её на каждом изменении кода, автоматически. В Java-мире это давно стандарт: ArchUnit появился в 2014 году и встроился в CI тысяч проектов. В PHP такой инструмент появился в июне 2020-го — это PHPArkitect. О нём и пойдёт речь: что это, как выглядит на реальном проекте и как внедрять без боли.
2. Что такое PHPArkitect
PHPArkitect — библиотека, которая запускается в CI и проверяет код на соответствие архитектурным правилам. Ключевая идея: правила пишутся на PHP, тем же языком, что и сам проект. Никаких DSL и YAML-схем — конфиг выглядит как тест и проходит ревью как код.
Пара цифр, чтобы понять масштаб (сверено 17.08.2026):
- создан 30.06.2020, вдохновлён ArchUnit из Java-мира;
- GitHub: ~920 звёзд (924 на Packagist), 52 форка, лицензия MIT;
- Packagist: 4 625 927 установок, 21 зависимый пакет;
- актуальная версия 1.3.0 (31.07.2026), требует PHP ^8.0.
Установка — одна команда:
composer require --dev phparkitect/phparkitect
Минимальный конфиг — файл phparkitect.php в корне проекта:
<?php
use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Expression\ForClasses\NotHaveDependencyOutsideNamespace;
use Arkitect\Rules\Rule;
$src = ClassSet::fromDir(__DIR__.'/src');
return static function (Config $config) use ($src): void {
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\Domain'))
->should(new NotHaveDependencyOutsideNamespace('App\Domain'))
->because('доменный слой изолирован от остального мира')
);
};
ClassSet::fromDir() — набор файлов, которые анализируются; их может быть несколько. Дальше — правила.
3. Анатомия правила: that / should / because
Каждое правило — цепочка из трёх частей, и это лучшее, что есть в инструменте: правила читаются как предложения.
that(...)— к кому применяется правило: классы из определённых namespace'ов, классы с определённым именем, наследники базового класса.should(...)— что они обязаны или не обязаны делать: не зависеть от чужих namespace'ов, наследоваться от нужного класса, заканчиваться на суффикс, быть final, содержать атрибут.because(...)— зачем. Этот текст попадает в отчёт об ошибке — разработчик, который нарушил правило, сразу видит, какое соглашение он задел и почему оно существует.
Запуск:
vendor/bin/phparkitect check
Для удобства — composer-скрипт:
"scripts": {
"arkitect": "phparkitect check"
}
Есть и вспомогательные команды: init — сгенерировать стартовый конфиг с примерами правил; debug:expression — показать, какие классы попадают под выражение, до того как вы добавили его в конфиг. Вторая команда экономит много итераций: можно проверить селектор на живом коде, не трогая конфиг.
4. Что умеет из коробки
Выражения покрывают типовые архитектурные соглашения, которые встречаются в каждом втором проекте:
- Именование: контроллеры заканчиваются на
*Controller, сервисы — на*Service, в проекте не появляются классы-«помойки» вроде*Helper. - Наследование: все классы в namespace —
final, либо, наоборот, наследуются от конкретного базового класса. - Зависимости: классы из
App\Domainне зависят от классов за пределами namespace;App\Controllerне обращается кApp\Infrastructure. - Атрибуты и DocBlock: класс содержит определённый PHP-атрибут или аннотацию (и наоборот — не содержит устаревшие).
- Компоненты: namespace'ы группируются в «компоненты», между ними задаётся строгая матрица зависимостей: компонент A может зависеть от B и C, но не от D.
Три типовых сценария: нельзя (запрет зависимостей), обязано (наследование, атрибуты, именование) и только так (компонентные границы). Для Laravel есть готовая обёртка с предустановленными правилами — smortexa/laravel-arkitect, а демо-проект с примерами — phparkitect/arkitect-demo.
5. Кейс: 19 правил модульного монолита
Посмотрим на инструмент в деле. Мой пет-проект — Tender Platform, модульный монолит на Symfony: 13 модулей — 9 бизнес-модулей, 3 платформенных и policy-плагин — разделяющих общий Shared kernel. Ключевой архитектурный инвариант: модуль не заглядывает во внутренности другого модуля, только в публичные контракты. Нарушение этого инварианта — первый шаг к монолиту-бетону, где модули существуют только номинально.
Конфиг phparkitect.php содержит 7 групп правил, которые при разворачивании дают 19 проверок. Пример — контроллеры не вызывают инфраструктуру напрямую:
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces(
'App\Controller',
'App\Iam\Controller',
'App\Tender\Controller',
// ... остальные модули
))
->should(new NotDependsOnTheseNamespaces([
'App\Infrastructure',
]))
->because('контроллеры вызывают UseCase/сервисы, а не инфраструктуру напрямую')
);
Самый интересный приём — генерация правил циклом. Границы модулей не описываются 13 раз вручную: они собираются из списка модулей и списка «внутренностей» — Controller, Command, Entity, Repository, Form, Input, Presenter, Exception, Storage, Rules, State, Stream, Timer, Step, Timeline, Service:
$moduleNamespaces = ['App\Iam', 'App\Tender', /* ... */];
$moduleInternals = ['Controller', 'Command', 'Entity', /* ... */];
foreach ($moduleNamespaces as $module) {
$forbidden = [];
foreach ($moduleNamespaces as $other) {
if ($other === $module) {
continue;
}
foreach ($moduleInternals as $internal) {
$forbidden[] = $other.'\\'.$internal;
}
}
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces($module))
->should(new NotDependsOnTheseNamespaces($forbidden))
->because('граница модуля: '.$module.' не заглядывает во внутренности других модулей')
);
}
13 модульных границ порождаются десятью строками кода. Осознанные исключения — публичные контракты, read-модели, enum'ы как value-типы — объявляются явным whitelist'ом: кросс-модульный доступ к внутренностям без записи в whitelist автоматически становится нарушением.
Прогон на текущем коде: 646 классов в src/, проверка заняла 14,35 секунды, нарушений — 0.
6. Как это живёт в CI
Проверка подключена в quality-пайплайн GitHub Actions и выполняется на каждом PR — после PHPStan, до тестов:
- name: PHPArkitect (architecture)
run: composer arkitect
Нарушение границы больше не попадает в main: PR просто не проходит. Посмотрим на отчёт. Минимальный пример: доменный класс Order начал использовать Mailer из инфраструктуры. PHPArkitect на двух классах отрабатывает за 0,28 секунды и выдаёт:
⚠️ 1 violations detected!
App\Domain\Order has 1 violations
depends on App\Infrastructure\Mailer, but should not depend on these namespaces:
App\Infrastructure because доменный слой изолирован от инфраструктуры (on line 11)
Класс-нарушитель, конкретная зависимость, текст из because(...) и номер строки. Разработчику не нужно гадать: правило объясняет само себя. CI падает с exit code 1 — merge заблокирован.
Оверхед в 14 секунд на 646 классов — приемлемая цена за гарантию того, что границы не размываются. Если хочется быстрее или строже:
--stop-on-failure— упасть на первом же нарушении;--format=json/--format=gitlab— машинные отчёты: JSON для GitHub Actions и SonarQube, GitLab code quality — для GitLab CI;--target-php-version=8.0..8.5— указать версию PHP, под которую парсить код, если она отличается от версии запуска.
7. Legacy: стратегия baseline
Главный вопрос всех, у кого «боевой» проект: «у нас уже 500 нарушений — включать проверку бессмысленно». Для этого есть baseline:
# зафиксировать текущие нарушения как «исторические»
vendor/bin/phparkitect check --generate-baseline
# дальше обычный прогон: старые нарушения не блокируют, новые — блокируют
vendor/bin/phparkitect check
Инструмент пишет текущий список нарушений в phparkitect-baseline.json. Дальше работает правило: существующие нарушения не роняют CI, новые — роняют. Команда исправляет legacy в своём темпе, а регресс исключён: откатить исправленное нарушение нельзя — оно снова станет «новым» и уронит сборку.
Это та же стратегия, что у PHPStan (--generate-baseline) и Psalm: не «всё или ничего», а контролируемый переход. Нюансы:
--ignore-baseline-linenumbers— сравнивать без учёта номеров строк; удобно, когда код активно двигается, но тогда инструмент не заметит повторное нарушение правила в том же файле;--skip-baseline— временно выключить baseline, например для честной полной проверки перед релизом.
8. Грабли и ограничения
Инструмент честный, но несколько вещей стоит знать заранее.
Ложные срабатывания на атрибутах. PHPArkitect анализирует зависимости классов, и проверки могут цепляться к техническим конструкциям — например, Doctrine-атрибутам в сущностях. В Tender Platform это решено формулировкой правил: сущностям запрещены зависимости от App\Controller и App\Infrastructure, а атрибуты фреймворка в правила не входят. Если наткнулись на ложное срабатывание — сначала уточните формулировку правила, а не добавляйте исключение. В annotation-тяжёлых проектах (Doctrine, Symfony) разбор кастомных аннотаций можно отключить — $config->skipParsingCustomAnnotations() — это ускоряет прогон.
PHAR вместо composer. Если зависимости проекта конфликтуют с зависимостями PHPArkitect, есть самодостаточный phparkitect.phar из GitHub releases. Для кастомных правил при запуске через PHAR обязателен флаг --autoload=vendor/autoload.php.
Статический анализ — это не рантайм. PHPArkitect проверяет исходники, а не поведение: класс, созданный динамически через рефлексию или eval, может выпасть из анализа. Для архитектурных границ это ок; для runtime-инвариантов — нет.
Правила надо поддерживать. Конфиг — это код: изменения границ проходят через PR и ревью, а не молчаливое редактирование. Это фича: изменение архитектурного решения становится видимым событием в истории проекта.
Оверхед на больших кодовых базах. На сотнях тысяч строк прогон может занять минуты. Решения: baseline, запуск только на изменённых путях, вынос проверки в отдельный job, который не тормозит локальные итерации.
9. Альтернативы: deptrac, phpat и PHPStan
PHPArkitect — не единственный инструмент в нише. Сравнение по ключевым критериям:
| Критерий | PHPArkitect | Deptrac | PHPat | PHPStan (custom rules) |
|---|---|---|---|---|
| Формат правил | PHP-код, цепочки that/should |
YAML: слои, правила зависимостей | PHP-код (расширение PHPStan) | PHP-классы на уровне AST |
| Классические проверки | именование, наследование, атрибуты, зависимости | только граф зависимостей | зависимости, наследование, трейты | всё, но руками |
| Baseline | ✅ --generate-baseline |
✅ | ✅ (через PHPStan) | ✅ --generate-baseline |
| Порог входа | низкий | низкий | средний | высокий |
| Кому подходит | «тесты для архитектуры» | визуализация и контроль слоёв | стек уже на PHPStan | точечные инварианты с типами |
Deptrac силён в графовом анализе и визуализации зависимостей; PHPat — если вы уже живёте на PHPStan и не хотите новый инструмент; PHPStan с кастомными правилами — когда правило должно учитывать типы. Есть и нишевый PHPArch, где правила пишутся как PHPUnit-тесты. Для типовых архитектурных соглашений PHPArkitect даёт самый короткий путь: правила на том же языке, что и проект, читаются как тесты и не требуют изучения DSL.
10. Итог: с чего начать
Хорошие практики становятся обязательными проверками. PHPArkitect не заменяет архитектора и ревью — он снимает с ревью механическую работу: «не залезай в чужой модуль», «не тащи инфраструктуру в домен», «не плоди Helper'ы». Человек проверяет то, что требует мышления; машина — то, что требует памяти.
Старт занимает вечер:
composer require --dev phparkitect/phparkitect;- описать 3–5 правил под свои боли — именование, границы слоёв, запрещённые зависимости;
check --generate-baseline, если legacy уже копил нарушения;- добавить шаг в CI на каждый PR.
Через месяц архитектура проекта перестанет зависеть от того, помнит ли каждый разработчик границы. Они станут кодом.
Проект открыт: github.com/alex-frolov/tender, MIT. Полный конфиг 19 правил — app/phparkitect.php в репозитории, CI с проверкой на каждый PR — там же. Продолжение серии про модульный монолит — в планах: sealed bids с шифрованием до вскрытия и outbox с 63 JSON Schema событиями.