
Php Modernization
- 90 installs
- 33 repo stars
- Updated July 27, 2026
- dirnbauer/webconsulting-skills
Modernizes PHP code to 8.1-8.5 features and PSR compliance with PHPStan, Rector, and PHP-CS-Fixer tooling.
About
Modernizes PHP code toward 8.1-8.5 features, PSR/PER-CS compliance, and type safety using PHPStan, Rector, and PHP-CS-Fixer. A developer uses it when upgrading PHP code and enforcing modern standards.
- PHP 8.1-8.5 features, enums, readonly, property hooks
- PHPStan, Rector, PHP-CS-Fixer tooling workflow
Php Modernization by the numbers
- 90 all-time installs (skills.sh)
- Ranked #45 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dirnbauer/webconsulting-skills --skill php-modernizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 90 |
|---|---|
| repo stars | ★ 33 |
| Last updated | July 27, 2026 |
| Repository | dirnbauer/webconsulting-skills ↗ |
What it does
Modernizes PHP code to 8.1-8.5 features and PSR compliance with PHPStan, Rector, and PHP-CS-Fixer tooling.
Files
PHP Modernization
Agent contract
1. Discover: uv run ${CLAUDE_SKILL_DIR}/scripts/introspect.py (cheap), or verify_php_project.py --summary (full, with agent_actions[]). 2. Drill: ... --check PM-XX per finding. Full output when triaging >3. 3. Apply: uv run ${CLAUDE_SKILL_DIR}/scripts/modernize_loop.py --mode dry-run. Review transcript before applying. 4. References: load on demand; do not pre-load.
Reference routing
| Need | Read |
|---|---|
| PHP 8.0-8.3 baseline | references/php8-features.md |
| PHP 8.4 | references/php-8.4.md |
| PHP 8.5 | references/php-8.5.md |
| PSR / PER-CS | references/psr-per-compliance.md |
| PHPStan config | references/phpstan-compliance.md |
| Static analysis | references/static-analysis-tools.md |
| PHP-CS-Fixer deprecations | references/php-cs-fixer-deprecations.md |
| DTOs / VOs / inputs | references/type-safety.md, references/request-dtos.md |
| Adapter / registry | references/adapter-registry-pattern.md |
| Multi-version compat | references/multi-version-adapters.md |
| Symfony patterns | references/symfony-patterns.md |
| PSR-15 middleware | references/psr15-middleware-architecture.md |
| Doctrine edges | references/doctrine-modernization-edges.md |
| API Platform | references/api-platform-edges.md |
| Immutability | references/immutability-boundaries.md |
| Contracts & invariants | references/contracts-and-invariants.md |
| Mutation testing | references/mutation-testing.md |
| Migration planning | references/migration-strategies.md |
| PHPUnit 12→13, mock vs stub | references/phpunit-modernization.md |
| Multi-agent dispatch hazards | references/multi-agent-pitfalls.md |
Hard guardrails
- Never apply
readonlyto Doctrine entities or mapped-superclasses (embeddables: seereferences/doctrine-modernization-edges.md). - Never run Rector without
--dry-run. Invokevendor/bin/rectordirectly — composer script aliases can drop---forwarded flags depending on configuration. - Never raise PHPStan level without regenerating + committing the baseline. Shrink, never delete.
- Never apply blanket
finalto mock targets or extension points without confirmation. - Never edit
@generatedfiles or files undervar/cache/,vendor/,node_modules/,.Build/. - Never
git checkout --files outside your scope in shared trees — usegit stash/git diff. - Never trust a warm PHPStan cache after vendor change:
rm -rf /tmp/phpstan-* var/cache/phpstanfirst. - Never mass-substitute
createMock→createStub— promote toexpects(...)->method(...)->with(...).
Migration checklist
- [ ]
declare(strict_types=1)everywhere - [ ]
@PER-CS, no deprecated aliases - [ ] PHPStan ≥9 (
treatPhpDocTypesAsCertain: false); 10 for new - [ ] PHPat for layer boundaries
- [ ] Return + parameter types on all methods
- [ ] DTOs over arrays; backed enums over constants
- [ ] PSR interfaces in type-hints
- [ ]
#[Override](8.3+),#[SensitiveParameter](8.2+), typed constants (8.3+) - [ ] readonly on DTOs/VOs/events only
- [ ] Property hooks (8.4);
array_find/any/all(8.4); pipe|>(8.5) - [ ] PHPUnit 12+: stubs use
createStub, mockscreateMock+expects(noself::any()in 13) - [ ] Rector
withComposerBased(symfony: true)(per-versionSymfonySetList::SYMFONY_*are@deprecated)
---
Credits & Attribution
This skill is based on the excellent work by [Netresearch DTT GmbH](https://www.netresearch.de/).
Original repository: https://github.com/netresearch/php-modernization-skill
Copyright (c) Netresearch DTT GmbH — Methodology and best practices (MIT / CC-BY-SA-4.0)
Special thanks to Netresearch DTT GmbH for their generous open-source contributions to the TYPO3 community, which helped shape this skill collection. Adapted by webconsulting.at for this skill collection
# Checkpoints for php-modernization skill
# Verifies PHP code quality tools and modern PHP patterns
# DEPRECATED CHECKPOINT IDs (relocated to typo3-conformance / typo3-extension-upgrade skills):
# PM-26, PM-27, PM-28, PM-33, PM-35, PM-36
# These IDs MUST NOT be reused. They were TYPO3-specific and have been moved
# to the TYPO3 skill set as part of the v2.x agent-harness refocus.
version: 1
skill_id: php-modernization
mechanical:
# === PHPSTAN CONFIGURATION ===
- id: PM-01
type: command
command: "test -f phpstan.neon || test -f Build/phpstan.neon || test -f Build/phpstan/phpstan.neon || test -f phpstan.neon.dist"
severity: error
desc: "PHPStan configuration must exist (phpstan.neon, Build/phpstan.neon, Build/phpstan/phpstan.neon, or phpstan.neon.dist)"
- id: PM-02
type: command
command: 'for f in phpstan.neon Build/phpstan.neon Build/phpstan/phpstan.neon phpstan.neon.dist; do [ -f "$f" ] && grep -qE "^[[:space:]]*level:[[:space:]]*(9|1[0-9]|max)([[:space:]]|$)" "$f" && exit 0; done; exit 1'
severity: error
desc: "PHPStan level must be 9 or higher (or max)"
- id: PM-03
type: contains
target: "{phpstan.neon,Build/phpstan.neon,Build/phpstan/phpstan.neon,phpstan.neon.dist}"
pattern: "treatPhpDocTypesAsCertain: false"
severity: warning
desc: "PHPStan should not trust PHPDoc types over runtime"
# === PHP-CS-FIXER CONFIGURATION ===
- id: PM-04
type: file_exists
target: "{.php-cs-fixer.php,.php-cs-fixer.dist.php}"
severity: error
desc: "PHP-CS-Fixer configuration must exist"
- id: PM-05
type: contains
target: "{.php-cs-fixer.php,.php-cs-fixer.dist.php}"
pattern: "@PER-CS"
severity: error
desc: "PHP-CS-Fixer must use @PER-CS ruleset"
- id: PM-06
type: contains
target: "{.php-cs-fixer.php,.php-cs-fixer.dist.php}"
pattern: "strict_types"
severity: warning
desc: "PHP-CS-Fixer should enforce strict_types declaration"
# === STRICT TYPES DECLARATION ===
- id: PM-07
type: contains
target: "{src,Classes}/**/*.php"
pattern: "declare(strict_types=1)"
severity: error
desc: "PHP files must declare strict_types=1"
- id: PM-08
type: contains
target: "{tests,Tests}/**/*.php"
pattern: "declare(strict_types=1)"
severity: error
desc: "Test files must declare strict_types=1"
# === RECTOR CONFIGURATION ===
- id: PM-09
type: file_exists
target: "{rector.php,Build/rector.php,Build/rector/rector.php}"
severity: warning
desc: "Rector configuration should exist for automated refactoring"
- id: PM-10
type: contains
target: "{rector.php,Build/rector.php,Build/rector/rector.php}"
pattern: "LevelSetList"
severity: warning
desc: "Rector should use LevelSetList for PHP version upgrades"
- id: PM-11
type: contains
target: "{rector.php,Build/rector.php,Build/rector/rector.php}"
pattern: "SetList::DEAD_CODE"
severity: info
desc: "Rector should include DeadCodeSet rules"
- id: PM-12
type: contains
target: "{rector.php,Build/rector.php,Build/rector/rector.php}"
pattern: "SetList::CODE_QUALITY"
severity: info
desc: "Rector should include CodeQualitySetList rules"
# === COMPOSER CONFIGURATION ===
- id: PM-13
type: json_path
target: composer.json
pattern: '.scripts["cs:fix"] or .scripts["fix:cs"] or .scripts["php:cs:fix"]'
severity: warning
desc: "Composer should have a coding standards fix script"
- id: PM-14
type: json_path
target: composer.json
pattern: '.scripts["phpstan"] or .scripts["analyse"] or .scripts["analyze"]'
severity: warning
desc: "Composer should have a PHPStan script"
- id: PM-15
type: json_path
target: composer.json
pattern: '.scripts["rector"] or .scripts["refactor"]'
severity: info
desc: "Composer should have a Rector script"
# === DEV DEPENDENCIES ===
# Note: command type checks both direct and transitive dependencies
# (e.g., via shared CI packages like netresearch/composer-patches-plugin)
- id: PM-16
type: command
command: "test -f .Build/bin/phpstan || grep -q phpstan composer.json || grep -q phpstan composer.lock"
severity: warning
desc: "PHPStan should be available (directly or transitively via a CI package)"
- id: PM-17
type: command
command: "test -f .Build/bin/php-cs-fixer || grep -q php-cs-fixer composer.json || grep -q php-cs-fixer composer.lock"
severity: warning
desc: "PHP-CS-Fixer should be available (directly or transitively via a CI package)"
# Rector can be a direct require-dev OR pulled transitively via the
# netresearch/typo3-ci-workflows meta-package. Same pattern as TT-64
# in typo3-testing-skill PR #63.
- id: PM-18
type: json_path
target: composer.json
pattern: '.["require-dev"]["rector/rector"] // .["require-dev"]["netresearch/typo3-ci-workflows"]'
severity: warning
desc: "Rector should be in require-dev (also satisfied transitively via netresearch/typo3-ci-workflows)"
# === PSR-4 AUTOLOADING ===
- id: PM-19
type: json_path
target: composer.json
pattern: '.autoload["psr-4"]'
severity: error
desc: "composer.json must have PSR-4 autoloading configured"
# === PHP-CS-FIXER COPYRIGHT HEADER ===
# Schema fix: previous definition used unrecognised `name`/`path`/`message`
# keys instead of the standard `desc`/`target`. The runner saw an empty
# `target` and emitted "Target file not found:" with no path.
- id: PM-30
type: regex
target: "{.php-cs-fixer.php,.php-cs-fixer.dist.php,Build/.php-cs-fixer.php}"
pattern: "header_comment"
severity: info
desc: "PHP-CS-Fixer should configure the header_comment rule for consistent copyright headers across all PHP files"
# === PHP-CS-FIXER RISKY RULES ===
- id: PM-24
type: contains
target: "{.php-cs-fixer.php,.php-cs-fixer.dist.php}"
pattern: "risky"
severity: info
desc: "PHP-CS-Fixer should enable @PER-CS:risky for comprehensive enforcement"
# === PHPAT ARCHITECTURE TESTS ===
- id: PM-25
type: command
command: "grep -rq 'phpat' composer.json 2>/dev/null || grep -rq 'phpat' composer.lock 2>/dev/null"
severity: info
desc: "PHPat should be configured for architecture testing in defined architectures"
# === MULTI-VERSION LIBRARY ADAPTERS ===
- id: PM-37
type: command
command: "! find src Classes -type f -name '*.php' 2>/dev/null -exec sh -c 'grep -Eq \"(method_exists|function_exists)\" \"$1\" && ! grep -Eq \"@phpstan-ignore method\\.dynamicName\" \"$1\"' sh {} \\; -print -quit | grep -q ."
severity: info
desc: "Classes using method_exists() for version detection should use adapter pattern with dynamic dispatch (@phpstan-ignore method.dynamicName)"
# === DOCBLOCK QUALITY ===
- id: PM-50
type: regex_not
target: "{src,Classes}/**/*.php"
pattern: '\*/\n\s{4}/\*\*'
severity: warning
desc: "Avoid duplicate consecutive docblocks between methods — merge @return/@param into the main docblock"
# === PHPSTAN BEST PRACTICES ===
- id: PM-51
type: regex_not
target: "{src,Classes,tests,Tests}/**/*.php"
pattern: '@phpstan-ignore-next-line'
severity: warning
desc: "Use @phpstan-ignore with specific identifier (e.g., // @phpstan-ignore argument.type) instead of @phpstan-ignore-next-line"
- id: PM-52
type: regex_not
target: "{src,Classes}/**/*.php"
pattern: '\(string\)\s*\$(?:this->arguments|GLOBALS)\b'
severity: warning
desc: "Avoid (string) cast on mixed — use assert(is_string()) or is_string() guard for proper type narrowing"
- id: PM-53
type: command
command: "for f in phpstan.neon phpstan.neon.dist Build/phpstan.neon; do if [ -f \"$f\" ]; then level=$(sed -n 's/^[[:space:]]*level:[[:space:]]*//p' \"$f\" 2>/dev/null); case \"$level\" in 10|max) exit 0 ;; esac; echo \"WARN: $f uses PHPStan level '$level' — level 10 (max) recommended\" && exit 1; fi; done; exit 0"
severity: warning
desc: "Projects should target PHPStan level 10 (max) for full strict typing (not gated on PHP version — verify applicability)"
# === IMMUTABILITY BOUNDARIES (guardrail) ===
- id: PM-39
type: command
command: "! grep -rlE '^(final\\s+)?readonly\\s+class' src/ Classes/ 2>/dev/null | xargs -r grep -lE '#\\[(ORM\\\\)?Entity|#\\[(ORM\\\\)?MappedSuperclass' 2>/dev/null | grep -q ."
severity: error
desc: "readonly must not be applied to Doctrine entities or mapped-superclasses (Doctrine hydrates them via reflection-bypassing the constructor). Embeddables are a nuanced case — see references/doctrine-modernization-edges.md"
llm_reviews:
# === READONLY CLASS STATIC PROPERTY CHECK ===
- id: PM-32
type: llm_review
severity: error
domain: code-quality
prompt: |
Review all PHP classes in Classes/ marked as `readonly class`:
1. Check if any readonly class contains static properties (private static, protected static, public static)
2. PHP 8.2+ readonly classes cannot have static properties
3. Report each file and class where a readonly class declares a static property
Only flag cases where the static property is inside the readonly class body, not in unrelated classes in the same file.
desc: "PHP 8.2+ readonly classes cannot declare static properties. Remove static properties or remove the readonly class modifier."
# === CODE QUALITY PATTERNS ===
- id: PM-20
domain: code-quality
prompt: |
Review the PHP codebase for methods that use array parameters or return arrays
where structured data (DTOs, Value Objects) would be more appropriate.
Look for patterns like:
- Methods with @param array<string, mixed> that represent structured data
- Methods returning associative arrays that could be typed objects
- Array-heavy constructors that should use named parameters or DTOs
Examine files in Classes/ directory. Report specific files and methods
that should be refactored to use typed objects instead of arrays.
This is important for:
- IDE autocompletion
- Static analysis accuracy
- Self-documenting code
- Type safety at runtime with strict_types
severity: warning
desc: "Identify array params/returns that should be DTOs or Value Objects"
- id: PM-21
domain: code-quality
prompt: |
Review the PHP codebase for class constants that should be converted
to backed enums (available since PHP 8.1).
Look for patterns like:
- Classes with multiple related STATUS_*, TYPE_*, or STATE_* constants
- Constants used in switch statements or match expressions
- Constants that represent a finite set of valid values
- Method parameters typed as int|string that accept constant values
Examine files in Classes/ directory. Report specific files and constant
groups that would benefit from enum conversion.
Good candidates for enums:
- Status constants (PENDING, ACTIVE, COMPLETED)
- Type constants (TYPE_A, TYPE_B, TYPE_C)
- State machine states
- Configuration option values
severity: warning
desc: "Identify class constants that should be backed enums"
- id: PM-22
domain: code-quality
prompt: |
Review the codebase for proper use of PHP 8+ features:
1. Constructor property promotion - are properties declared the old way
when they could use promotion?
2. Named arguments - are there function calls with many positional
parameters that would benefit from named arguments?
3. Match expressions - are there complex switch statements that
could be simplified to match expressions?
4. Nullsafe operator - are there verbose null checks that could use ?->?
5. Union/intersection types - are there PHPDoc types that could be
native union types?
Report specific files and line numbers where modernization is needed.
severity: info
desc: "Verify modern PHP 8+ features are used where appropriate"
- id: PM-23
domain: code-quality
prompt: |
Review the PHPStan configuration for completeness:
1. Is level 9 or max used?
2. Are all relevant paths included in paths/scanDirectories?
3. Are extension-specific extensions configured (e.g., phpstan-typo3)?
4. Is treatPhpDocTypesAsCertain set to false?
5. Are there excessive baseline entries that should be fixed?
6. Is checkMissingIterableValueType enabled?
7. Is checkGenericClassInNonGenericObjectType enabled?
Suggest specific configuration improvements.
severity: warning
desc: "Verify PHPStan configuration follows best practices"
# === DRY / CODE DUPLICATION ===
- id: PM-29
name: No duplicated utility methods across classes
type: llm_review
severity: warning
domain: dry
prompt: |
Scan for duplicated utility methods across PHP classes:
1. Type coercion helpers (intVal, stringVal, intFrom) in multiple classes
2. Translation helpers (translate, getTranslation) in multiple classes
3. Encryption/key access methods duplicated across services
4. User extraction from $GLOBALS duplicated across controllers
If found, suggest extracting into shared traits in a Utility/ namespace.
tags: [dry, duplication, traits, refactoring]
# === TRAIT NAMESPACE ARCHITECTURE ===
- id: PM-31
name: Shared traits in Utility or dedicated namespace
type: llm_review
severity: warning
domain: architecture
prompt: |
Check that shared traits (used by classes in multiple namespaces)
are placed in a neutral namespace like Utility/ or Traits/.
A trait in Controller/ should not be used by Middleware/ or UserSettings/.
This prevents architecture layer violations detected by tools like PHPat.
tags: [traits, namespace, architecture, phpat]
# === COPY-ON-WRITE AWARENESS FOR DESTRUCTIVE OPERATIONS ===
- id: PM-34
name: Copy-on-write safety for destructive memory operations
type: llm_review
severity: error
domain: code-quality
prompt: |
Review the codebase for destructive in-place memory operations that may
be affected by PHP's copy-on-write (COW) semantics.
Check for patterns like:
1. sodium_memzero($var) where $var was assigned from another variable
(e.g., $key = $this->masterKey; sodium_memzero($key) — only zeroes
the copy, not the original)
2. Any destructive function on string or buffer data (for example,
sodium_memzero or similar zeroization routines) applied to a variable
that is a COW reference to another value
3. Variables assigned via simple assignment ($a = $b) then modified
destructively — the original $b is unaffected due to COW
4. Suggest using references (&$var) or operating directly on the source
variable when the intent is to destroy the original data
This is critical for security-sensitive code handling encryption keys,
passwords, or other secrets where memory cleanup matters.
tags: [cow, copy-on-write, sodium, security, memory]
# === MULTI-VERSION LIBRARY ADAPTER PATTERN ===
- id: PM-38
name: Multi-version library compatibility via adapter pattern
type: llm_review
severity: warning
domain: code-quality
prompt: |
Review the codebase for direct usage of libraries that support multiple
major versions (check composer.json for "^X || ^Y" constraints).
Check for:
1. Direct library class usage in service classes instead of through an adapter interface
2. method_exists() or function_exists() checks on typed library parameters
(PHPStan narrows these, making the check ineffective for static analysis)
3. Version-specific @phpstan-ignore tags (method.notFound, argument.type) that
would fail on the other supported version
4. Missing adapter interface — consumer classes should depend on a project-owned
interface, not the library class directly
Correct pattern:
- Define an interface with unified method signatures
- Create adapter class accepting `object` (not typed library class)
- Use dynamic dispatch ($obj->{$method}()) with @phpstan-ignore method.dynamicName
- Wire interface to adapter in Services.yaml
- Consumer classes depend only on the interface
Report specific files where library classes are used directly and suggest
adapter extraction.
tags: [adapter, multi-version, phpstan, compatibility, di]
# === TYPE NARROWING PATTERNS ===
- id: PM-80
type: llm_review
severity: warning
domain: code-quality
prompt: |
Review the PHP codebase for type narrowing patterns that bypass static analysis:
1. (string) or (int) casts on mixed-type variables instead of assert(is_string())
or is_string() guards — casts silence PHPStan without proving the type
2. @var annotations used to override inferred types instead of proper instanceof
checks or is_array() guards
3. @phpstan-ignore-next-line used to suppress errors that could be resolved with
proper type narrowing (assert, instanceof, is_*() guards)
4. Broad exception catches (catch (\Throwable)) where specific exception types
would provide better type information in the catch block
Correct patterns:
- assert(is_string($value)) narrows type for both runtime and static analysis
- if (!$obj instanceof Foo) { throw ... } narrows type in subsequent code
- is_array($data) in if-guard narrows type in the branch
- Specific catch types provide typed exception properties
Report specific files and patterns where type narrowing should replace
casts, annotations, or ignore directives.
tags: [type-narrowing, phpstan, assert, instanceof, casts]
# === IMMUTABILITY BOUNDARIES (LLM companion to PM-39) ===
- id: PM-40
type: llm_review
domain: code-quality
severity: error
prompt: |
Review the codebase for `readonly class` or `final readonly class` declarations.
Flag any class that is ALSO one of:
1. Annotated with #[ORM\Entity], #[ORM\Embeddable], or #[ORM\MappedSuperclass]
2. Implements a Form-bound interface (e.g., extends Symfony AbstractType bound model)
3. Implements __unserialize / Serializable / is restored via session/cache
4. Used as a hydration target by a deserializer (Symfony Serializer, JMS, Symfony ObjectMapper)
readonly is correct for DTOs, value objects, events, and command/query objects.
readonly is INCORRECT for entities, hydration targets, and any class needing post-construct property assignment.
Report specific files violating this boundary.
desc: "readonly is incorrect on entities, form-bound models, and deserialization targets"
# === MUTATION-SCORE GUARDRAIL ===
- id: PM-41
type: llm_review
domain: testing
severity: warning
prompt: |
If `infection.json` or `infection.json5` exists, evaluate whether the project follows
the recommended Infection strategy (see references/mutation-testing.md):
1. Diff-mode (--git-diff-base) configured for PR runs
2. --min-msi threshold set (>= 70 baseline, >= 80 for new code)
3. Excluded paths cover legitimate non-test surface (logging, debug-only, generated code)
4. CI invokes Infection on PR diff, not full codebase per push
Report deviations and suggest configuration improvements.
desc: "Infection should run in diff-mode for PRs with explicit MSI threshold"
# === API PLATFORM RESOURCE/ENTITY SEPARATION ===
- id: PM-42
type: llm_review
domain: code-quality
severity: warning
prompt: |
If composer.json depends on `api-platform/core` or `api-platform/symfony`,
review the codebase for proper separation of API resources and Doctrine entities:
1. Classes annotated `#[ApiResource]` that ALSO have Doctrine mapping
attributes (`#[ORM\Entity]`) — flag as a tight coupling that prevents
making the API resource `readonly` and complicates evolution.
2. State providers/processors that are not constructor-injected — flag.
3. Input/Output DTOs that are arrays instead of typed classes — flag.
4. API resources that are mutable when they could be `final readonly` — flag.
Reference `references/api-platform-edges.md` for the recommended split.
desc: "API resources should be readonly DTOs, separate from Doctrine entities"
- id: PM-43
type: llm_review
domain: code-quality
severity: error
prompt: |
Review all PSR-15 middleware process() methods that contain a broad catch
block: catch(\Throwable), catch(Throwable), catch(\Exception), catch(Exception),
or multi-catch forms like catch(FooException|\Throwable $e).
In TYPO3 and Symfony middleware stacks, certain exceptions are framework
control-flow signals that must propagate to the error-handling middleware
further up the stack. Swallowing them (logging and continuing) prevents
error pages, redirects, and access controls from working correctly.
Flag any broad catch block that does NOT re-throw at least the following
types before handling the error:
TYPO3 projects:
- TYPO3\CMS\Core\Http\ImmediateResponseException (carries PSR-7 response, must propagate)
- TYPO3\CMS\Core\Http\PropagateResponseException (same)
- TYPO3\CMS\Core\Error\Http\StatusException (base for 404, 503, etc. — handled by SiteBasedErrorHandlingMiddleware)
Symfony projects:
- Symfony\Component\HttpKernel\Exception\HttpExceptionInterface (carries HTTP status code + headers, must propagate for HttpKernel to render the correct error response)
- Symfony\Component\Security\Core\Exception\AccessDeniedException (used by security firewall to trigger authentication entry point or render access-denied page)
A correct guard looks like (TYPO3):
if ($e instanceof ImmediateResponseException
|| $e instanceof PropagateResponseException
|| $e instanceof StatusException
) {
throw $e;
}
Or for Symfony:
if ($e instanceof HttpExceptionInterface
|| $e instanceof AccessDeniedException
) {
throw $e;
}
Report the file, method, and missing re-throw types for each violation.
desc: 'PSR-15 middleware broad catch(\Throwable) blocks must re-throw framework control-flow exceptions (TYPO3: ImmediateResponseException, PropagateResponseException, StatusException; Symfony: HttpExceptionInterface, AccessDeniedException)'
Adapter Registry Pattern
Purpose: Dynamic adapter instantiation from runtime configuration
Overview
The Adapter Registry pattern separates PHP implementation classes (Adapters) from configuration records (Providers). This allows runtime selection of an implementation based on configuration loaded from any source (database, config file, environment).
When to Use
- Integrating with multiple external services (APIs, SDKs)
- Supporting multiple implementations of the same interface
- Dynamic provider selection based on configuration
- Clean separation between protocol logic and connection credentials
- Multi-version library compatibility (see also
multi-version-adapters.md)
Terminology
| Term | Description | Example |
|---|---|---|
| Adapter | PHP class implementing protocol | GdAdapter, ImagickAdapter |
| Provider | Configuration record with credentials/options | Row in DB, config struct, env-loaded DTO |
| Registry | Maps provider type string → adapter class | ImagingAdapterRegistry |
Pattern Structure
┌─────────────────────────────────────────────────────────────┐
│ AdapterRegistry │
│ ───────────────────────── │
│ Maps adapter_type string → PHP Adapter class │
│ Creates configured adapter instances from Provider records │
└──────────────────────────┬──────────────────────────────────┘
│ creates
▼
┌─────────────────────────────────────────────────────────────┐
│ AdapterInterface │
│ ───────────────── │
│ Common contract for all adapters │
│ configure(), execute(), supports() │
└─────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
┌──────────┴─────┐ ┌──────────┴─────┐ ┌──────────┴─────┐
│ GdAdapter │ │ ImagickAdapter │ │ VipsAdapter │
│ │ │ │ │ │
│ adapter_type: │ │ adapter_type: │ │ adapter_type: │
│ "gd" │ │ "imagick" │ │ "vips" │
└────────────────┘ └────────────────┘ └────────────────┘Implementation
Adapter Interface
<?php
declare(strict_types=1);
namespace App\Imaging;
interface AdapterInterface
{
/**
* Configure adapter with provider settings.
*
* @param array<string, mixed> $config
*/
public function configure(array $config): void;
/**
* Check if adapter supports a capability.
*/
public function supports(string $capability): bool;
}Concrete Adapter
<?php
declare(strict_types=1);
namespace App\Imaging\Adapter;
use App\Imaging\AdapterInterface;
use Psr\Http\Client\ClientInterface;
final class GdAdapter implements AdapterInterface
{
private int $quality = 85;
private string $tmpDir = '/tmp';
private int $timeout = 30;
public function __construct(
private readonly ClientInterface $httpClient,
) {}
public function configure(array $config): void
{
$this->quality = (int) ($config['quality'] ?? 85);
$this->tmpDir = (string) ($config['tmpDir'] ?? '/tmp');
$this->timeout = (int) ($config['timeout'] ?? 30);
}
public function supports(string $capability): bool
{
return in_array($capability, ['resize', 'crop', 'jpeg', 'png'], true);
}
/**
* @return array{path: string, width: int, height: int}
*/
public function resize(string $sourcePath, int $width, int $height): array
{
// GD-based resize implementation
$img = imagecreatefromstring(file_get_contents($sourcePath));
$resized = imagescale($img, $width, $height);
$outPath = $this->tmpDir . '/' . uniqid('img_', true) . '.jpg';
imagejpeg($resized, $outPath, $this->quality);
return ['path' => $outPath, 'width' => $width, 'height' => $height];
}
}Provider Record (Configuration DTO)
<?php
declare(strict_types=1);
namespace App\Imaging\Config;
final class ImagingProvider
{
public const ADAPTER_GD = 'gd';
public const ADAPTER_IMAGICK = 'imagick';
public const ADAPTER_VIPS = 'vips';
public function __construct(
public readonly string $identifier,
public readonly string $name,
public readonly string $adapterType,
public readonly int $quality = 85,
public readonly string $tmpDir = '/tmp',
public readonly int $timeout = 30,
public readonly bool $isActive = true,
) {}
}Registry Implementation
<?php
declare(strict_types=1);
namespace App\Imaging;
use App\Imaging\Adapter\GdAdapter;
use App\Imaging\Adapter\ImagickAdapter;
use App\Imaging\Adapter\VipsAdapter;
use App\Imaging\Config\ImagingProvider;
use Psr\Container\ContainerInterface;
final class ImagingAdapterRegistry
{
/**
* Maps adapter_type string to adapter class
*
* @var array<string, class-string<AdapterInterface>>
*/
private const array ADAPTER_MAP = [
ImagingProvider::ADAPTER_GD => GdAdapter::class,
ImagingProvider::ADAPTER_IMAGICK => ImagickAdapter::class,
ImagingProvider::ADAPTER_VIPS => VipsAdapter::class,
];
public function __construct(
private readonly ContainerInterface $container,
) {}
/**
* @return list<string>
*/
public function getAvailableAdapterTypes(): array
{
return array_keys(self::ADAPTER_MAP);
}
public function hasAdapterType(string $adapterType): bool
{
return isset(self::ADAPTER_MAP[$adapterType]);
}
/**
* Create adapter instance from a provider record.
*
* @throws \InvalidArgumentException If adapter type unknown
*/
public function createAdapterFromProvider(ImagingProvider $provider): AdapterInterface
{
$adapterClass = self::ADAPTER_MAP[$provider->adapterType]
?? throw new \InvalidArgumentException(
sprintf('Unknown adapter type: %s', $provider->adapterType)
);
/** @var AdapterInterface $adapter */
$adapter = $this->container->get($adapterClass);
$adapter->configure([
'quality' => $provider->quality,
'tmpDir' => $provider->tmpDir,
'timeout' => $provider->timeout,
]);
return $adapter;
}
}DI container configuration
Wire the registry as a public service and each adapter under its FQCN. The exact format depends on the framework (Symfony YAML/PHP, Laminas, PHP-DI, Pimple, custom). The registry must receive a PSR-11 ContainerInterface. Each adapter must be retrievable by its class name.
# Example: Symfony-style services file
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App\Imaging\ImagingAdapterRegistry:
public: true
App\Imaging\Adapter\GdAdapter: ~
App\Imaging\Adapter\ImagickAdapter: ~
App\Imaging\Adapter\VipsAdapter: ~For other DI containers, perform the equivalent factory wiring: register the registry as a service that receives the container, and ensure each adapter class is resolvable by FQCN.
Usage Examples
In a Service Class
<?php
declare(strict_types=1);
namespace App\Service;
use App\Imaging\Config\ImagingProviderRepository;
use App\Imaging\ImagingAdapterRegistry;
final class ThumbnailService
{
public function __construct(
private readonly ImagingProviderRepository $providerRepository,
private readonly ImagingAdapterRegistry $adapterRegistry,
) {}
/**
* @return array{path: string, width: int, height: int}
*/
public function makeThumbnail(string $providerId, string $sourcePath, int $w, int $h): array
{
$provider = $this->providerRepository->findByIdentifier($providerId)
?? throw new \InvalidArgumentException('Provider not found: ' . $providerId);
$adapter = $this->adapterRegistry->createAdapterFromProvider($provider);
if (!$adapter->supports('resize')) {
throw new \RuntimeException('Adapter does not support resize');
}
return $adapter->resize($sourcePath, $w, $h);
}
}In a Controller
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Imaging\Config\ImagingProviderRepository;
use App\Imaging\ImagingAdapterRegistry;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class ProviderController
{
public function __construct(
private readonly ImagingProviderRepository $providerRepository,
private readonly ImagingAdapterRegistry $adapterRegistry,
private readonly ResponseFactory $responseFactory,
) {}
public function testConnectionAction(ServerRequestInterface $request): ResponseInterface
{
$providerId = (string) ($request->getParsedBody()['provider'] ?? '');
$provider = $this->providerRepository->findByIdentifier($providerId);
if ($provider === null) {
return $this->responseFactory->jsonError('Provider not found', 404);
}
try {
$adapter = $this->adapterRegistry->createAdapterFromProvider($provider);
return $this->responseFactory->json([
'success' => true,
'capabilities' => array_filter(
['resize', 'crop', 'jpeg', 'png', 'webp', 'avif'],
static fn (string $cap): bool => $adapter->supports($cap),
),
]);
} catch (\Throwable $e) {
return $this->responseFactory->jsonError($e->getMessage(), 400);
}
}
}Extending with New Adapters
1. Create Adapter Class
<?php
declare(strict_types=1);
namespace App\Imaging\Adapter;
use App\Imaging\AdapterInterface;
final class WebpAdapter implements AdapterInterface
{
// Implement interface...
}2. Add to Provider Constants
// In ImagingProvider
public const ADAPTER_WEBP = 'webp';3. Update Registry Map
// In ImagingAdapterRegistry
private const array ADAPTER_MAP = [
// ... existing adapters
ImagingProvider::ADAPTER_WEBP => WebpAdapter::class,
];4. Register in DI container
Add the new adapter to your container configuration (services file, factory wiring, etc.) so the registry can resolve it via the PSR-11 container.
Benefits
| Benefit | Description |
|---|---|
| Separation of Concerns | Protocol logic separate from configuration |
| Runtime Flexibility | Select implementation via configuration |
| Testability | Mock adapters easily in tests |
| Extensibility | Add new adapters without changing existing code |
| Type Safety | Interface ensures consistent API across adapters |
Testing
<?php
declare(strict_types=1);
namespace App\Tests\Imaging;
use App\Imaging\Adapter\GdAdapter;
use App\Imaging\Config\ImagingProvider;
use App\Imaging\ImagingAdapterRegistry;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
final class ImagingAdapterRegistryTest extends TestCase
{
public function testCreatesCorrectAdapterForProviderType(): void
{
$mockAdapter = $this->createMock(GdAdapter::class);
$mockAdapter->expects(self::once())
->method('configure')
->with(self::callback(static fn (array $cfg): bool
=> $cfg['quality'] === 90 && $cfg['timeout'] === 60));
$container = $this->createMock(ContainerInterface::class);
$container->method('get')
->with(GdAdapter::class)
->willReturn($mockAdapter);
$registry = new ImagingAdapterRegistry($container);
$provider = new ImagingProvider(
identifier: 'gd-default',
name: 'GD default',
adapterType: 'gd',
quality: 90,
timeout: 60,
);
$adapter = $registry->createAdapterFromProvider($provider);
self::assertInstanceOf(GdAdapter::class, $adapter);
}
public function testThrowsExceptionForUnknownAdapterType(): void
{
$container = $this->createMock(ContainerInterface::class);
$registry = new ImagingAdapterRegistry($container);
$provider = new ImagingProvider(
identifier: 'unknown',
name: 'Unknown',
adapterType: 'unknown',
);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown adapter type: unknown');
$registry->createAdapterFromProvider($provider);
}
}Related Patterns
- Strategy Pattern: Adapters implement the Strategy pattern
- Factory Pattern: Registry acts as a factory for adapters
- Dependency Injection: Adapters created via PSR-11 container
Related References
multi-version-adapters.md— Adapter pattern for multi-version library compatibilitysymfony-patterns.md— Dependency injection patternstype-safety.md— Interface and type declarations
API Platform × PHP Modernization: Edges Only
This document covers only the edges where modern PHP features and API Platform interact. For API Platform usage, see https://api-platform.com/docs/.
API Platform is API-first, attribute-driven, and built on Symfony. The modernization patterns most likely to interact with it are: final readonly classes, constructor promotion, attribute-based configuration, PSR-3 logging, PHP 8 attributes for validation, and the strict separation between mutable Doctrine entities and immutable API resources.
API Resources as Immutable DTOs
An API resource declared with #[ApiResource] is, in the recommended pattern, a DTO — not a Doctrine entity. DTOs have no internal state evolution, no Doctrine reflection-based hydration, and no proxy generation. That makes them ideal candidates for final readonly class (PHP 8.2+).
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
#[ApiResource(
operations: [
new Get(uriTemplate: '/books/{id}'),
new GetCollection(uriTemplate: '/books'),
],
provider: BookOutputProvider::class,
)]
final readonly class BookOutput
{
public function __construct(
public string $id,
public string $title,
public string $author,
public \DateTimeImmutable $publishedAt,
) {}
}Distinction: a Doctrine entity (#[ORM\Entity]) must NOT be readonly — Doctrine bypasses the constructor and writes via reflection, which readonly blocks. See references/immutability-boundaries.md for the full explanation. The pattern below ("Doctrine entity vs API Resource separation") shows how to keep both correct.
Input/Output DTOs and the Symfony Serializer Interaction
When an #[ApiResource] declares separate input: / output: classes, those classes should be DTOs — not arrays.
Before (array-based input, no type guarantees):
#[ApiResource(
operations: [new Post(uriTemplate: '/books')],
// no input class — body deserializes to array, then a processor extracts keys
)]
class Book
{
// ...
}After (DTO-based input, full type safety, validated):
final readonly class CreateBookInput
{
public function __construct(
public string $title,
public string $author,
public \DateTimeImmutable $publishedAt,
) {}
}
#[ApiResource(
operations: [
new Post(
uriTemplate: '/books',
input: CreateBookInput::class,
output: BookOutput::class,
processor: CreateBookProcessor::class,
),
],
)]
final readonly class BookOutput { /* ... */ }The Symfony Serializer hydrates CreateBookInput from the request body. Because all properties are constructor-promoted and readonly, the deserializer must use a constructor-based denormalizer — which is the default in modern Symfony Serializer versions (ObjectNormalizer calls the constructor when properties are typed and promoted). Validate that the denormalizer used in your project supports constructor-based hydration; if you target Symfony 6.4+ / 7.x this is the default.
State Providers and Processors as PHP 8 Services
ProviderInterface (read) and ProcessorInterface (write) implementations are plain Symfony services. They should use constructor injection only — never new or service-locator patterns for dependencies — and benefit from final readonly themselves.
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* @implements ProcessorInterface<CreateBookInput, BookOutput>
*/
final readonly class CreateBookProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = [],
): BookOutput {
\assert($data instanceof CreateBookInput);
$entity = Book::register($data->title, $data->author, $data->publishedAt);
$this->em->persist($entity);
$this->em->flush();
$this->logger->info('Book created', ['id' => $entity->id()]);
return new BookOutput(
id: (string) $entity->id(),
title: $entity->title(),
author: $entity->author(),
publishedAt: $entity->publishedAt(),
);
}
}Type the PSR-3 LoggerInterface parameter, not Monolog's concrete Logger. The processor is final readonly — it has no mutable state and no inheritance contract. Symfony autowires it via constructor.
Filters with Attribute-based Configuration
API Platform filters configured via #[ApiFilter(...)] go on the resource class or directly on individual properties.
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
#[ApiResource]
#[ApiFilter(OrderFilter::class, properties: ['title', 'publishedAt'])]
final readonly class BookOutput
{
public function __construct(
public string $id,
#[ApiFilter(SearchFilter::class, strategy: 'ipartial')]
public string $title,
#[ApiFilter(SearchFilter::class, strategy: 'exact')]
public string $author,
public \DateTimeImmutable $publishedAt,
) {}
}PHP 8.4 property hooks would, in principle, allow computed/validated properties on the resource — but API Platform's filter introspection reads the declared property type, not the hook return type. For filtered properties, prefer plain promoted properties; reserve hooks for non-filtered derived state. Verify against your API Platform version (3.4+ has improved property metadata handling but hooks remain conservative).
Validation with PHP 8 Attributes
Symfony Validator constraints are PHP 8 attributes. They compose cleanly with constructor promotion and readonly on input DTOs that double as the validated payload.
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateBookInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 255)]
public string $title,
#[Assert\NotBlank]
public string $author,
#[Assert\NotNull]
public \DateTimeImmutable $publishedAt,
#[Assert\Email]
#[Assert\NotBlank]
public string $contactEmail,
) {}
}API Platform invokes the validator on the deserialized input before passing it to the processor. Constraint violations surface as a 422 Unprocessable Entity response with a Hydra-formatted error payload — no manual validation in the processor required. See references/request-dtos.md for the broader request-DTO pattern; this is the API-Platform-specific application of it.
Doctrine Entity vs API Resource Separation
The cleanest pattern: keep mutable Doctrine entities, expose immutable API resources, map between them in a state provider/processor. This isolates the persistence model from the API contract — schema can evolve without breaking clients, and the API resource can expose a different shape than the row.
// Persistence: mutable, Doctrine-managed.
#[ORM\Entity]
class Book
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
public function __construct(
#[ORM\Column] private string $title,
#[ORM\Column] private string $author,
#[ORM\Column] private \DateTimeImmutable $publishedAt,
) {}
public function id(): ?int { return $this->id; }
public function title(): string { return $this->title; }
public function author(): string { return $this->author; }
public function publishedAt(): \DateTimeImmutable { return $this->publishedAt; }
}
// API contract: immutable, no Doctrine annotations.
#[ApiResource(provider: BookOutputProvider::class)]
final readonly class BookOutput
{
public function __construct(
public string $id,
public string $title,
public string $author,
public \DateTimeImmutable $publishedAt,
) {}
}
// Provider: maps entity → resource at the boundary.
final readonly class BookOutputProvider implements ProviderInterface
{
public function __construct(private BookRepository $books) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$entity = $this->books->find($uriVariables['id'] ?? null);
return $entity === null ? null : new BookOutput(
id: (string) $entity->id(),
title: $entity->title(),
author: $entity->author(),
publishedAt: $entity->publishedAt(),
);
}
}See references/doctrine-modernization-edges.md for why entities cannot be readonly and how to factor invariants into factory methods.
#[ApiResource] and the readonly Trap
A common shortcut: put #[ApiResource] directly on the Doctrine entity and skip the DTO. It works — and for a thin CRUD service it can be a reasonable trade-off. But:
- The class cannot be
final readonly— Doctrine bypasses the constructor on hydration. Seereferences/immutability-boundaries.md. - Schema evolution is now an API breaking change.
- Filters, normalization groups, and serialization context all live on a class that also has to satisfy ORM constraints.
If your API resource IS the Doctrine entity, accept that the class stays mutable and non-readonly. If your API resource is a DTO mapped from an entity (the recommended pattern above), it should be final readonly. Cross-link: references/immutability-boundaries.md lists this as one of the four scenarios where readonly is incorrect.
Async / Mercure / Message Handlers
When a processor publishes a Mercure update or dispatches a Symfony Messenger message, the payload should be a final readonly event DTO — never an array, never the entity itself. PSR-14-style event objects are the right shape.
final readonly class BookCreated
{
public function __construct(
public string $bookId,
public string $title,
public \DateTimeImmutable $occurredAt,
) {}
}
// Inside the processor, after persistence:
$this->messageBus->dispatch(new BookCreated(
bookId: (string) $entity->id(),
title: $entity->title(),
occurredAt: new \DateTimeImmutable(),
));Messenger serializes the message for transport (when using AMQP/Redis/SQS); a readonly constructor-promoted class round-trips cleanly through Symfony's default Messenger serializer. Avoid passing the Doctrine entity itself across the bus — proxy state, lazy collections, and identity-map assumptions do not survive serialization.
Summary
The PHP modernization patterns most likely to collide with API Platform are: final readonly (correct on resources/DTOs/events, incorrect when the resource IS the entity), constructor injection (the only acceptable shape for providers/processors), and the input/output DTO split (typed classes, never arrays). Apply each pattern at the right boundary:
- API resource as DTO →
final readonly - Input / Output DTOs →
final readonlywith#[Assert\*]attributes - State providers / processors →
final readonlyservices, constructor-injected, PSR-3 logger - Doctrine entity exposed as resource → mutable, non-
readonly, accept the trade-off - Event/message payloads →
final readonly, never the entity
Contracts & Invariants
Encode preconditions, postconditions, and invariants as runtime checks in the code path. Treat them as the bridge between a spec sentence and the tests that verify it: the same predicate becomes the inline assertion, the PHPUnit assertion, and (where it makes sense) the property-based test oracle.
When to Use
- Value objects whose validity is structural ("amount never negative", "ISBN matches the checksum")
- DTOs that cross a domain boundary (HTTP edge → application core; queue payload → handler)
- Service methods that move a system between named states (orders, subscriptions, workflows)
- Money, quantities, identifiers with shape constraints
- Aggregate roots whose internal consistency outlives a single method
When NOT to Use
- Plain CRUD glue, controller plumbing, framework callbacks
- Anything driven by user input → use validation and typed errors (
Symfony\Component\Validator,Webmozart\Assertwith caller-facing exceptions, form constraints) - Doctrine entity hydration paths where reflection re-creates state (see
references/immutability-boundaries.md)
Test: would a violation indicate the program is in an impossible state, given correct inputs? Yes → contract. No → validation.
Tooling
Native assert()
PHP compiles assert() calls under zend.assertions:
zend.assertions=1(dev / CI): assertions run, failures throwAssertionErrorzend.assertions=-1(production): the compiler strips the calls entirely — zero cost
; php.ini (development / CI)
zend.assertions = 1; php.ini (production)
zend.assertions = -1(Note: assert.exception was deprecated in PHP 8.3 — under zend.assertions=1, failures already throw AssertionError. Don't set it on PHP 8.3+.)
Because assert() is strippable, do not rely on it for input validation, security checks, or side-effecting checks. It is for invariants the code itself is supposed to maintain.
Reference: <https://www.php.net/manual/en/function.assert.php>
webmozart/assert
Use for always-on runtime checks at boundaries (input validation, library-API guard rails). The exceptions thrown are InvalidArgumentException; most application frameworks need an explicit exception-listener / handler to map that to a 4xx response (Symfony's kernel.exception listener, Laravel's Handler::register, Slim's error middleware, etc.). Configure that mapping or the failure surfaces as a generic 500.
use Webmozart\Assert\Assert;
public function withdraw(int $amountCents): void
{
Assert::positiveInteger($amountCents); // caller's responsibility
Assert::lessThanEq($amountCents, $this->balanceCents);
$this->balanceCents -= $amountCents;
\assert($this->balanceCents >= 0, 'invariant: balance >= 0'); // self-check
}The split is deliberate: Webmozart\Assert for "is this caller using the API correctly?", native assert() for "is this object still in a valid state?".
@phpstan-assert (static analysis)
Teach PHPStan that an assertion narrows a type, so subsequent code can rely on it without redundant guards:
/**
* @phpstan-assert non-empty-string $value
*/
private static function requireNonEmpty(string $value, string $field): void
{
if ($value === '') {
throw new \InvalidArgumentException(sprintf('%s must not be empty', $field));
}
}After requireNonEmpty($email, 'email'), PHPStan treats $email as non-empty-string for the remainder of the scope.
Reference: <https://phpstan.org/writing-php-code/phpdocs-basics#assertions>
Custom InvariantViolation
For checks that must survive zend.assertions=-1 in production, throw a domain-specific error. Reserve for crystalline guarantees whose violation must crash the request even in production:
final class InvariantViolation extends \LogicException
{
public static function of(string $message): self
{
return new self('invariant: ' . $message);
}
}
public function applyDiscount(Money $discount): self
{
if ($discount->isNegative()) {
throw InvariantViolation::of('discount must not be negative');
}
$next = $this->total->minus($discount);
if ($next->isNegative()) {
throw InvariantViolation::of('total would go negative after discount');
}
return new self($next);
}LogicException semantically means "the program is wrong", which is exactly what an invariant violation signals.
Patterns
Constructor-time invariants on value objects
The single best place to encode structural validity:
final readonly class EmailAddress
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(sprintf('invalid email: %s', $value));
}
}
}Every place in the codebase that takes EmailAddress can rely on its validity without re-checking. Cross-link: references/immutability-boundaries.md, references/type-safety.md.
Pre/postconditions on service methods
Document the contract in the docblock; enforce it at runtime.
final class TransferService
{
/**
* Transfer funds between accounts.
*
* Contract:
* pre: $amount->isPositive() // caller's responsibility → always-on guard
* pre: $from->balance->isGreaterThanOrEqual($amount) // caller's responsibility → always-on guard
* post: $from->balance + $to->balance == old($from->balance + $to->balance)
* inv: account balances never go negative
*/
public function transfer(Account $from, Account $to, Money $amount): void
{
// Caller-facing preconditions: always-on. Webmozart throws
// InvalidArgumentException, which the framework maps to a 4xx.
Assert::true($amount->isPositive(), 'amount must be positive');
Assert::true($from->balance->isGreaterThanOrEqual($amount), 'insufficient balance');
$sumBefore = $from->balance->plus($to->balance);
$from->debit($amount);
$to->credit($amount);
// Self-checks: strippable in production. They guard *our own*
// implementation, not the caller.
\assert($from->balance->plus($to->balance)->equals($sumBefore), 'postcondition: conservation');
\assert(!$from->balance->isNegative() && !$to->balance->isNegative(), 'invariant: non-negative');
}
}Property hooks as invariant points (PHP 8.4)
Hooks let you guard the one mutation site for a property, so the invariant cannot be bypassed by future setters:
final class Inventory
{
public int $available {
set (int $value) {
if ($value < 0) {
throw InvariantViolation::of('available stock must not go negative');
}
$this->available = $value;
}
}
}Cross-link: references/php-8.4.md for the wider property-hooks pattern.
Property-Based Tests from Contracts
A postcondition is automatically a property: "for all valid inputs, the postcondition holds". Two routes in PHP:
Targeted data providers (pragmatic)
PHPUnit data providers cover representative inputs. Paired with mutation testing (infection/infection), they catch most contract drift in domain code:
use PHPUnit\Framework\Attributes\DataProvider;
public static function withdrawalCases(): iterable
{
yield 'zero balance, zero withdrawal' => [0, 0, 0];
yield 'full balance withdrawal' => [100, 100, 0];
yield 'partial withdrawal' => [100, 30, 70];
}
#[DataProvider('withdrawalCases')]
public function testWithdrawalPreservesBalanceInvariant(int $initial, int $amount, int $expected): void
{
$account = new Account($initial);
$account->withdraw($amount);
self::assertSame($expected, $account->balance);
self::assertGreaterThanOrEqual(0, $account->balance); // invariant
}Pair with Infection (references/mutation-testing.md) to verify the assertions are load-bearing — a mutant that violates the contract must kill the test.
giorgiosironi/eris (property-based)
eris is the maintained property-based testing library for PHPUnit. Useful where the input space is large and the postcondition is more tractable than enumerating cases:
use Eris\Generator;
use Eris\TestTrait;
final class AccountPropertyTest extends \PHPUnit\Framework\TestCase
{
use TestTrait;
public function testWithdrawNeverGoesNegative(): void
{
$this->forAll(
Generator\nat(1_000_000),
Generator\seq(Generator\nat(10_000)),
)->then(function (int $initial, array $ops): void {
$account = new Account($initial);
foreach ($ops as $op) {
try {
$account->withdraw($op);
} catch (\InvalidArgumentException) {
// validation rejection is fine
}
self::assertGreaterThanOrEqual(0, $account->balance);
}
});
}
}Be honest about the cost: eris is well-suited to algorithmic / value-object code and overkill for typical CRUD services. Use it for the 10% where postconditions are non-trivial and the input space is genuinely large.
Reference: <https://github.com/giorgiosironi/eris>
Common Mistakes
| Mistake | Fix |
|---|---|
Using assert() for input validation | Use Webmozart\Assert or throw an InvalidArgumentException. assert() is strippable. |
Custom InvariantViolation everywhere "to be safe" | Reserve it for crystalline production-critical guarantees; let strippable assert() carry the rest |
Asserting on Doctrine-hydrated state in __construct | Hydration bypasses the constructor; assert in a separate validity method called after persistence loads |
| Postcondition that re-implements the function | The postcondition states the property (conservation, monotonicity, bounded range), not the steps |
Sprinkling assert() decoratively in controllers | Gate by domain — value objects, services, aggregates. Not framework glue. |
| Property tests on every method | Pick the few methods whose postcondition is a real algebraic property; let providers + Infection cover the rest |
Cross-References
references/type-safety.md— typed properties, DTOs, value objectsreferences/immutability-boundaries.md— wherereadonlyis correct and where it is wrongreferences/request-dtos.md— boundary validation of incoming payloadsreferences/php-8.4.md— property hooks for one-site enforcementreferences/mutation-testing.md— Infection as the strength-test for your assertionsreferences/phpstan-compliance.md—@phpstan-assertand the type-narrowing payoff
Core Rules & Examples
DTOs Required
When passing structured data, always use DTOs instead of arrays:
// Bad: public function createUser(array $data): array
// Good: public function createUser(CreateUserDTO $dto): UserDTOEnums Required
When defining fixed value sets, always use backed enums instead of constants:
// Bad: const STATUS_DRAFT = 'draft'; function setStatus(string $s)
// Good: enum Status: string { case Draft = 'draft'; }PSR Interface Compliance
When type-hinting dependencies, use PSR interfaces (PSR-3, PSR-6, PSR-7, PSR-11, PSR-14, PSR-18).
Scoring Criteria
| Criterion | Requirement |
|---|---|
| PHPStan | Level 9 minimum |
| PHP-CS-Fixer | @PER-CS zero violations |
| DTOs/VOs | No array params/returns for structured data |
| Enums | Backed enums for fixed value sets |
Doctrine ORM × PHP Modernization: Edges Only
This document covers only the edges where modern PHP features and Doctrine ORM interact. It is not a Doctrine guide. For Doctrine ORM usage — mapping, DQL, query builder, schema management, migrations — consult the official documentation at https://www.doctrine-project.org/projects/orm.html.
Doctrine ORM 3.x Baseline Expectations
Doctrine ORM 3.x (released April 2024) closed off several legacy extension paths that modern PHP code commonly relied on. Before applying modernization patterns, confirm you are working against 3.x and not 2.x.
EntityRepository is no longer extended by inheritance
In ORM 2.x, the typical pattern was to subclass EntityRepository:
// 2.x style — works in 2.x, not the modern recommended path
class UserRepository extends EntityRepository
{
public function findActive(): array
{
return $this->findBy(['active' => true]);
}
}In ORM 3.x, this still compiles, but the EntityRepository is increasingly closed and the recommended pattern is composition or — in Symfony — ServiceEntityRepository:
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
/**
* @extends ServiceEntityRepository<User>
*/
final class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/** @return list<User> */
public function findActive(): array
{
return $this->findBy(['active' => true]);
}
}For non-Symfony projects, prefer composition: inject EntityManagerInterface, call getRepository(User::class), and wrap the result in a domain-specific class that exposes only intent-revealing methods.
PHP 8 Attribute Mappings
Doctrine ORM 3.x is attribute-first. Annotations are removed; XML and YAML are still supported for legacy projects but attributes are the modernization target.
Migration from annotations
// Before (annotations, ORM 2.x)
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\Column(type="string", length=255, unique=true)
*/
private string $email;
}// After (attributes, ORM 3.x)
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private string $email;
}Note that type is often inferable from the property type declaration in 3.x, so explicit type: attributes are usually redundant.
Rector has a ready set: Rector\Doctrine\Set\DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES.
Embeddables as PHP Value Objects
#[ORM\Embeddable] lets a value object live as a set of columns on the parent entity, without a separate table. This is the bridge between immutable VOs (which the modernization skill recommends) and mutable entities (which Doctrine requires).
#[ORM\Embeddable]
final readonly class Money
{
public function __construct(
#[ORM\Column]
public int $cents,
#[ORM\Column(length: 3)]
public string $currency,
) {}
}
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Embedded(class: Money::class)]
private Money $total;
public function __construct(Money $total)
{
$this->total = $total;
}
public function total(): Money { return $this->total; }
public function reprice(Money $newTotal): void
{
$this->total = $newTotal;
}
}Doctrine instantiates the embeddable via reflection too, so it bypasses the constructor on hydration. final readonly works here because Doctrine's reflection-based property assignment treats the embeddable's properties as part of the parent entity's hydration lifecycle — and embeddables are never re-hydrated independently. Verify on your Doctrine version (this has historically been a moving target; on ORM 3.x with PHP 8.2+ it is supported).
Lazy Proxies vs PHP 8.4 Native Lazy Objects
Doctrine has used generated proxy classes for lazy loading since ORM 2.x. The proxy is a subclass of the entity that intercepts property access and triggers loading. Two consequences:
- The entity must not be
final, because the proxy needs to extend it. - The proxy initializes the entity at the first property access.
PHP 8.4 introduces native lazy objects (RFC: https://wiki.php.net/rfc/lazy-objects). Two flavors:
// Lazy ghost: same instance, initializer fills properties on first access.
$user = $reflClass->newLazyGhost(function (User $user): void {
$data = $repo->loadRowById($id);
$user->__construct($data['email']);
});
// Lazy proxy: a proxy object that delegates to a real instance on first access.
$user = $reflClass->newLazyProxy(fn(): User => $repo->find($id));Doctrine ORM does not yet ship a hydrator backed by newLazyGhost as the default — but support has been added incrementally (track https://github.com/doctrine/orm/issues for specifics). Today, the practical implications:
- Continue assuming Doctrine's traditional proxy generation. Do not mark entities
final. - For your own lazy-loading needs outside Doctrine (caching, aggregating, deferred external calls), prefer
newLazyGhostover hand-rolled proxies — they integrate with reflection,var_dump, and serialization correctly. - When Doctrine releases a version that uses native lazy objects internally, the
finalrestriction on entities can be revisited.
Collections and Immutability
Doctrine\Common\Collections\Collection and ArrayCollection are mutable by design — entities expose them so Doctrine can track add/remove operations for relationship updates.
Do not expose the Collection directly on a public API. Wrap it:
#[ORM\Entity]
class Order
{
/** @var Collection<int, OrderLine> */
#[ORM\OneToMany(targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'])]
private Collection $lines;
public function __construct()
{
$this->lines = new ArrayCollection();
}
public function addLine(OrderLine $line): void
{
$this->lines->add($line);
}
/** @return list<OrderLine> */
public function lines(): array
{
return array_values($this->lines->toArray());
}
}lines() returns a snapshot list. Callers cannot mutate the underlying collection through it. Mutations go through addLine() / removeLine() so the entity controls invariants.
The readonly Trap
final readonly class on a Doctrine entity will fail at hydration. See references/immutability-boundaries.md for the full explanation. The short version: Doctrine bypasses the constructor and writes properties via reflection, which readonly blocks after first assignment.
Embeddables (above) and DTOs/VOs outside the entity graph are the right places for readonly. Entities themselves stay mutable.
Hydration and Constructor Decisions
Doctrine instantiates entities via ReflectionClass::newInstanceWithoutConstructor() (or an instantiator library). The constructor is not called when an entity is loaded from the database. Three implications:
Validation in the constructor does not run on load
#[ORM\Entity]
class User
{
public function __construct(
#[ORM\Column]
private string $email,
) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \DomainException('invalid email'); // only runs on `new User(...)`
}
}
}If the database somehow contains an invalid email (legacy import, manual SQL), the entity loads without complaint. Validation must happen at write boundaries (form, command handler, named factory) or via Doctrine lifecycle callbacks (#[ORM\PrePersist], #[ORM\PreUpdate]).
Factory methods over public constructors
A common pattern: keep the constructor protected (so Doctrine can still hydrate via reflection) and expose intent-revealing factories:
#[ORM\Entity]
class User
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
protected function __construct(
#[ORM\Column(unique: true)]
private string $email,
#[ORM\Column]
private \DateTimeImmutable $registeredAt,
) {}
public static function register(string $email, \DateTimeImmutable $now): self
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \DomainException('invalid email');
}
return new self(strtolower($email), $now);
}
}new User(...) from outside is now blocked at compile time. Doctrine's reflection-based instantiation bypasses the visibility check. Application code goes through User::register(), which enforces invariants.
Private constructors are not safe
private function __construct() works in some Doctrine versions but is fragile. Stick with protected for compatibility. If a future Doctrine version requires public, the change is mechanical.
Summary
The PHP modernization patterns most likely to collide with Doctrine are: final, readonly, constructor-based validation, and immutable collections. Apply each modern pattern at the right boundary:
- DTOs, VOs, events, commands →
final readonly - Embeddables →
final readonly(verify per version) - Entities → mutable, with private/protected setters or property hooks; non-final until lazy-loading goes native
- Repositories → composition or
ServiceEntityRepository<EntityClass> - Validation → at the write boundary, not in the entity constructor
Immutability Boundaries: When readonly Is Wrong
The php-modernization skill recommends final readonly class for data-shaped classes. That recommendation is correct for DTOs, value objects, and events. It is wrong for entities, form-bound models, and most deserialization targets. This document draws the line.
What readonly Actually Does
PHP supports two forms:
- Property-level `readonly` (PHP 8.1+): the property may be initialized exactly once, only from inside the declaring class scope.
- Class-level `readonly` (PHP 8.2+): every declared property is implicitly
readonly. The class additionally cannot declare static or untyped properties, and cannot use dynamic properties.
Both forms enforce single-assignment at runtime. The constraints cannot be lifted by:
- Reflection (
ReflectionProperty::setValuethrows on a readonly property after initialization). - Cloning (clones inherit the initialized state; you must implement
__clonewith a wither pattern to "change" values). unserialize()(PHP 8.3 added__unserializesupport that respects readonly via reflection on uninitialized clones, but the constraint is still single-assignment).- Inheritance (a child class cannot redeclare a readonly property to drop the modifier).
References:
- RFC: https://wiki.php.net/rfc/readonly_properties_v2
- RFC: https://wiki.php.net/rfc/readonly_classes
Where readonly Is Correct
DTO (request/response shape)
final readonly class CreateUserRequest
{
public function __construct(
public string $email,
public string $name,
/** @var list<string> */
public array $roles,
) {}
}Value object
final readonly class Money
{
public function __construct(
public int $cents,
public string $currency,
) {}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('currency mismatch');
}
return new self($this->cents + $other->cents, $this->currency);
}
}Domain event
final readonly class OrderShipped
{
public function __construct(
public string $orderId,
public \DateTimeImmutable $occurredAt,
public string $carrier,
) {}
}These all share a property: the object is constructed once, fully populated by the constructor, and never modified.
Where readonly Is Incorrect — and Why
Doctrine entities
Doctrine ORM hydrates entities by:
1. Creating the instance without calling the constructor (via ReflectionClass::newInstanceWithoutConstructor() or, in newer versions, instantiator libraries). 2. Assigning each mapped property via reflection.
Step 2 fails on a readonly property after the proxy has been initialized — and even on first hydration, Doctrine relies on being able to write properties at any point during the unit-of-work lifecycle (re-loading, refresh, lazy proxy initialization).
#[ORM\Entity]
final readonly class User // BROKEN
{
public function __construct(
#[ORM\Id, ORM\Column]
public int $id,
#[ORM\Column]
public string $email,
) {}
}
// On EntityManager::find(User::class, 1):
// Error: Cannot modify readonly property User::$idThe correct shape: a non-readonly entity, with private or protected setters that enforce invariants.
#[ORM\Entity]
class User
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
public function __construct(
#[ORM\Column(unique: true)]
private string $email,
) {}
public function id(): ?int { return $this->id; }
public function email(): string { return $this->email; }
}Form-bound models (Symfony Form)
Symfony Form's default data mapper writes into the bound object using property accessors (PropertyAccess). It needs either public writable properties or matching setX() methods. A readonly property has neither.
You can work around this with 'mapped' => false plus manual hydration into a DTO, but at that point the form binds to a DTO and the entity is updated by a separate command/use case. That is the pattern to prefer — but the entity itself must remain mutable.
Deserialization targets
- Symfony Serializer: by default uses constructor + property writes. Readonly works only if every property is set via the constructor and the serializer is configured to use constructor arguments (
AbstractNormalizer::OBJECT_CREATION_FROM_CONSTRUCTOR/ explicit constructor argument resolution). For partial payloads, this becomes brittle. - JMS Serializer: bypasses the constructor by default, then assigns via reflection. Incompatible with
readonlyunless reconfigured per class. - Generic ObjectMapper / hand-rolled hydrators: same pattern; check whether they use constructor-only mode.
Treat readonly DTOs as deserialization targets only when you control the deserializer and have proven it goes through the constructor.
__unserialize and session-restored objects
unserialize() reconstructs objects by allocating without calling the constructor and then writing properties. PHP 8.3+ tolerates readonly inside __unserialize, but only because the engine treats the object as freshly allocated. If you rely on the default serialize/unserialize magic on a readonly class without __unserialize, behavior depends on PHP version — verify before assuming it works.
Test doubles and mocks
PHPUnit's getMockBuilder() and Mockery generate subclasses. final blocks subclassing entirely. readonly constraints propagate to the subclass. Combined, final readonly class is hard to mock.
Mitigations:
- Mock against an interface, not the concrete class. The interface stays mockable; the implementation can stay
final readonly. - Use
bovigo/assertor hand-rolled stubs for value objects (usually you don't need to mock a VO — just construct one). - For PHPUnit 10+,
createStub()on an interface is the standard path.
Decision Matrix
| Class purpose | readonly? | final? | Notes |
|---|---|---|---|
| DTO / Request DTO | yes | yes | Pure data, immutable |
| Value object (Money, Email) | yes | yes | Identity = value |
| Domain event | yes | yes | Fact, never mutates |
| Command / Query (CQRS message) | yes | yes | Bus payload |
| Immutable configuration object | yes | yes | Built once at boot |
| Doctrine entity | no | usually no | Hydration writes properties post-construct |
| Form-bound model | no | depends | Form binding requires settable properties |
| Service / use case | no needed | yes usually | Stateless behavior; final unless extension is a use case |
| Aggregate root (event-sourced) | depends | yes | If reconstructed via apply(Event), can be readonly with care |
| Test double target | no | no | Or mock against an interface |
Migration Patterns When You Want Both
Embed an immutable VO in a mutable entity
#[ORM\Entity]
class Order
{
#[ORM\Embedded(class: Money::class)]
private Money $total;
public function __construct(Money $total)
{
$this->total = $total;
}
public function reprice(Money $newTotal): void
{
$this->total = $newTotal; // entity is mutable; Money stays readonly
}
public function total(): Money { return $this->total; }
}Bind form to DTO, then map onto entity
final readonly class UpdateUserRequest
{
public function __construct(
public string $email,
public string $name,
) {}
}
final class UpdateUserHandler
{
public function __construct(private EntityManagerInterface $em) {}
public function __invoke(int $userId, UpdateUserRequest $request): void
{
$user = $this->em->find(User::class, $userId)
?? throw new \RuntimeException('user not found');
$user->changeEmail($request->email);
$user->rename($request->name);
$this->em->flush();
}
}The form binds to UpdateUserRequest (readonly). The handler mutates the entity through intent-revealing methods. The entity stays writable.
Property Hooks (PHP 8.4) as Middle Ground
PHP 8.4 introduces property hooks (RFC: https://wiki.php.net/rfc/property-hooks). They let a single property expose controlled mutation without the whole class becoming a free-for-all of public setters.
class User
{
public string $email {
set(string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \DomainException('invalid email');
}
$this->email = strtolower($value);
}
}
}For entities and form-bound models, property hooks are a cleaner alternative to writing explicit setter methods. The class is still mutable (Doctrine and Symfony Form can still write to it), but invariants are enforced at the assignment site.
See references/php-8.4.md for the full property-hooks reference.
Summary
readonly is a tool for shapes that are constructed once and observed many times. It is not a tool for shapes that are loaded from a database, populated by a form, or restored from a serialized payload. Apply it where the lifecycle matches; reach for property hooks, private setters, or intent-revealing methods where it does not.
PHP Migration Strategies
Version Upgrade Planning
Pre-Migration Assessment
# Check PHP compatibility
composer require --dev phpcompatibility/php-compatibility
# Run compatibility check
vendor/bin/phpcs -p --standard=PHPCompatibility \
--runtime-set testVersion 8.2 \
src/
# Check deprecated features
php -d error_reporting=E_ALL \
-d display_errors=1 \
vendor/bin/phpunitMigration Phases
1. Assessment (1-2 days)
- Run compatibility checks
- Identify deprecated features
- Document breaking changes
- Estimate effort
2. Preparation (2-5 days)
- Update composer.json constraints
- Fix deprecation warnings
- Update CI configuration
- Prepare feature branches
3. Execution (3-10 days)
- Apply automated fixes (Rector)
- Manual code updates
- Test extensively
- Update dependencies
4. Validation (2-3 days)
- Full test suite
- Performance benchmarks
- Security scan
- Staging deployment
Rector Automation
Basic Configuration
<?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;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withSkip([
__DIR__ . '/src/Kernel.php',
])
->withSets([
LevelSetList::UP_TO_PHP_83,
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::TYPE_DECLARATION,
SymfonySetList::SYMFONY_64,
]);Targeted Upgrades
<?php
// rector.php for specific PHP version upgrade
use Rector\Config\RectorConfig;
use Rector\Php80\Rector\Class_\AnnotationToAttributeRector;
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
use Rector\Php80\Rector\FunctionLike\MixedTypeRector;
use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withRules([
// PHP 8.0
ClassPropertyAssignToConstructorPromotionRector::class,
AnnotationToAttributeRector::class,
// PHP 8.1
ReadOnlyPropertyRector::class,
// Type declarations
MixedTypeRector::class,
]);Running Rector
# Dry run (preview changes)
vendor/bin/rector process --dry-run
# Apply changes
vendor/bin/rector process
# Process specific path
vendor/bin/rector process src/Entity/
# Generate baseline for gradual adoption
vendor/bin/rector process --clear-cacheDependency Upgrades
Composer Constraint Updates
{
"require": {
"php": ">=8.2",
"symfony/framework-bundle": "^7.0",
"doctrine/orm": "^3.0"
},
"config": {
"platform": {
"php": "8.2.0"
}
}
}Upgrade Process
# Update constraints in composer.json first
# Show outdated packages
composer outdated --direct
# Update with dry run
composer update --dry-run
# Update specific package
composer update symfony/framework-bundle --with-dependencies
# Update all
composer update
# Validate after update
composer validate --strictFramework-Specific Migrations
Symfony Upgrade
# Install Symfony Flex
composer require symfony/flex
# Update recipes
composer recipes:update
# Run deprecation detector
php bin/console debug:container --deprecations
# Use Symfony upgrade guide
# https://symfony.com/doc/current/setup/upgrade_major.htmlDoctrine Upgrade (2.x to 3.x)
// Before: Annotations
/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
*/
private $id;
}
// After: Attributes (Doctrine 3.x)
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
}Testing During Migration
Parallel Testing
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
symfony: ['6.4', '7.0']
steps:
- name: Install dependencies
run: |
composer require symfony/framework-bundle:^${{ matrix.symfony }} --no-update
composer update --prefer-dist
- name: Run tests
run: vendor/bin/phpunitDeprecation Tracking
<?php
// tests/bootstrap.php
use Symfony\Bridge\PhpUnit\DeprecationErrorHandler;
// Fail on deprecations
DeprecationErrorHandler::register(E_USER_DEPRECATED);
// Or track with threshold
putenv('SYMFONY_DEPRECATIONS_HELPER=max[direct]=0');Common Migration Patterns
Annotation to Attribute
// Rector handles this automatically, but manual pattern:
// Before
/**
* @Route("/api/users", name="api_users")
* @Method({"GET", "POST"})
*/
// After
#[Route('/api/users', name: 'api_users', methods: ['GET', 'POST'])]Array to Named Arguments
// Before
$response = new Response(
'',
200,
['Content-Type' => 'application/json']
);
// After
$response = new Response(
content: '',
status: Response::HTTP_OK,
headers: ['Content-Type' => 'application/json']
);Switch to Match
// Before
switch ($status) {
case 'active':
$color = 'green';
break;
case 'pending':
$color = 'yellow';
break;
default:
$color = 'gray';
}
// After
$color = match($status) {
'active' => 'green',
'pending' => 'yellow',
default => 'gray',
};Property Initialization
// Before (PHP 7.4)
class Service
{
private LoggerInterface $logger;
private array $config;
public function __construct(LoggerInterface $logger, array $config)
{
$this->logger = $logger;
$this->config = $config;
}
}
// After (PHP 8.0+)
class Service
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly array $config,
) {}
}Rollback Strategy
Version Control
# Create migration branch
git checkout -b php82-upgrade
# Tag pre-migration state
git tag pre-php82-upgrade
# If rollback needed
git checkout main
git branch -D php82-upgradeFeature Flags
// Enable gradual rollout
class FeatureFlags
{
public static function useNewParser(): bool
{
return getenv('USE_NEW_PARSER') === 'true'
|| PHP_VERSION_ID >= 80200;
}
}
// Usage
if (FeatureFlags::useNewParser()) {
return $this->newParser->parse($input);
}
return $this->legacyParser->parse($input);Anti-Patterns to Avoid
Premature Backwards Compatibility
Don't add backwards compatibility code for features or versions that haven't been released yet:
// ❌ BAD: BC code for unreleased extension
// Adding complexity for versions that don't exist in the wild
if ($options['auth_type'] !== null) {
// Convert legacy string to enum for BC
$placement = SecretPlacement::tryFrom($options['auth_type']);
}
// ✅ GOOD: Clean implementation without BC for unreleased code
public function request(array $options): Response
{
$placement = $options['placement']; // Just use the enum directly
// ...
}When BC is appropriate:
- After a public release with real installations
- When deprecating a feature (provide migration path)
- When API contracts exist with external consumers
When BC is NOT needed:
- Pre-release/unreleased extensions
- Internal refactoring during development
- Private/internal APIs
Rule: Only add BC code when there are actual installations to support. Premature BC creates unnecessary complexity and maintenance burden.
Post-Migration Checklist
- [ ] All tests pass on target PHP version
- [ ] No deprecation warnings in logs
- [ ] PHPStan passes at configured level
- [ ] Performance benchmarks acceptable
- [ ] Dependencies updated and compatible
- [ ] CI/CD pipelines updated
- [ ] Documentation updated
- [ ] Team trained on new features
- [ ] Rollback plan tested
- [ ] Staging environment validated
Multi-Agent Modernization Pitfalls
Empirical hazards when dispatching parallel sub-agents for a PHP modernization pass. All of these were observed in production runs and are worth bracing against in the agent's instructions.
Hazard 1: composer SCRIPT -- --flag does NOT forward the flag
Composer scripts have well-known argument-forwarding limitations. The canonical example:
"scripts": {
"rector": "rector process src --config=config/quality/rector.php"
}# Looks like a dry-run. Actually runs rector in APPLY mode.
composer rector -- --dry-runWhy it bites in multi-agent runs: a "config cleanup" sub-agent running this thinking it's safe will modify source files. If it notices and reverts via git checkout -- <files>, those files may have been modified by a sibling agent — its uncommitted work vanishes silently.
Workaround: invoke the binary directly when you need the flag.
bin/rector process src --config=config/quality/rector.php --dry-run
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse --no-progressBrief every agent: when scripted commands accept flags, hand the agent the raw binary invocation, not the composer alias.
Hazard 2: git checkout -- outside your declared scope
If sub-agents run in parallel against the same working tree, one agent's git checkout -- <path> on a file outside its declared scope can wipe out a sibling agent's uncommitted edits.
The rule (single, unambiguous):
An agent may onlygit checkout --/git restorefiles **inside
its own declared file scope**. For any file outside that scope —
including auto-generated files (Hazard 6) — use git stash,git diff, or coordinate with the orchestrator instead.Safer alternatives when comparing to a baseline:
git stash push -m "tmp", do the read-only test,git stash pop.
Stashes are agent-private as long as the name is unique.
- Spawn a separate read-only worktree with
isolation: "worktree"for
the discovery work. (Don't write across worktrees.)
git diff main -- <path>to see differences without reverting.
Hazard 3: Local PHPStan cache lies vs CI
phpstan.neon typically configures tmpDir: /tmp/phpstan-X. The cache indexes analysis results keyed to vendor stubs. Two lab conditions in which this routinely diverges from CI:
- After a rebase that bumped a
phpstan-*-bridgeextension or
PHPUnit major (CI builds vendor cleanly; local doesn't unless composer install was re-run).
- After mass test refactors where the local
tmpDiralready analysed
the OLD code shape.
Mitigation in the agent's verification step:
composer install # if composer.lock changed
vendor/bin/phpstan clear-result-cache # invalidate analysis cache safely
vendor/bin/phpstan analyse --no-progressvendor/bin/phpstan clear-result-cache is the supported way to clear PHPStan's cache — it knows the actual tmpDir from the config, and won't accidentally nuke caches from sibling projects on the same machine the way rm -rf /tmp/phpstan-* could.
Always run this before reporting [OK] No errors. The skill's default verify scripts should not declare success on a cached run.
Hazard 4: Vendor skew after rebase
If a rebase pulled in composer.lock updates (e.g. another PR upgraded a major version) and the agent doesn't run composer install, the local vendor/ still has the old version. Tests may pass locally on the old binary while failing in CI on the new.
Brief: any agent that rebases or merges into its working tree should run composer install --ignore-platform-req=... afterwards (or inside Docker if the host lacks the project's PHP extensions).
Hazard 5: File-scope overlap between concurrent agents
If two agents have overlapping file scopes, the second to finish overwrites the first's edits. Even with no overlap on lines, edits made via Read+Edit on different lines of the same file race.
Allocate scopes by file, not by feature. If two agents both need to touch JiraHttpClientService.php, sequence them — the second runs after the first commits.
When a third agent must touch a file the previous two also touched, brief it with the post-merge state and the specific line ranges, and have it re-read the file before editing.
Hazard 6: Auto-generated files repeatedly re-appearing in the diff
Symfony's symfony-cmd-driven cache:clear post-install hook regenerates config/reference.php. Doctrine migrations and schema files have similar regenerators. If the agent commits these, every subsequent composer install produces a fresh diff.
Mitigation (in order of preference):
1. Best: project gitignores the file. Suggest this in the PR. 2. Without gitignore: at the END of the agent's work, after all sibling agents have committed, run git restore <generated-file> only if that file is in YOUR declared scope (per Hazard 2). The orchestrator should give exactly one agent ownership of cleanup. 3. Mid-run: don't restore. Commit your real edits, then submit a follow-up commit that restores the regenerated file. 4. Alternative: git update-index --assume-unchanged <file> for the duration of the run — files don't appear in git status and regenerators won't pollute the diff. Reset with --no-assume-unchanged when done.
Hazard 7: Pre-commit hooks running tests on the host
Many projects' captainhook / husky / pre-commit configs run phpunit directly on host PHP. If the host lacks the project's required extensions (pdo_mysql, ldap, etc.) or has stale-cache issues, the hook fails on every commit with environmental errors that have nothing to do with the staged changes.
Workarounds in priority order:
1. Ask the user to install the missing extension or configure the hook to run via docker compose run --rm app-dev …. 2. Commit inside the project's Docker container:
docker compose run --rm app-dev sh -c \
'git config --global --add safe.directory "*" &&
git -c user.email=YOU -c user.name=YOU commit --signoff -m "…"'3. As a last resort, --no-verify — but only with explicit user permission, and only after confirming the staged change passes the tests in the project's preferred environment (Docker).
Concise briefing template
Include this in every multi-agent dispatch where modifications happen in parallel:
SHARED REPO HAZARDS:
- Use vendor/bin/rector / vendor/bin/php-cs-fixer / vendor/bin/phpstan
directly. Composer script aliases sometimes drop --forwarded flags
depending on the script body's quoting.
- Do NOT run `git checkout --`, `git restore`, or `git reset --hard`
on files OUTSIDE your declared scope. Use `git stash` / `git diff`
to compare to a baseline.
- Before reporting clean: `vendor/bin/phpstan clear-result-cache`
and re-run analyse.
- After any composer.lock change in your scope: run `composer install`.
- If pre-commit hooks block your commit: report the hook output, do
NOT --no-verify.Multi-Version Library Adapter Pattern
Source: t3x-nr-image-optimize Extension -- intervention/image v2/v4 compatibility Purpose: Support multiple major versions of a library with incompatible APIs
When to Use
- A dependency has multiple major versions with breaking API changes
- Your extension must support both versions (e.g., via
composer.json"lib/pkg": "^2.0 || ^4.0") - Direct usage of the library class causes PHPStan errors on one version or the other
method_exists()checks get narrowed by PHPStan when the variable is typed
Pattern Overview
┌──────────────────────────────────────────────────────┐
│ YourInterface │
│ ────────────── │
│ Unified method signatures your code needs │
│ encode(), resize(), getWidth(), etc. │
└──────────────────────────┬───────────────────────────┘
│ implements
▼
┌──────────────────────────────────────────────────────┐
│ LibraryAdapter │
│ ────────────── │
│ - Accepts object (not typed library class) │
│ - Detects version at construction via method_exists │
│ - Uses dynamic dispatch: $obj->{$method}(...) │
│ - Single @phpstan-ignore method.dynamicName │
└──────────────────────────────────────────────────────┘
│ used by
▼
┌──────────────────────────────────────────────────────┐
│ Consumer classes │
│ ────────────── │
│ Depend on YourInterface, never the library directly │
│ Wired via Services.yaml │
└──────────────────────────────────────────────────────┘Implementation Steps
1. Define the Interface
Define only the methods your code actually needs, using your own types (not the library's):
<?php
declare(strict_types=1);
namespace Vendor\Extension\Adapter;
interface ImageManagerInterface
{
/**
* Create an image instance from a file path or binary string.
*/
public function make(string $source): object;
/**
* Encode an image to the given format.
*
* @return string Binary image data
*/
public function encode(object $image, string $format, int $quality = 90): string;
}2. Create the Adapter
Use object type for the library instance to prevent PHPStan from narrowing method_exists() checks:
<?php
declare(strict_types=1);
namespace Vendor\Extension\Adapter;
final class InterventionImageAdapter implements ImageManagerInterface
{
private readonly bool $isV4;
/**
* @param object $manager Intervention\Image\ImageManager (v2 or v4)
*/
public function __construct(
private readonly object $manager,
) {
// Detect version by checking for v4-specific method
$this->isV4 = method_exists($manager, 'read');
}
public function make(string $source): object
{
$method = $this->isV4 ? 'read' : 'make';
// @phpstan-ignore method.dynamicName
return $this->manager->{$method}($source);
}
public function encode(object $image, string $format, int $quality = 90): string
{
if ($this->isV4) {
// v4: $image->encodeByExtension('webp', quality: 80)
$method = 'encodeByExtension';
// @phpstan-ignore method.dynamicName
$encoded = $image->{$method}($format, quality: $quality);
return (string) $encoded;
}
// v2: $image->encode('webp', 80) returns an Intervention\Image\Image
$method = 'encode';
// @phpstan-ignore method.dynamicName
$encoded = $image->{$method}($format, $quality);
return (string) $encoded;
}
}3. Wire in Services.yaml
services:
# Create the library's native manager
Intervention\Image\ImageManager:
factory: ['Vendor\Extension\Factory\ImageManagerFactory', 'create']
# Adapter wraps the native manager
Vendor\Extension\Adapter\InterventionImageAdapter:
arguments:
$manager: '@Intervention\Image\ImageManager'
# Interface points to the adapter
Vendor\Extension\Adapter\ImageManagerInterface:
alias: 'Vendor\Extension\Adapter\InterventionImageAdapter'4. Depend on the Interface
<?php
declare(strict_types=1);
namespace Vendor\Extension\Service;
use Vendor\Extension\Adapter\ImageManagerInterface;
final class ImageProcessor
{
public function __construct(
private readonly ImageManagerInterface $imageManager,
) {}
public function convertToWebp(string $sourcePath, int $quality = 80): string
{
$image = $this->imageManager->make($sourcePath);
return $this->imageManager->encode($image, 'webp', $quality);
}
}Key Techniques
Use object Type, Not the Library Class
// BAD: PHPStan narrows the type and method_exists() becomes always-true/false
public function __construct(
private readonly ImageManager $manager, // typed to specific version
) {
// PHPStan knows ImageManager::read() exists (or doesn't), ignores method_exists()
if (method_exists($this->manager, 'read')) { ... }
}
// GOOD: object prevents PHPStan from knowing the exact class
public function __construct(
private readonly object $manager, // no type narrowing
) {
// PHPStan cannot narrow — method_exists() check is respected
if (method_exists($this->manager, 'read')) { ... }
}Dynamic Method Dispatch
// BAD: version-specific @phpstan-ignore tags
// Works on v2, errors on v4:
/** @phpstan-ignore-next-line */
$image->encode('webp', 80);
// Works on v4, errors on v2:
/** @phpstan-ignore-next-line */
$image->encodeByExtension('webp', quality: 80);
// GOOD: dynamic dispatch with version-independent ignore
$method = $this->isV4 ? 'encodeByExtension' : 'encode';
// @phpstan-ignore method.dynamicName
$result = $image->{$method}(...$args);Prefer Universal API Methods
Before writing version-branching code, check whether a method exists in ALL supported versions:
// Both v2 and v4 support save() with format inference from extension
$image->save('/path/to/output.webp');
// Only v4 has toWebp() — avoid unless you version-branch
$image->toWebp(quality: 80);Always check the common API surface first. Document which methods are version-specific vs. universal in your adapter's PHPDoc.
PHPStan Considerations
| Problem | Solution |
|---|---|
method_exists() narrowed on typed param | Use object parameter type |
@phpstan-ignore-next-line for v2 method errors on v4 | Isolate in adapter with dynamic dispatch |
@phpstan-ignore-next-line is version-specific | Use method.dynamicName identifier (fires on all versions) |
| Library class not found in one version | Never import library classes in consumer code |
Version-Specific vs. Version-Independent Ignores
// VERSION-SPECIFIC INLINE IGNORES (break on the other version):
// @phpstan-ignore-next-line method.notFound // only needed when method missing
// @phpstan-ignore-next-line argument.type // only needed when signature differs
// VERSION-INDEPENDENT INLINE IGNORE (safe on all versions):
// @phpstan-ignore-next-line method.dynamicName // always fires for $obj->{$var}()Testing
Test the adapter against both library versions in CI:
# .github/workflows/ci.yml
strategy:
matrix:
include:
- php: '8.2'
deps: 'intervention/image:^2.0'
- php: '8.3'
deps: 'intervention/image:^4.0'
steps:
- run: composer require ${{ matrix.deps }} --no-update
- run: composer update --prefer-dist
- run: vendor/bin/phpunit
- run: vendor/bin/phpstan analyseRelated References
adapter-registry-pattern.md-- Runtime adapter selection from database configphpstan-compliance.md-- PHPStan error fixing and baseline strategiestype-safety.md-- Interface and type declaration patternssymfony-patterns.md-- Dependency injection and Services.yaml wiring
Mutation Testing with Infection
Operational guide for Infection (https://infection.github.io/), the de-facto mutation testing framework for PHP. Three modes, configuration, CI integration, and how to read the report.
Why Mutation Testing for Modernization
Code coverage tells you which lines were executed by the test suite. It does not tell you whether those tests would catch a regression. Mutation testing closes that gap: Infection mutates source code (changes > to >=, && to ||, drops a return statement, etc.) and re-runs your tests. If the tests still pass, the mutant escaped — meaning your test suite does not actually verify that line's behavior.
This matters for modernization because:
- Rector batch transforms rewrite syntax. Coverage stays the same. Without mutation testing, you have no signal that the rewrites preserved semantics.
- Refactoring to DTOs / readonly / enums changes data flow. Tests that asserted on the old shape may pass against the new shape without actually checking the new invariants.
- Adding strict types can change behavior at boundaries (TypeError instead of silent coercion). Mutation testing catches the cases your tests don't.
When you make a large mechanical change and the test suite stays green, mutation MSI tells you whether that's because the change is safe — or because the tests don't check.
Three Modes
Diff Mode (default for PRs)
vendor/bin/infection \
--git-diff-base=origin/main \
--git-diff-lines \
--threads=$(nproc) \
--min-msi=80Mutates only the lines changed in the current branch versus the base. Typical runtime: under a minute on a normal PR. High signal: every escaped mutant maps to code the developer just wrote or touched.
This is the default mode for PR gates. It scales: a 5000-line codebase with a 30-line PR runs in seconds.
--git-diff-lines (rather than --git-diff-filter) restricts mutation to the changed lines, not entire files. Without it, a one-line change pulls the whole file into mutation scope.
Full Mode (scheduled, not per-PR)
vendor/bin/infection --threads=$(nproc) --min-msi=70Mutates the entire codebase. Runtime ranges from minutes (small libraries) to hours (large applications). Run nightly on main, on release branches, or on a manual trigger — never on every PR.
Use full mode to track MSI trend over time. Diff mode keeps PRs fast; full mode keeps the codebase honest.
First-Time / Legacy Baseline
The first run on a legacy codebase is ugly. Realistic numbers: 30–50% MSI, hundreds of escaped mutants. Don't gate on this immediately.
Strategy: 1. Run full mode once. Record the MSI as the baseline. 2. Set minMsi slightly below the current value (e.g., baseline 42% → gate at 40%). 3. Exclude generated code, debug helpers, and anything you genuinely don't want mutated. 4. Each sprint, ratchet minMsi upward.
# Initial baseline run
vendor/bin/infection \
--initial-tests-php-options="-d memory_limit=2G" \
--threads=$(nproc) \
--only-covered--only-covered skips mutating uncovered code (the report would be misleading if you can't kill mutants in lines no test executes). For a legacy baseline, this gives you a cleaner picture of what your existing tests cover semantically.
Configuration
Sample infection.json5:
{
$schema: "vendor/infection/infection/resources/schema.json",
source: {
directories: ["src"],
excludes: [
"DependencyInjection",
"Migrations",
"**/*Generated*"
]
},
timeout: 10,
logs: {
text: "infection.log",
json: "infection.json",
github: true,
stryker: { badge: "main" }
},
mutators: {
"@default": true,
"Throw_": false
},
minMsi: 70,
minCoveredMsi: 80,
testFramework: "phpunit",
testFrameworkOptions: "--testsuite=unit"
}Key options:
- `source.directories` — what gets mutated. Usually just
src. Don't includetests. - `source.excludes` — patterns relative to each source directory. Common excludes: framework boilerplate (
DependencyInjection), migrations, generated code, fixtures. - `timeout` — per-mutant test run timeout in seconds. If your test suite has slow integration tests, raise this; otherwise keep it tight to avoid hanging on infinite-loop mutants.
- `mutators` —
@defaultenables the standard set. Disable specific mutators that produce noise (Throw_mutates throw statements; often noisy in defensive code). - `minMsi` vs `minCoveredMsi` — see below.
- `testFrameworkOptions` — passed through to PHPUnit. Restricting to
--testsuite=unitis usually right; functional tests are slow and flaky under mutation pressure.
minMsi vs minCoveredMsi
- MSI (Mutation Score Indicator): killed mutants ÷ total mutants generated. Includes mutants in uncovered code (which can never be killed by definition, so they always escape).
- Covered MSI: killed mutants ÷ mutants in covered code. The pure measure of your test suite's mutation-killing ability.
Gate on `minCoveredMsi` for PR diffs (you want the new code's tests to be sharp). Gate on `minMsi` for full runs (you also want overall coverage to grow).
A common pair: minCoveredMsi: 80 and minMsi: 70. The 10-point gap absorbs the "we have a few uncovered helpers" reality without letting the suite degrade.
CI Integration
GitHub Actions, diff mode on PRs:
- name: Mutation testing (PR diff)
if: github.event_name == 'pull_request'
run: |
vendor/bin/infection \
--git-diff-base=origin/${{ github.base_ref }} \
--git-diff-lines \
--threads=$(nproc) \
--min-msi=80 \
--logger-github
env:
INFECTION_BADGE_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}--logger-github annotates the PR diff with escaped mutants directly on the changed lines. The developer sees the missed test on the file view, not buried in the run log.
For nightly full mode, schedule a separate workflow:
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
mutation-full:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 }
# ... PHP/Composer setup ...
- run: vendor/bin/infection --threads=$(nproc) --min-msi=70Don't run full mode on PRs — it will routinely time out, and the signal-to-cost ratio is bad.
Common False-Positive Sources
Not every escaped mutant is a real test gap. Recognize the noise:
- Log messages — mutating the message string of a log call rarely affects behavior. Exclude logger calls or exclude
String_mutators near them. - Debug-only branches — code wrapped in
if ($this->debug)won't be exercised in tests. Either cover it or exclude. - Defensive coding past the type system — a
nullcheck on aprivate intproperty that is always initialized is unreachable. Mutation will detect this; the right fix is to remove the check, not to add a test. - Generated code — DI containers, ORM proxies, hand-rolled but mechanically generated lookup tables. Always exclude.
- Equivalent mutants — a mutation that produces semantically identical code (e.g., changing the order of two commutative operations). Infection's mutator set tries to avoid these but a few slip through. Mark them with
@infection-ignore-allor specific mutator annotations on the line.
/** @infection-ignore-all */
private function logForDevelopers(string $msg): void
{
$this->logger->debug($msg);
}Reading the Report
Infection produces three views:
1. Console summary — Mutation Score Indicator (MSI): 76% and counts of killed / escaped / errored / timed-out / not covered mutants. 2. Text log (infection.log) — one entry per escaped mutant, with a unified diff of the mutation and the file:line. 3. JSON log (infection.json) — same data, machine-readable.
A typical entry:
1) src/Order/Pricing.php:42 [M] Greater
--- Original
+++ New
@@ @@
- if ($cart->subtotal() > $threshold) {
+ if ($cart->subtotal() >= $threshold) {
return $this->discounted($cart);
}Read this as: "I changed > to >=. Your tests passed anyway. Therefore, your tests don't distinguish behavior at the threshold boundary."
Two interpretations:
- The code is buggy (off-by-one at the boundary, but no test catches it). Add a boundary test, possibly fix the operator.
- The test doesn't check this case (the threshold boundary doesn't matter for current behavior). Add a test that asserts the exact boundary, even if just to document the chosen semantics.
In both cases the action is the same: write the test. The mutant is killed and the suite is sharper.
When Not to Gate on Infection
- First week after introduction. Let the team see the report before failing builds on it. Gate informationally first, enforced second.
- Legacy code without a baseline. Run, record, ratchet. Don't fail the first PR that touches a 10-year-old controller.
- Very small modules. A file with 3 mutants is unstable: one false positive moves MSI from 100% to 67%. Apply Infection at the package or directory level, not per-file.
- Tests that are themselves under refactor. Mutation testing on a moving test suite produces noise. Land the test changes first.
- Integration-heavy code. If 80% of your behavior lives in HTTP/database integration, unit-level mutation testing under-reports. Either accept that coverage is limited, or invest in a faster integration harness.
Summary
Diff mode for PRs (fast, high signal). Full mode nightly (trend tracking). Baseline first on legacy, ratchet up. Gate on minCoveredMsi for PRs, on minMsi for full runs. Use --logger-github for actionable PR feedback. Treat escaped mutants as test gaps, not bugs — the fix is almost always a sharper test, occasionally a code fix when the mutant reveals an actual edge case.
PHP-CS-Fixer Deprecated Rule Set Aliases
Verify against your installed version. Upstream has shifted on
these naming schemes more than once. As of PHP-CS-Fixer 3.95.1 (May
2026) the @PHP8x*Migration experiment is withdrawn — thenon-hyphenated@PHP80Migration/@PHP82Migrationetc. forms are
the current, non-deprecated names.
>
Always confirm by running vendor/bin/php-cs-fixer list-sets againstyour locked version.
Historical / version-dependent renames
These were tagged deprecated in some 3.5x → 3.6x range and may resurface again. Always check list-sets output rather than relying on this table.
| Possibly deprecated alias | Possibly preferred form |
|---|---|
@PHPUnit100Migration:risky | @PHPUnit10x0Migration:risky |
@PER-CS1.0 | @PER-CS1x0 |
@PER-CS2.0 | @PER-CS2x0 |
Rules
| Deprecated | Replacement |
|---|---|
function_typehint_space | type_declaration_spaces |
Detection
Run PHP-CS-Fixer in dry-run mode and check for "Detected deprecations" in output:
vendor/bin/php-cs-fixer fix --dry-run 2>&1 | grep -A 20 "Detected deprecations"PER-CS Versions
@PER-CS (without version) is a dynamic alias that resolves to the latest non-deprecated PER-CS version. As of PHP-CS-Fixer 3.54.0, this resolves to PER-CS 2.0 (@PER-CS2x0).
Use @PER-CS for always-latest behavior, or pin to @PER-CS2x0 for stability.
PHP migration sets — current availability
PHP-CS-Fixer ships migration sets ahead of, but staggered behind, the PHP version itself. The non-:risky set follows release closely; the :risky variant lags. As of PHP-CS-Fixer 3.95.1 (May 2026, against PHP 8.5 GA):
| Set | Available | Notes |
|---|---|---|
@PHP80Migration … @PHP85Migration | ✓ | use the one matching your composer.json's PHP constraint |
@PHP80Migration:risky, @PHP82Migration:risky | ✓ | @PHP82Migration:risky is the latest extant `:risky` set — there is no @PHP83/84/85Migration:risky yet |
Bumping the migration set should track `composer.json`'s PHP constraint. A project on "php": "^8.5" should use @PHP85Migration. If your project is otherwise PHP-8.5-clean, bumping the set produces zero diff but sets the right gate.
If your installed PHP-CS-Fixer is older than 3.95 (e.g. 3.6x branch), the upper bound may be @PHP83Migration or @PHP84Migration — verify with list-sets:
# Inventory what migration sets are installed locally
vendor/bin/php-cs-fixer list-sets | grep -E "PHP[0-9]+Migration"Verifier checks
The verify scripts should detect:
- A
@PHPxxMigrationset lower than the project's PHP constraint
(e.g. composer.json has "php": "^8.5" but .php-cs-fixer.dist.php uses @PHP83Migration).
- A
@PHPxxMigrationset higher than the installed PHP-CS-Fixer
supports — the rule set won't resolve.
@PSR12/@PSR12:risky(deprecated by@PER-CS).- Any rule-set name that produces a "Detected deprecations" warning
on dry-run (run the detection command above).
PHPUnit Modernization (12 → 13)
PHPUnit 12.5 deprecated and 13 removed several long-standing idioms. The biggest blast-radius change for modernization passes is the strict mock-vs-stub distinction, which routinely produces 100s of "PHPUnit notices" and InvocationStubber::with() PHPStan errors when the distinction isn't honoured.
Mock vs Stub: the decision that matters
| Object | Created via | Has ->expects(...) | Has ->with(...) | Verifies args |
|---|---|---|---|---|
| Stub | createStub(X) | ✗ | ✗ | ✗ — only returns values |
| Mock | createMock(X) | ✓ | ✓ (after expects) | ✓ |
| Mock-as-stub | createMock(X) | ✗ (just method) | ✗ in 13 | — was deprecated path |
PHPUnit 12.5 deprecated and 13 removed the "mock-as-stub" pattern ($mock->method('x')->with($arg)->willReturn(...) without expects). The chain receiver is now InvocationStubber, which has no with().
Decision tree for migration
Does the test care what argument the call receives?
├─ NO → it's a stub
│ $stub = self::createStub(X::class);
│ $stub->method('foo')->willReturn($value);
│
└─ YES → it's a mock; require expectation
$mock = self::createMock(X::class);
$mock->expects(self::once()) // see "matcher choice" below
->method('foo')
->with($expectedArg) // ← THIS is the assertion
->willReturn($value);createMock/createStub are static methods on TestCase; PHPStan flags $this->createStub(...) as staticMethod.dynamicCall. Use self:: consistently (or migrate the whole file at once if your repo convention is $this->).
Common antipatterns
❌ Mechanical createMock → createStub
The single biggest mistake when chasing the "no expectations were configured" notice (1698-occurrence-class). Substituting createStub deletes argument verification if ->with(...) was present. Always inspect: if ->with(...) is in the chain, it was a mock — promote it to expects() form, don't strip it.
❌ self::any() in PHPUnit 13
PHPUnit\Framework\TestCase::any() is hard-deprecated (phpunit#6461) and PHPStan flags every call as method.deprecated. Pick the matcher that matches the test's actual call semantics — don't blanket-replace with once(), that adds a strict assertion the original test didn't make and will break paths that legitimately invoke the method 0 or 2+ times.
| Original intent | Use |
|---|---|
| "Method must be called exactly once" | self::once() |
"Method may be called any number of times, including 0" (true any()) | self::atLeast(0) (== effectively any but accepted by PHPStan) — or rethink whether this is a stub, not a mock |
| "Method must be called at least once" | self::atLeastOnce() |
| "Method must NOT be called" | self::never() |
| "Method must be called exactly N times" | self::exactly(N) |
For a mass-conversion of legacy ->method('x')->with(...) chains without expects() — these were treated as a stub-style call by PHPUnit pre-12, and the framework didn't verify count at all. atLeastOnce() is usually the safest faithful translation; once() is correct only when you've verified the test path invokes the method exactly once.
❌ expectNotToPerformAssertions() with mock expectations
public function testEarlyReturn(): void
{
$this->expectNotToPerformAssertions();
$mock->expects(self::never())->method('save'); // ← THIS counts
$service->process(invalidInput: true);
}expects(self::never()) IS an assertion. PHPUnit 12+ flags this as risky: "expectsNoAssertions but performed N assertions". Drop the expectNotToPerformAssertions() — the mock expectation is the proof.
❌ Leaving ->with(...) on stub-style chains
// Deprecated in 12.5, hard-removed in 13
$stub->method('foo')->with('x')->willReturn($v);Either promote to mock (expects() form) or drop with(). Don't leave it on a stub chain.
When to use #[AllowMockObjectsWithoutExpectations]
Apply at class level when:
- The same fixture (e.g.
protected ServiceX $serviceinsetUp) is
used both as a stub on some tests and a mock on others, and
- Refactoring
setUpto per-test creation would yield no semantic gain.
Don't apply as a default escape hatch. The notice exists to push genuinely-stub use of createMock over to createStub.
Risky test patterns
| Risky message | Fix |
|---|---|
Test code did not remove its own exception handlers | A parent (often Symfony KernelTestCase) registered one; add tearDown that calls restore_exception_handler() until the count balances. |
This test did not perform any assertions | Add the assertion the test name implies, or use expectNotToPerformAssertions() (only if no mock expectations either). |
expectsNoAssertions but performed N | See antipattern above — drop the expects-no-assertions call. |
PHPStan stub fingerprint
phpstan/phpstan-phpunit ships a stub for MockObject that's keyed to the installed PHPUnit major. After bumping PHPUnit (or after a rebase that pulled in such a bump), run:
composer install # sync vendor
vendor/bin/phpstan clear-result-cache # invalidate analysis cache
vendor/bin/phpstan analyse # re-run from cold cacheWithout the cache flush, local PHPStan can keep reporting clean while CI fails — the stale tmpDir holds analyses keyed to the old stub.
Mass-conversion playbook
When a project has 100+ deprecated ->with() sites:
1. Inventory: grep -rn "->method(.*->with" tests/ --include="*.php" gives the candidate sites. Don't trust line counts; one site may span multiple lines. 2. Read the production caller for each method. The arg passed at the call site is what with(...) should assert. Don't guess. 3. Promote, don't strip: insert ->expects(self::once()) before ->method(...). Keep ->with(...) exactly as it was. 4. Verify cold: clear PHPStan tmpDir and re-run, OR run analyze inside Docker if CI uses Docker — local-only verify can lie. 5. Run the affected tests to confirm once() matches the actual call count on the test path.
Verification commands
# Notice count (the agent should drive this to 0)
vendor/bin/phpunit --display-all-issues 2>&1 | grep -E "PHPUnit notice|tests triggered"
# Risky count
vendor/bin/phpunit 2>&1 | grep -E "Risky:|There (was|were) [0-9]+ risky"
# Cold PHPStan
vendor/bin/phpstan clear-result-cache
vendor/bin/phpstan analyse --no-progress#!/usr/bin/env bash
# Backward-compatible wrapper. Delegates to verify_php_project.py (default)
# or introspect.py (when first arg is "introspect").
#
# Usage:
# verify-php-project.sh [project-dir] # full verifier
# verify-php-project.sh introspect [project-dir] # cheap introspection
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Subcommand dispatch — first argument decides target script.
SUBCOMMAND="verify"
if [[ $# -gt 0 && "$1" == "introspect" ]]; then
SUBCOMMAND="introspect"
shift
fi
PROJECT_DIR="${1:-.}"
case "$SUBCOMMAND" in
introspect)
TARGET="$SCRIPT_DIR/introspect.py"
ARGS=(--root "$PROJECT_DIR" --format json)
;;
verify)
TARGET="$SCRIPT_DIR/verify_php_project.py"
ARGS=(--root "$PROJECT_DIR" --format json)
;;
esac
if ! command -v uv >/dev/null 2>&1; then
echo "ERROR: 'uv' is required to run this tool (install: https://docs.astral.sh/uv/)" >&2
echo " Falls back to direct python3 invocation if available..." >&2
if command -v python3 >/dev/null 2>&1; then
exec python3 "$TARGET" "${ARGS[@]}"
fi
exit 1
fi
exec uv run "$TARGET" "${ARGS[@]}"
{
"cs:fix": "vendor/bin/php-cs-fixer fix",
"cs:check": "vendor/bin/php-cs-fixer fix --dry-run --diff",
"phpstan": "vendor/bin/phpstan analyse --no-progress",
"rector": "vendor/bin/rector process",
"rector:check": "vendor/bin/rector process --dry-run",
"phpat": "vendor/bin/phpstan analyse",
"audit": "composer audit --locked",
"skill:inspect": "uv run skills/php-modernization/scripts/introspect.py --root .",
"skill:verify": "uv run skills/php-modernization/scripts/verify_php_project.py --root . --format json",
"skill:fix": ["@cs:fix", "@rector"],
"skill:qa": ["@skill:inspect", "@cs:check", "@phpstan", "@skill:verify"]
}