The problem
Take an online shop organised as a monorepo: the Symfony
services order, stock,
shipping, billing and the shared
app-contracts package. The whole is
orchestrated by moon. Without shared quality tooling, the
same defects come back from one pull request to the next.
- Code reviews argue about indentation and import order, instead of behaviour.
-
A processor in the
orderservice calls$order->getCustomer()->getEmail(), whilegetCustomer()can returnnull. The error shows up in production, on an order with no customer attached. -
A
Reservationentity in thestockservice imports an API resource class. The database schema and the API contract end up tied together. - A wrongly typed service argument only breaks at runtime, when the service is instantiated.
- A vulnerability published on a dependency is only discovered by chance, during an update.
The usual answer is SonarQube. Its free self-hosted edition, Community Build, only analyses the main branch: the findings arrive after the merge. The self-hosted editions that analyse pull requests are paid.
Without that budget, the question becomes: which free tools cover these defects, and when should they run?
The options
SonarQube Community Build alone
Community Build is free, self-hosted and released every month. It analyses PHP: its documentation states full support for PHP 5.0 to 8.4.
- What it solves: a single dashboard, a history of the debt, rules maintained by the vendor.
- What it costs: a server to run. Above all, it only analyses the main branch: neither the other branches, nor pull requests. Injection vulnerability detection (data flow analysis, or taint analysis) is not part of it.
A community plugin, not supported by SonarSource, adds branches and pull requests. Its version follows the server's: on 1 October 2026, it targets 26.5, while Community Build is at 26.9.
A paid SonarQube edition
- What it solves: branch and pull request analysis, and the quality gate status on every pull request.
- What it costs: for SonarQube Server, a licence per instance and per year, based on the number of lines of code.
The free plan of SonarQube Cloud also analyses pull requests that target the main branch, up to 50,000 lines of private code. The code is then analysed outside your walls.
A stack of open source tools, run by the monorepo
- What it solves: each tool targets one family of defects. It runs locally as in CI, and its configuration lives in the repository. Its exit code is enough to block a pull request.
- What it costs: several tools to configure and keep up to date, and no consolidated dashboard.
An all-in-one tool
Two tools aim at this slot. PHP Insights aggregates PHP-CS-Fixer, PHP_CodeSniffer and Slevomat Coding Standard into a scored report. Mago, written in Rust, bundles a formatter, a linter, a static analyser and architecture rules in a single binary.
- What it solves: one installation, one configuration, a single output.
- What it costs: PHP Insights overlaps the tools it aggregates, and its version 2.15 requires PHP 8.4. Mago is young: version 1.0 in December 2025, version 1.50 on 21 September 2026.
Our recommendation
PHPStan at level 10, PHP-CS-Fixer, Rector, Deptrac,
composer audit and the Symfony linters, run
by moon locally as in CI.
Each tool catches a family of defects the others do not see. A PHPStan baseline absorbs the existing debt, so the maximum level can be the target from day one. The configuration is versioned with the code, and a developer gets the CI verdict locally.
| Tool | What it catches |
|---|---|
| PHP-CS-Fixer | style, imports, syntax to modernise |
| Symfony and Doctrine linters | invalid YAML, miswired service, inconsistent mapping |
| PHPStan and its extensions | types, calls on null, invalid DQL |
| Deptrac | forbidden dependency between layers |
Rector with --dry-run |
code to migrate to the target PHP or Symfony version |
composer audit |
vulnerable or abandoned dependency |
| PhpMetrics | complexity, coupling, maintainability per class |
Depending on the context, we add:
-
Psalm, for its taint analysis only
(
--taint-analysis), withpsalm/plugin-symfony, which extends it to Symfony. It follows user input down to an SQL query or an HTML output. This is what Community Build lacks. -
Twig CS Fixer and
lint:twig, for a service that renders templates, such as theshippingemails. -
Infection, on the most sensitive code,
such as the amount calculations of
billing. Mutation testing reveals the tests that pass without checking anything. On a pull request,--git-diff-lineslimits the mutations to the changed lines. - PHP_CodeSniffer, if a coding standard you need only exists for it. PHPCSStandards has maintained it since the original repository was abandoned, under the same package name.
We leave aside:
- PHPMD: its latest stable release, 2.15.0, dates from 11 December 2023. The 3.x branch is active, but no version 3 is published as of 1 October 2026.
- GrumPHP: it installs its own git hooks from a Composer project. In a monorepo, moon already manages the hooks, with the same tasks as the CI.
- PHP Insights: it overlaps PHP-CS-Fixer, already in the stack, and its version 2.15 still depends on PHP_CodeSniffer 3.
- Mago, for now: we evaluate it on a branch, without blocking, before trusting it with a pull request.
The trade-offs we accept
Several tools instead of one dashboard.
Each tool has its configuration, its output and its
upgrades. We treat them like the other development
dependencies: pinned in composer.lock, updated
through pull requests.
No consolidated view. Nobody sees the debt
of the whole monorepo at a glance. PhpMetrics and, where it
exists, Community Build on main fill part of
that gap.
A baseline to shrink. It accepts the existing debt in order to block new debt. It only shrinks if someone takes care of it, and a growing baseline gets rejected in review.
A demanding level 10. At this level,
PHPStan rejects any operation on a mixed value,
even an implicit one. Code that reads untyped arrays
(payloads, configuration) needs annotations or DTOs.
The signal that should trigger a review of this choice: an audit that requires consolidated security reports. Or separate reports that nobody reads any more. A commercial SonarQube edition then becomes an option worth pricing.
Implementation
The examples target Symfony 7.4 and PHP 8.4; they also hold for Symfony 8. Each service installs its tools as development dependencies.
composer require --dev phpstan/phpstan phpstan/extension-installer \
phpstan/phpstan-symfony phpstan/phpstan-doctrine \
friendsofphp/php-cs-fixer rector/rector deptrac/deptrac phpmetrics/phpmetrics
1. PHPStan at level 10, with a baseline
# phpstan.neon (order service)
includes:
- phpstan-baseline.neon
parameters:
level: 10
paths:
- src/
- tests/
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/object-manager.php
With phpstan/extension-installer, the Symfony
and Doctrine extensions enable themselves, rules included.
The first one reads the compiled container and knows the
type of every service. The second one loads the entity
manager (the tests/object-manager.php script
from its documentation) to check the DQL and the match
between columns and properties.
We write 10 rather than max. The
max alias follows the highest level, and
PHPStan added one with its version 2.0. With a number,
moving up a level remains a decision.
The baseline records the existing errors, so that only new ones block:
vendor/bin/phpstan analyse --generate-baseline
Run this command before adding
phpstan-baseline.neon to includes:
PHPStan refuses to include a missing file.
The phpstan-baseline.neon file is committed.
When an error is fixed, PHPStan reports the entry that is no
longer needed, because
reportUnmatchedIgnoredErrors is on by default.
The baseline is then regenerated: it shrinks, and any
increase shows in the diff.
2. Style with PHP-CS-Fixer
// .php-cs-fixer.dist.php (order service)
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = (new Finder())->in([__DIR__ . '/src', __DIR__ . '/tests']);
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,
])
->setFinder($finder);
Locally, vendor/bin/php-cs-fixer fix fixes. In
CI, vendor/bin/php-cs-fixer check --diff fails
and shows the expected fix. "Risky" rules can change the
behaviour of the code: we enable them on a service covered
by tests.
3. Rector in check mode
// rector.php (order service)
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withPhpSets()
->withComposerBased(doctrine: true, phpunit: true, symfony: true)
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml');
withPhpSets() without arguments reads the PHP
version from composer.json.
withComposerBased() loads the rules that match
the installed versions of Symfony, Doctrine and PHPUnit. In
CI, vendor/bin/rector process --dry-run fails
if code remains to be migrated.
For a Symfony upgrade, we run
vendor/bin/rector process without
--dry-run. The diff is then reviewed like an
ordinary pull request.
4. Deptrac and the layers of the order service
In the order service, the API resources
(src/ApiResource) are DTOs. The providers and
processors (src/State) bridge them with the
Doctrine entities. Deptrac turns this rule into a check.
# deptrac.yaml (order service)
deptrac:
paths:
- src/
layers:
- name: Controller
collectors:
- type: directory
value: src/Controller/.*
- name: ApiResource
collectors:
- type: directory
value: src/ApiResource/.*
- name: State
collectors:
- type: directory
value: src/State/.*
- name: Messaging
collectors:
- type: directory
value: src/Messaging/.*
- type: directory
value: src/MessageHandler/.*
- name: Entity
collectors:
- type: directory
value: src/Entity/.*
- type: directory
value: src/Repository/.*
ruleset:
Controller: [Entity, Messaging]
# ApiResource -> Entity: API Platform's stateOptions(entityClass: Order::class)
ApiResource: [Entity]
State: [ApiResource, Entity, Messaging]
Messaging: [Entity]
Entity: ~
By default, Deptrac forbids any dependency between layers:
the ruleset lists the only allowed ones.
Entities and repositories form a single layer, because
#[ORM\Entity(repositoryClass: ...)] ties the
entity to its repository. The Entity layer
depends on nothing: with the same layers in
stock, the Reservation entity from
the problem fails the pull request.
Classes outside the layers (Symfony, Doctrine,
app-contracts) are not checked here. For
existing debt, --formatter=baseline writes a
deptrac.baseline.yaml file to import. The
package is now named deptrac/deptrac:
qossmic/deptrac is marked as abandoned.
5. Symfony and Doctrine linters, and the dependency audit
Symfony, Doctrine and Composer provide their own checks, with no extra dependency:
php bin/console lint:yaml config --parse-tags
php bin/console lint:container
php bin/console doctrine:schema:validate --skip-sync
composer audit --locked
lint:container checks that the injected
arguments match the declared types.
doctrine:schema:validate --skip-sync validates
the mapping without comparing it with the database.
composer audit checks the
composer.lock against the security advisories.
Since Composer 2.7, it also fails on abandoned packages by
default.
6. The moon tasks, locally and in CI
Each service declares its tasks in its
moon.yml:
# moon.yml (order service)
language: php
layer: application
dependsOn:
- app-contracts
tasks:
lint:
script: >-
vendor/bin/php-cs-fixer check --diff
&& php bin/console lint:yaml config --parse-tags
&& php bin/console lint:container
&& php bin/console doctrine:schema:validate --skip-sync
analyse:
script: >-
php bin/console cache:warmup --env=dev
&& vendor/bin/phpstan analyse --no-progress
&& vendor/bin/deptrac analyse --no-progress
&& vendor/bin/rector process --dry-run --no-progress-bar
audit:
command: composer audit --locked
options:
cache: false
runInCI: always
metrics:
command: vendor/bin/phpmetrics --report-html=var/phpmetrics src
The analyse task first compiles the dev
container, which PHPStan and Rector read. Security
advisories change while the code does not:
audit therefore bypasses the cache and runs on
every CI build.
The pre-commit hook only runs the lint of the projects touched by the staged files:
# .moon/workspace.yml
vcs:
defaultBranch: 'main'
hooks:
pre-commit:
- moon run :lint --affected --status=staged
sync: true
Without defaultBranch, moon uses
master as the comparison base when the CI does
not provide one.
In CI, a single command runs the tasks of the projects affected by the pull request:
moon ci :lint :analyse :audit
moon ci compares the branch with its base and
keeps only the relevant tasks. It needs the full history of
the repository: a shallow clone skews the detection. Branch
protection rules make this job mandatory before the merge.
7. The periodic report
A scheduled job, on main, reruns the audit and
the PhpMetrics report of every project:
moon run :audit :metrics
A vulnerability published after the last pull request thus
shows up without waiting for the next one. If Community
Build is installed, the same job sends it the analysis of
main.
PhpMetrics produces an HTML report: complexity, coupling and maintainability index per class. Its 2.x branch is still released (2.11.0 on 9 August 2026), and 3.0 has been a release candidate since 2023.
Checklist
- PHPStan at level 10, with the Symfony and Doctrine extensions.
- A committed baseline that does not grow without review.
- PHP-CS-Fixer fixing locally and checking in CI.
-
Rector with
--dry-runin CI, and for every upgrade. - Deptrac layers, with entities that depend on nothing.
-
lint:yaml,lint:containeranddoctrine:schema:validate --skip-syncon every pull request. -
composer audit --locked, outside the cache, on every CI build. - The same moon tasks in pre-commit and in CI, mandatory before the merge.
-
A periodic report on
main: PhpMetrics, and Community Build if it is in place.
Sources
Versions and dates checked on Packagist and GitHub on 1 October 2026. Latest stable releases: PHPStan 2.2.16, PHP-CS-Fixer 3.95.27, Rector 2.6.7, Deptrac 4.7.2, Psalm 6.19.1 (7.0 in beta), Mago 1.50.0.
- SonarSource, Feature comparison table: branches, pull requests and injections by edition.
- SonarSource, PHP and Release cycle model for Community Build.
- SonarSource, Plans and pricing and SonarQube Cloud subscription plans.
- sonarqube-community-branch-plugin: support and compatibility.
- PHPStan, Rule Levels and The Baseline.
- phpstan-symfony, phpstan-doctrine and extension-installer.
- Psalm, Security Analysis, and psalm/plugin-symfony.
- Mago, Tools and release history.
- PHP-CS-Fixer, Usage and @Symfony rules.
- Repositories of PHP_CodeSniffer, Twig CS Fixer, PHP Insights and GrumPHP; PHPMD releases.
- Deptrac, repository and 4.x documentation; qossmic/deptrac on Packagist.
- PhpMetrics, documentation and releases.
- Rector, Composer-based sets, Set lists and rector-symfony.
- Infection, Command line options.
-
Composer,
audit command
and
policyconfiguration. - Symfony, lint:container, lint:twig and lint:yaml; Doctrine ORM, mapping validation.
- moon, moon ci, CI guide, VCS hooks and project configuration.