What the CI doesn't see doesn't exist
Step 1: an architecture rule that explains how to fix it
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;
}
}
Yaml
# phpstan.neon
includes:
- phpstan-baseline.neon
parameters:
level: 6
paths:
- src
rules:
- App\Tools\PHPStan\NoEntityManagerInControllerRule
Step 2: layers with Deptrac
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: ~
Step 3: the ratchet on legacy
Bash
vendor/bin/phpstan analyse --generate-baseline
vendor/bin/deptrac analyse --formatter=baseline
Bash
echo "phpstan_baseline_entries $(grep -c 'message:' phpstan-baseline.neon)"
Step 4: an output readable by humans and machines
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
The traps
- 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.
Sources
- SymfonyCon Warsaw 2026 – Green Is Not Done — Symfony blog, Mélyne Freuze, October 16, 2026 (talk by Anne-Julia Seitz)
- Je ne code pas, BMAD code pour moi avec ses agents : sur parole ou sur preuves ? — OCTO Talks, Claude Camus, October 12, 2026
- Former une équipe aux agents IA — YoanDev, Yoan Bernabeu, October 8, 2026
- PHPStan: custom rules and the baseline — PHPStan documentation
- Deptrac: formatters and baseline — Deptrac documentation