Modern PHP projects require a comprehensive static analysis toolchain. This reference covers the required tools for quality PHP development.
| Tool | Purpose | Requirement |
|---|---|---|
| PHPStan | Type checking, bug detection | Level 9 minimum, level 10 recommended |
| PHPat | Architecture testing | Required for projects with defined architecture |
| Rector | Automated refactoring | Required for modernization |
| PHP-CS-Fixer | Coding style | Required with @PER-CS |
PHPStan performs static analysis to find bugs without running code.
Requirement: Level 9 minimum, level 10 (max) recommended for production code.
composer require --dev phpstan/phpstan
composer require --dev phpstan/extension-installer
# Recommended extensions
composer require --dev phpstan/phpstan-strict-rules
composer require --dev phpstan/phpstan-deprecation-rules# phpstan.neon
parameters:
level: 10 # Maximum strictness (9 is minimum acceptable)
paths:
- src
- tests
excludePaths:
- src/*/Tests/*
# Strict settings
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
reportStaticMethodSignatures: true
# Type aliases for domain concepts
typeAliases:
UserId: 'int<1, max>'
Email: 'non-empty-string'
PositiveInt: 'int<1, max>'| Level | Added Checks |
|---|---|
| 8 | Nullable values, method existence |
| 9 | Mixed type strictness (minimum for production) |
| 10 | Maximum strictness, experimental checks |
Level 9 enforces mixed type handling, catching issues like:
// Level 8 allows this (dangerous!)
public function process(mixed $data): void
{
echo $data['key']; // Could fail at runtime
}
// Level 9+ requires explicit type handling
public function process(mixed $data): void
{
if (!is_array($data) || !isset($data['key'])) {
throw new InvalidArgumentException('Expected array with key');
}
echo $data['key']; // Safe
}# .github/workflows/static-analysis.yml
name: Static Analysis
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-progress
- run: vendor/bin/phpstan analyse --error-format=githubPHPat is a PHPStan extension for testing architectural rules.
composer require --dev phpat/phpat
# With extension installer (recommended)
composer require --dev phpstan/extension-installer# phpstan.neon
includes:
- vendor/phpat/phpat/extension.neon
parameters:
paths:
- src
- tests/Architecture # Include architecture tests
phpat:
ignore_built_in_classes: true
show_rule_names: true
services:
- class: Tests\Architecture\LayerTest
tags:
- phpat.test
- class: Tests\Architecture\DependencyTest
tags:
- phpat.test<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;
final class LayerTest
{
/**
* Domain layer must not depend on Infrastructure.
*/
public function testDomainIndependence(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Domain'))
->shouldNotDependOn()
->classes(Selector::inNamespace('App\Infrastructure'))
->because('Domain must be independent of infrastructure');
}
/**
* Controllers must only depend on Application layer.
*/
public function testControllerDependencies(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Controller'))
->canOnlyDependOn()
->classes(
Selector::inNamespace('App\Application'),
Selector::inNamespace('Symfony'),
Selector::inNamespace('Psr'),
);
}
/**
* Entities must not use repositories directly.
*/
public function testEntityPurity(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Entity'))
->shouldNotDependOn()
->classes(Selector::classname('*Repository'));
}
}<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;
final class DependencyTest
{
/**
* Services must implement interfaces.
*/
public function testServicesImplementInterfaces(): Rule
{
return PHPat::rule()
->classes(Selector::classname('*Service'))
->excluding(Selector::classname('*Interface'))
->shouldImplement()
->classes(Selector::classname('*Interface'));
}
/**
* No class should depend on concrete implementations.
*/
public function testDependOnAbstractions(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App'))
->shouldNotDependOn()
->classes(
Selector::classname('GuzzleHttp\Client'),
Selector::classname('Doctrine\ORM\EntityManager'),
)
->because('Depend on interfaces, not implementations');
}
/**
* DTOs must be final and readonly.
*/
public function testDtoStructure(): Rule
{
return PHPat::rule()
->classes(Selector::classname('*DTO'))
->shouldBeFinal()
->shouldBeReadonly();
}
/**
* Commands must not return values.
*/
public function testCommandsAreVoid(): Rule
{
return PHPat::rule()
->classes(Selector::inNamespace('App\Command'))
->shouldHaveOnlyOnePublicMethod()
->andThisMethodShouldHaveReturnType('void');
}
}| Selector | Description |
|---|---|
Selector::inNamespace('App\Domain') |
All classes in namespace |
Selector::classname('*Service') |
Classes matching pattern |
Selector::implements('Interface') |
Classes implementing interface |
Selector::extends('BaseClass') |
Classes extending class |
Selector::isAbstract() |
Abstract classes only |
Selector::isFinal() |
Final classes only |
Rector automates code migrations and refactoring.
composer require --dev rector/rectorPer-version SymfonySetList::SYMFONY_* constants are all
@deprecated upstream — they don't handle package differences. The
modern shape is to let Rector read installed versions from composer.lock:
<?php
// rector.php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withSkip([
__DIR__ . '/src/Kernel.php',
])
->withSets([
LevelSetList::UP_TO_PHP_85, // bump alongside composer.json's php constraint
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::TYPE_DECLARATION,
SetList::PRIVATIZATION,
SetList::EARLY_RETURN,
SymfonySetList::SYMFONY_CODE_QUALITY, // still non-deprecated
])
->withComposerBased(symfony: true) // auto-detects Symfony major from composer.lock
->withAttributesSets(symfony: true) // annotation → attribute migration
->withRules([
AddVoidReturnTypeWhereNoReturnRector::class,
]);withComposerBased(symfony: true) replaces every per-version
SymfonySetList::SYMFONY_60/SYMFONY_70/SYMFONY_80 etc. and
auto-tracks the installed Symfony major. Same for doctrine,
phpunit, behat flags on the same call.
# Preview changes — INVOKE THE BINARY DIRECTLY.
# `composer rector -- --dry-run` does NOT forward the flag (composer
# script aliases swallow trailing args) and runs Rector in apply mode.
vendor/bin/rector process --dry-run
bin/rector process --dry-run # netresearch convention
# Apply changes
vendor/bin/rector process
# Process specific path
vendor/bin/rector process src/Entity/
# Clear cache
vendor/bin/rector process --clear-cache| Set | Purpose |
|---|---|
LevelSetList::UP_TO_PHP_85 |
PHP version upgrade — bump alongside composer.json's php constraint; available from Rector 2.4+ |
SetList::CODE_QUALITY |
Improve code quality |
SetList::DEAD_CODE |
Remove unused code |
SetList::TYPE_DECLARATION |
Add type declarations |
SetList::PRIVATIZATION |
Make code more private |
SetList::EARLY_RETURN |
Convert to early returns |
If your installed Rector predates 2.4, the upper UP_TO_PHP_* constant
will be UP_TO_PHP_84 or lower — check vendor/rector/rector/src/Set/ValueObject/LevelSetList.php
for what's actually available.
# In CI, run Rector in dry-run to catch unrefactored code
- name: Rector Check
run: vendor/bin/rector process --dry-run --ansiEnforces coding standards automatically.
composer require --dev friendsofphp/php-cs-fixer<?php
// .php-cs-fixer.dist.php
declare(strict_types=1);
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests');
return (new PhpCsFixer\Config())
->setRules([
'@PER-CS' => true, // Latest PER Coding Style
'@PER-CS:risky' => true,
'declare_strict_types' => true,
'strict_param' => true,
'array_syntax' => ['syntax' => 'short'],
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'single_quote' => true,
'trailing_comma_in_multiline' => true,
])
->setRiskyAllowed(true)
->setFinder($finder);PHP-CS-Fixer 3.50.0+ renamed several rule set aliases. Always check for deprecations:
vendor/bin/php-cs-fixer fix --dry-run 2>&1 | grep -A 20 "Detected deprecations"See references/php-cs-fixer-deprecations.md for the full mapping of deprecated aliases to replacements.
# Check for violations (dry run)
vendor/bin/php-cs-fixer fix --dry-run --diff
# Fix violations
vendor/bin/php-cs-fixer fix
# Verbose output
vendor/bin/php-cs-fixer fix -v- name: PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff --ansiname: Quality Assurance
on: [push, pull_request]
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Install dependencies
run: composer install --no-progress
- name: PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
- name: PHPStan (Level 10)
run: vendor/bin/phpstan analyse --error-format=github
- name: Rector
run: vendor/bin/rector process --dry-run --ansi{
"scripts": {
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"stan": "phpstan analyse",
"rector:check": "rector process --dry-run",
"rector:fix": "rector process",
"qa": [
"@cs:check",
"@stan",
"@rector:check"
],
"fix": [
"@cs:fix",
"@rector:fix"
]
}
}| Tool | Minimum Threshold |
|---|---|
| PHPStan | Level 9 (level 10 for new projects) |
| PHPat | All architecture tests pass |
| Rector | No remaining suggestions |
| PHP-CS-Fixer | Zero violations |
For existing projects, use baselines to adopt tools incrementally:
# PHPStan baseline
vendor/bin/phpstan analyse --generate-baseline
# Include in phpstan.neon
includes:
- phpstan-baseline.neonReduce baseline errors over time until reaching zero.
phpstan.neon's tmpDir (e.g. tmpDir: /tmp/phpstan-X) caches
analysis results keyed to vendor/ stubs. After any of:
composer install/composer update- A rebase that pulled in a
phpstan-*extension orphpunitmajor bump - Mass test refactors (mock → stub conversions, etc.)
…the cache may report the OLD shape as clean while CI fails on the new. Always purge before final verify:
rm -rf /tmp/phpstan-* var/cache/phpstan
vendor/bin/phpstan analyse --no-progressMake this part of the agent's verification step, not optional.
PHP-CS-Fixer (and, less often, PHPStan) behaves in a PHP-version-specific
way: which fixers apply and how they format depends on the PHP version the
binary runs under. So php vendor/bin/php-cs-fixer fix --dry-run on a newer
local PHP can report a file clean while CI — running on the project's pinned,
older PHP — rewrites it and fails the gate. (Seen in practice: a closure
formatted fn ( passed local PHP 8.5 but CI's PHP 8.2 demanded fn(, costing a
red Quality gate and a force-push.)
Run quality gates through the project's own runner so the PHP version matches CI:
- If the repo ships a wrapper —
make code-style/make phpstan, a composer script (composer ci:cs,composer ci:test:php:cs), or a Docker-based runner — use it. It pins the PHP version and the config CI uses. - If you must call the binary directly, invoke it under the same PHP version CI
uses (e.g.
docker run --rm -v "$PWD":/app -w /app php:8.2-cli vendor/bin/php-cs-fixer fix --dry-run), not your local default interpreter. PHP_CS_FIXER_IGNORE_ENVonly silences the version-mismatch warning; it does not make a newer PHP behave like the pinned one.
Make "run the gate the way CI runs it" part of the verification step — a locally-green cs-fixer/PHPStan is not proof until it has run on CI's toolchain.