PHPStan PHP Architektura

Reguły zamiast review

Modele generują kod szybciej, niż zespół jest w stanie go przejrzeć. Powtarzalną część review da się zapisać jako reguły: Deptrac na warstwy, własne reguły PHPStana na kontrakty.

AK Aleksander Kowalski
• 24 August 2026 • 8 min

Model potrafi wygenerować trzysta linii w minutę. Przejrzenie tych trzystu linii zajmuje mi czterdzieści i połowę tego czasu spędzam, wypisując w komentarzach rzeczy które wypisałem już w zeszłym tygodniu w innym PR-ze. Odpowiedzialność za jakość kodu przesunęła się prawie w całości na drugiego dewelopera I to jest wąskie gardło, którego nie da się rozwiązać zatrudnieniem kolejnego.

TL;DR

Co pilnuję Czym Komendą
Zależności między warstwami Deptrac composer test:architecture
Kontrakty i nawyki w kodzie własne reguły PHPStana composer phpstan
Styl PHP CS Fixer composer cs:check

Wszystko, o czym piszę, stoi w moim boilerplacie: symfony-boilerplate-ddd.

Co zostaje człowiekowi, a co maszynie

Review ma dwie warstwy i tylko jedna z nich potrzebuje człowieka.

Pierwsza to pytania, na które odpowiedź wymaga kontekstu spoza repozytorium: czy ten model domenowy oddaje to, o czym rozmawialiśmy z biznesem, czy ten trade-off jest wart swojej ceny, czy nazwa tej metody będzie zrozumiała za rok. To jest praca dla człowieka i długo nią pozostanie.

Druga to sprawdzanie po raz dwudziesty, czy ktoś znowu nie wstrzyknął EntityManagera do domeny. To jest praca dla maszyny i zawsze nią była. Dopóki kod pisali wyłącznie ludzie, dawało się ją utrzymać dobrą wolą i pamięcią kolegi z zespołu, który akurat patrzył na PR-a. Ten model przestał działać, kiedy objętość generowanego kodu urosła szybciej niż liczba osób, które mają go przejrzeć.

Jest jeszcze druga przyczyna, mniej oczywista. Model nie zna ustaleń, których nie ma w kontekście. Nie był na spotkaniu, na którym zdecydowaliście, że mapowanie Doctrine idzie do infrastruktury. Zna to, co dostanie w promptcie, i to, co powie mu narzędzie, kiedy uruchomi testy. Jeśli kontrakt zespołu żyje wyłącznie w głowach, model go nie pozna. Jeśli żyje jako reguła, dostanie go zwrotnie przy każdym uruchomieniu analizy i sam się poprawi, zanim PR wogóle do mnie trafi.

Warstw pilnuje Deptrac

Zacznę od rzeczy najprostszej, bo daje najwięcej za najmniejszy koszt. Deptrac przypisuje klasy do warstw, i sprawdza czy zależności między nimi idą w dozwoloną stronę.

Powstał w czasach, w których diagram warstw wisiał na Confluence, a trzymanie się go było kwestią dyscypliny. Ktoś rysował pudełka ze strzałkami, zespół zgadzał się ich pilnować, a po pół roku okazywało się, że domena importuje EntityManagera, bo tak było szybciej w piątek po południu. Zamysł był prosty: przenieść ten diagram z Confluence do repozytorium, żeby dało się go sprawdzić zamiast w niego wierzyć.

Ten zamysł się nie zmienił. Zmienił się odbiorca. Wcześniej Deptrac był narzędziem kontrolnym, odpalanym rzadko: przy większym refaktorze albo przed wydaniem, żeby zobaczyć, jak daleko odjechaliśmy od projektu. Dziś jest przede wszystkim narzędziem informacyjnym dla kogoś, kto tego diagramu nigdy nie widział. Model nie ma dostępu do Confluence'a. Ma dostęp do outputu composer test:architecture, który powie mu wprost, że App\Order\Domain\Order nie ma prawa zależeć od Doctrine\ORM\EntityManager.

To jest cały trik: jedna komenda zamienia ustalenie architektoniczne w informację zwrotną, którą da się dostać w pętli, przy każdej zmianie zamiast raz na kwartał.

