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 order service calls $order->getCustomer()->getEmail(), while getCustomer() can return null. The error shows up in production, on an order with no customer attached.
  • A Reservation entity in the stock service 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

Fast before the commit, complete on the pull request, global on main 1. Before the commit on the developer's machine, touched projects moon run :lint --affected --status=staged A failure blocks the commit. PHP-CS-Fixer in check mode lint:yaml, lint:container doctrine:schema:validate --skip-sync push 2. Pull request in CI, affected projects moon ci :lint :analyse :audit A failure blocks the merge. All of the lint, then: PHPStan at level 10, with a baseline Deptrac, Rector with --dry-run composer audit --locked merge into main 3. Periodic scheduled job on main moon run :audit :metrics Informs, does not block. composer audit of every project PhpMetrics: HTML report SonarQube Community Build, if installed

Depending on the context, we add:

  • Psalm, for its taint analysis only (--taint-analysis), with psalm/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 the shipping emails.
  • 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-lines limits 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-run in CI, and for every upgrade.
  • Deptrac layers, with entities that depend on nothing.
  • lint:yaml, lint:container and doctrine:schema:validate --skip-sync on 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.