17.08.2026 · PHP · архитектура · тестирование · ~12 мин чтения

PHPArkitect: архитектурные правила как исполняемый код

1. Введение: почему архитектура умирает молча

Архитектура в PHP-проектах умирает не от одного фатального решения, а от тысячи мелких. Контроллер обратился к репозиторию напрямую — «ну, по-быстрому». Сервис затащил зависимость из чужого модуля — «оно же рядом лежит». Слой Domain начал знать про инфраструктуру — «потом разберёмся». Потом приходит новый разработчик, видит, что «так можно», и делает так же. Теперь это не исключение, а норма, и проект медленно превращается в большой комок связанности, который страшно менять.

Проблема не в том, что архитектуру не описывают. Её описывают: ADR, диаграммы, раздел в README. Проблема в том, что документация не исполняется. Запись «слой Domain не зависит от Infrastructure» никого не останавливает: в доменном классе спокойно появляется use App\Infrastructure\Mailer;. Ревью это не ловит: человек не может помнить все границы проекта и устаёт через двадцать минут. Когда нарушений набирается десяток, исправлять уже поздно: зависимости переплелись, и вытаскивать их — отдельный проект. Чем позже обнаружено нарушение, тем дороже оно обходится.

Единственный способ держать архитектуру живой — проверять её на каждом изменении кода, автоматически. В Java-мире это давно стандарт: ArchUnit появился в 2014 году и встроился в CI тысяч проектов. В PHP такой инструмент появился в июне 2020-го — это PHPArkitect. О нём и пойдёт речь: что это, как выглядит на реальном проекте и как внедрять без боли.

PHPArkitect в CI: PR → PHPStan → PHPArkitect → тесты → main; при нарушении exit code 1 и merge заблокирован; baseline-стратегия для legacy: существующие нарушения не блокируют, новые — блокируют
PHPArkitect в CI: проверка на каждом PR + baseline-стратегия для legacy

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(...)зачем. Этот текст попадает в отчёт об ошибке — разработчик, который нарушил правило, сразу видит, какое соглашение он задел и почему оно существует.
Анатомия правила PHPArkitect: that — к кому применяется (классы в App\Domain), should — что обязаны (не зависеть от чужих namespace), because — зачем; внизу отчёт о нарушении с классом, зависимостью, причиной и номером строки
Анатомия правила: that → should → 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'ы». Человек проверяет то, что требует мышления; машина — то, что требует памяти.

Старт занимает вечер:

  1. composer require --dev phparkitect/phparkitect;
  2. описать 3–5 правил под свои боли — именование, границы слоёв, запрещённые зависимости;
  3. check --generate-baseline, если legacy уже копил нарушения;
  4. добавить шаг в CI на каждый PR.

Через месяц архитектура проекта перестанет зависеть от того, помнит ли каждый разработчик границы. Они станут кодом.

Проект открыт: github.com/alex-frolov/tender, MIT. Полный конфиг 19 правил — app/phparkitect.php в репозитории, CI с проверкой на каждый PR — там же. Продолжение серии про модульный монолит — в планах: sealed bids с шифрованием до вскрытия и outbox с 63 JSON Schema событиями.

Обсудить вашу задачу

Внедряете архитектурные проверки в CI или разбираете legacy-монолит? Помогу спроектировать границы модулей, настроить PHPArkitect/Deptrac и встроить проверки в пайплайн. Отвечаю в течение 24–48 часов.