deptrac.yaml
layers:
  - name: Domain
    collectors:
      - type: classLike
        value: '^App\\[A-Za-z0-9]+\\Domain\\'

  - name: Application
    collectors:
      - type: classLike
        value: '^App\\[A-Za-z0-9]+\\Application\\'

ruleset:
  Domain: ~

  Application:
    - Domain

  Infrastructure:
    - Domain
    - Application
    - Vendor

Domain: ~ to pusta lista dozwolonych zależności, czyli domena nie może zależeć od niczego poza sobą. Aplikacja widzi domenę. Infrastruktura widzi wszystko, w tym vendora.

Ciekawszy jest sposób, w jaki zdefiniowana jest warstwa Vendor: nie jako lista paczek, tylko negatywnie: wszystko, co nie jest App\, z białą listą typów wbudowanych PHP (DateTimeImmutable, Throwable, Countable i podobne). Dzięki temu każda nowa zależność z Packagista jest domyślnie zakazana w domenie, bez dopisywania czegokolwiek do konfiguracji.

Odpowiedzialności wszystkich warstw, zapisane w pięćdziesięciu linijkach YAML-a i sprawdzane jedną komendą. Nie ma nic tańszego.

Własne reguły PHPStana

O poziomach PHPStana i o tym, że warto włączyć level: max, napisano już wszystko. Mniej mówi się o sekcji rules:, czyli o tym, że do analizatora można dopisać własne reguły, które nie mają nic wspólnego z typami, a wszystko z tym, jak wasz zespół umówił się pracować.

Reguła to jedna klasa z dwiema metodami. getNodeType() zawęża drzewo składniowe do interesującego węzła, processNode() decyduje, czy to błąd:

tools/phpstan/src/NoAmbientClockRule.php
final class NoAmbientClockRule implements Rule
{
    private const DOMAIN_NAMESPACE_MARKER = "\\Domain\\";

    public function getNodeType(): string
    {
        return New_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if ($this->isDomainNamespace((string)$scope->getNamespace())) {
            return [];
        }

        if (!$node->class instanceof Name || $node->class->toLowerString() !== "datetimeimmutable") {
            return [];
        }

        $argument = $node->getArgs()[0]->value ?? null;

        if ($argument === null || ($argument instanceof String_ && strtolower($argument->value) === "now")) {
            return [
                RuleErrorBuilder::message(
                    "Do not read the ambient clock — inject Psr\\Clock\\ClockInterface (symfony/clock provides it) and take the current time from its now(). (Domain classes are exempt, they may stamp themselves.)",
                )->identifier("symfonyBoilerplate.noAmbientClock")->build(),
            ];
        }

        return [];
    }
}

Trzydzieści linii, a pilnują nawyku, który normalnie wychwytuje się dopiero wtedy, gdy trzeba napisać test na coś, co zależy od „teraz". Zwróć uwagę na identifier. Wrócę do niego za chwilę bo to najbardziej niedoceniana część tego API.

Rejestracja to jedna linia w phpstan.neon:

phpstan.neon
rules:
    - SymfonyBoilerplate\PhpStan\NoAmbientClockRule

14 reguł, które trzymają kontrakt

W boilerplacie mam ich czternaście. Nie dlatego, że lubię reguły, tylko dlatego, że każda z nich zastąpiła komentarz, który pisałem więcej niż raz.

Reguła Czego pilnuje
NoFrameworkTypeInDomainRule domena nie nazywa typów z Symfony, Doctrine, PSR ani Nelmio
NoDoctrineAttributeInDomainRule mapowanie encji siedzi w infrastrukturze, nie w atrybutach domeny
NoPublicSetterInDomainRule zamiast settera metoda ujawniająca intencję i trzymająca inwariant
DomainExceptionContractRule wyjątki domenowe mają wspólną bazę, żeby listener zmapował je na HTTP
RequestDtoContractRule request DTO jest final i readonly, bo to kontrakt, a nie punkt rozszerzeń
ApiEndpointDocumentedRule każdy routowany kontroler ma atrybut #[OA\...]
DoctrineTypeRegisteredRule custom type Doctrine deklaruje public const string NAME
NoAmbientClockRule czas bierzemy z wstrzykniętego ClockInterface
NoMutableDateTimeRule DateTimeImmutable zamiast DateTime
NoRawSqlStringRule SQL idzie przez repozytorium, DQL albo QueryBuilder
NoRawRequestAccessInControllerRule kontroler nie grzebie w $request gołymi rękami
NoEnvSuperglobalRule konfiguracja przez parametry kontenera, nie przez $_ENV
NoDebugFunctionRule żadnego dd(), exit ani var_dump() w commicie
NoTraitUseRule zamiast traitów kompozycja

