Europe/Paris
Posts

A green CI is not a definition of done: architecture rules and the ratchet effect on legacy Symfony

October 23, 2026 · 5 min de lecture
When a coding agent opens a PR, the CI becomes its definition of "done": anything your checks don't see can pass as finished. That's the thesis Anne-Julia Seitz will present at SymfonyCon Warsaw at the end of November, and it applies just as well to humans. On an existing Symfony app, a typical pipeline checks that the tests pass and that PHPStan reports nothing new. It says nothing about a controller that injects the EntityManagerInterface and calls flush() three times, or a domain class that imports Doctrine. An experienced reviewer spots it. An agent or a newcomer, not necessarily. And the review is the first thing that gets rushed on a Friday afternoon. Seitz calls the answer harness engineering (all the tooled guardrails around whoever writes the code: tests, static analysis, architecture rules, error messages). Her abstract, presented on the Symfony blog, focuses on three things: turning PHPStan and Deptrac into actionable feedback, adding custom architecture rules for controllers, and enforcing them on legacy code with a baseline that can only shrink. The same lesson shows up from another angle on the OCTO blog. Claude Camus assessed eight projects coded by agents with four measurable questions, and half of his grid stayed "not measured". One detail says a lot: the only project with a lint threshold saw it drop from 164 to 40 in a few days. What has a threshold moves. The rest doesn't. The rule below forbids controllers from receiving the EntityManagerInterface. What matters most is not the detection, it's the message: it is what the developer, or the agent, reads in the CI output. It has to say what to do, not just what's wrong.
Php
<?php

declare(strict_types=1);

namespace App\Tools\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<ClassMethod>
 */
final class NoEntityManagerInControllerRule implements Rule
{
    private const FORBIDDEN = 'Doctrine\ORM\EntityManagerInterface';

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

    public function processNode(Node $node, Scope $scope): array
    {
        if (!$scope->isInClass()) {
            return [];
        }

        $class = $scope->getClassReflection()->getName();
        if (!str_starts_with($class, 'App\\Controller\\')) {
            return [];
        }

        $errors = [];
        foreach ($node->getParams() as $param) {
            if (!$param->type instanceof Name) {
                continue;
            }

            if ($scope->resolveName($param->type) !== self::FORBIDDEN) {
                continue;
            }

            $errors[] = RuleErrorBuilder::message(sprintf(
                '%s receives EntityManagerInterface. Controllers must not handle persistence.',
                $class,
            ))
                ->identifier('app.controller.entityManager')
                ->tip('Move the write logic into a service under src/Application and inject that service. See docs/adr/0007-controllers.md.')
                ->line($param->getStartLine())
                ->build();
        }

        return $errors;
    }
}
Registration, with the class autoloaded in autoload-dev of composer.json (here App\Tools\ pointing to tools/):
Yaml
# phpstan.neon
includes:
    - phpstan-baseline.neon

parameters:
    level: 6
    paths:
        - src

rules:
    - App\Tools\PHPStan\NoEntityManagerInControllerRule
The tip points to an ADR (Architecture Decision Record, a short document that explains a decision and its context). The rule enforces, the ADR explains why. Without the "why", the first person who disagrees adds an ignore. Deptrac (a tool that groups classes into layers and fails when one layer uses another it is not allowed to) covers what a rule per case would make tedious: keeping the domain away from the framework.
Yaml
# deptrac.yaml
imports:
    - deptrac.baseline.yaml

deptrac:
    paths:
        - ./src
    layers:
        - name: Controller
          collectors:
              - type: directory
                value: src/Controller/.*
        - name: Application
          collectors:
              - type: directory
                value: src/Application/.*
        - name: Domain
          collectors:
              - type: directory
                value: src/Domain/.*
        - name: Infrastructure
          collectors:
              - type: directory
                value: src/Infrastructure/.*
        - name: Doctrine
          collectors:
              - type: classLike
                value: ^Doctrine\\.*
    ruleset:
        Controller:
            - Application
            - Domain
        Application:
            - Domain
        Infrastructure:
            - Domain
            - Doctrine
        Domain: ~
The Doctrine layer is not there by accident. A dependency to a class that belongs to no layer is not a violation for Deptrac, it's just "uncovered". Without that layer, use Doctrine\ORM\Mapping as ORM; in the domain would go straight through. On an existing project, both tools will throw hundreds of errors on the first run. The answer is a baseline (the list of known violations, accepted for now):
Bash
vendor/bin/phpstan analyse --generate-baseline
vendor/bin/deptrac analyse --formatter=baseline
What turns the baseline into a ratchet (a mechanism that only turns one way) is a default behaviour of both tools: when an entry in the baseline no longer matches anything, they report it. PHPStan does this as long as reportUnmatchedIgnoredErrors stays enabled, and Deptrac treats an unmatched skipped violation as an error. Whoever fixes old debt therefore has to remove the line in the same PR. New violations fail immediately, old ones can only disappear. OCTO's advice applies directly here: follow the numbers over time rather than comparing projects with each other. One line in the pipeline is enough to graph the size of the baseline:
Bash
echo "phpstan_baseline_entries $(grep -c 'message:' phpstan-baseline.neon)"
On GitLab, both tools can produce the Code Quality format, which shows the new findings directly in the merge request:
Yaml
quality:
  stage: test
  script:
    - composer install --no-interaction --no-progress
    - vendor/bin/phpstan analyse --no-progress --error-format=gitlab > phpstan-quality.json
    - vendor/bin/deptrac analyse --formatter=codeclimate --output=deptrac-quality.json
  artifacts:
    when: always
    reports:
      codequality:
        - phpstan-quality.json
        - deptrac-quality.json
If PHPStan fails, the job stops before Deptrac. That's acceptable: the first error is enough to send the PR back. Where do the rules come from? In his write-up on training a team in agentic development, Yoan Bernabeu explains that the shared rules emerged from what the team had experienced, not from the trainer's slides, and that the team skills built from them are versioned, reviewed and shared like code. The same goes for an architecture rule: one ADR, one PR, a team discussion, then the rule in the CI.
  • Regenerating the whole baseline in a feature PR. It silently absorbs the new violations of that PR. The Deptrac documentation itself warns that a baseline can hide violations. Regenerate only in a dedicated PR, and never set reportUnmatchedIgnoredErrors: false.
  • Believing the rule is exhaustive. The one above ignores ?EntityManagerInterface, union types and property injection. A rule is a guardrail, not a proof. And a rule with false positives quickly collects ignores until nobody reads it anymore.
  • Confusing green with useful. The CI says the code respects the rules. It says nothing about whether the feature serves anyone: in the OCTO assessment, user expectations and actual usage stayed unmeasured on all eight projects, because no repository can answer that.
A rule that only lives in the wiki is respected as long as the person who wrote it does the reviews.
On this page
Book a call