Widać w tym trzy rodzaje kontraktu. Kilka reguł pilnuje warstw, czyli tego, czego Deptrac nie złapie, bo dzieje się wewnątrz jednego namespace'u. Kilka pilnuje interfejsów: skoro klasa implementuje RequestInterface, to argument resolver ją zdeserializuje, więc musi być final i niemutowalna, a reguła zapisuje, gdzie i jak wolno ten interfejs implementować. Reszta pilnuje zwykłych nawyków, które w komentarzu do PR-a brzmią jak czepianie się.

Komunikat błędu pisz dla modelu

Skoro większość pisania kodu oddaliśmy modelom, to warto spojrzeć na komunikaty błędów z narzędzi jak na wejście, a nie wyjście, bo to one wracają do pętli. Tu właśnie najczęściej marnuje się potencjał własnych reguł. Porównaj dwa komunikaty z tego samego repozytorium.

Ten jest zły, mój własny, do poprawki:

Use DateTimeImmutable, not mutable DateTime.

A ten dobry:

Domain class names the framework type "%s" — the domain layer stays framework-free; depend on an interface you own and adapt the framework type in the Infrastructure layer.

Różnica nie polega na długości. Pierwszy mówi, czego nie wolno. Drugi mówi, co zamiast, i podaje warstwę, w której należy to zrobić. Dla człowieka to różnica między irytacją a podpowiedzią. Dla modelu to różnica między zatrzymaniem się a naprawieniem kodu w następnej iteracji, bo output analizatora jest dokładnie tym, co dostaje z powrotem do kontekstu.

Komunikat reguły to nie linijka loga, tylko jedyny kawałek dokumentacji, który napewno zostanie przeczytany. Pisz go jak akapit z onboardingu.

Drugą rzeczą jest identifier. To adres reguły, po którym wycisza się pojedyncze przypadki zamiast całej reguły:

phpstan.neon
ignoreErrors:
    -
        identifier: symfonyBoilerplate.noAmbientClock
        path: tests/*
        reportUnmatched: false

W testach czytanie zegara jest w porządku. Wyjątek jest tu jawny, opisany i ograniczony do jednej ścieżki, a nie schowany w @phpstan-ignore-next-line rozsianym po kodzie.

Wady?

Żeby było uczciwie:

  • Reguła to kod, który trzeba utrzymać. Testujesz ją, poprawiasz, aktualizujesz razem z PHPStanem.
  • False positives potrafią zaboleć. Reguła, która blokuje poprawny kod, w tydzień wyląduje w ignoreErrors i przestanie cokolwiek chronić.
  • Regułą nie zapiszesz sensu. Ani jedna z tych czternastu nie powie ci, że model domenowy jest zły. Zwalniają czas na to pytanie, ale nie odpowiadają na nie.
  • Wdrożenie w istniejącym projekcie boli. Trzeba zacząć od baseline'u i schodzić z długiem stopniowo, bo inaczej pierwszy przebieg wypluje kilkaset błędów i wszyscy to wyłączą.

Podsumowanie

Zasada jest jedna: jeśli powiedziałeś to samo w komentarzu do PR-a trzeci raz, to nie jest już uwaga do kodu, tylko brakująca reguła.

Wcześniej ten mechanizm działał na pamięci kolegi z zespołu i tak długo, jak kod powstawał w ludzkim tempie, wystarczał. Dziś nie wystarcza, i nie dlatego, że modele piszą gorzej, tylko dlatego, że piszą szybciej, niż jesteśmy w stanie czytać. Kontrakt, który zapiszesz jako regułę, egzekwuje się sam przy każdym uruchomieniu, a przy okazji wraca do modelu jako podpowiedź, zanim ktokolwiek otworzy PR-a.

Człowiekowi zostaje ta część review, w której faktycznie jest potrzebny.


Masz pytania? Napisz na LinkedIn.

Podobał Ci się artykuł?

Nowe wpisy raz w miesiącu. Zero spamu.