PHP Modern Idioms
When to Use
Use this skill when:
- The user is writing or reviewing PHP 8.0+ code and wants to apply idiomatic patterns -- named arguments, match expressions, nullsafe operators, fibers, enums, readonly properties, intersection types, first-class callables, and constructor property promotion
- The user is migrating a PHP 7.x codebase to PHP 8.x and needs to know which legacy patterns to replace and how to replace them systematically
- The user asks how to eliminate verbose boilerplate in PHP classes (e.g., getters/setters, constructor assignments, array-based pseudo-enums) using modern language features
- The user wants to improve type safety in PHP without overcomplicating the codebase -- covering union types, intersection types, never return types, and strict_types declarations
- The user is building or refactoring a PHP library or application and wants production-grade patterns for error handling, value objects, data transfer objects, and domain modeling
- The user asks about PHP coding standards and which tools (PHPStan, Psalm, PHP-CS-Fixer, Rector) to configure for enforcing modern idioms automatically
- The user wants to write expressive, readable PHP that leverages functional-style patterns -- array functions, immutability, pipelines -- without reaching for external FP libraries unnecessarily
Do NOT use this skill when:
- The user needs help with PHP framework internals (Laravel, Symfony, Laminas) -- those have dedicated framework-specific skills
- The user is asking about PHP performance profiling or Swoole/FrankenPHP async architecture -- use the PHP runtime performance skill
- The user wants guidance on PHP database access patterns (Doctrine ORM, PDO, query builders) -- use the PHP persistence skill
- The user is asking about PHP deployment, containerization, or PHP-FPM tuning -- use the PHP infrastructure skill
- The user needs general object-oriented design patterns not specific to PHP -- use the OOP design patterns skill
- The user is working on PHP 5.x or 7.3 and below code that cannot be upgraded -- PHP 8.x features do not apply and recommending them would cause errors
- The user is asking about PHP security hardening (input validation, SQL injection, CSP headers) -- use the PHP security skill
Process
1. Establish the PHP Version and Strict Mode Baseline
Before recommending any specific idiom, confirm the PHP version because feature availability is version-gated.
- Check the declared PHP version in
composer.json under "require": { "php": "^8.x" } -- this is the authoritative source
- If the version is below 8.0, use Rector to automate the upgrade path with the
SetList::PHP_80, SetList::PHP_81, SetList::PHP_82 rulesets before applying idioms manually
- Every PHP file in a modern codebase should begin with
declare(strict_types=1); -- this converts implicit type coercions into TypeError exceptions, surfacing bugs that silent coercion would hide
- In
php.ini or per-pool FPM config, set error_reporting = E_ALL and display_errors = Off (log instead) to ensure no warnings are silently swallowed
- Run
php -v and php --ini to confirm the active PHP binary matches the project's requirement -- version mismatches between CLI and FPM are a common source of confusion
- If using Composer, add a platform config:
"config": { "platform": { "php": "8.2.0" } } to prevent installing packages incompatible with your runtime
2. Apply Constructor Property Promotion and Readonly Properties
Constructor property promotion and readonly properties are the single highest-ROI modernization for most PHP codebases.
- Replace the classic pattern of declaring properties, assigning them in
__construct, and providing getters with promoted properties:// Before (PHP 7.x)
class UserDto {
public string $name;
public string $email;
public function __construct(string $name, string $email) {
$this->name = $name;
$this->email = $email;
}
}
// After (PHP 8.0+)
class UserDto {
public function __construct(
public readonly string $name,
public readonly string $email,
) {}
}
- Use
readonly on promoted properties whenever the value should not change after construction -- this enforces immutability at the language level, not by convention
- PHP 8.2 introduced readonly classes -- annotate the entire class with
readonly when every property should be immutable, avoiding per-property annotation:readonly class Money {
public function __construct(
public int $amountInCents,
public string $currency,
) {}
}
- For value objects that need a modified copy, implement
with() methods that return a new instance rather than mutating state:public function withCurrency(string $currency): static {
return new static($this->amountInCents, $currency);
}
- Avoid using
public visibility on mutable properties -- prefer private or protected with explicit mutation methods or use readonly to enforce immutability
- Trailing commas in parameter lists (PHP 8.0+) should be used consistently to produce clean diffs when adding parameters later
3. Replace Array-Based Pseudo-Enums with Backed Enums
PHP 8.1 native enums eliminate the most common PHP anti-pattern: constants arrays used to simulate enumerated types.
- Use a
string-backed enum when values are stored in a database or serialized to JSON -- the backing type appears in the enum declaration:enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
case Pending = 'pending';
}
- Use an
int-backed enum when the values map to integer codes in a legacy system or API
- Use a pure (unit) enum when no serialization is needed and the identity of the case is sufficient
- Enums can implement interfaces, which is critical for type-safe service dispatch:
interface HasLabel {
public function label(): string;
}
enum Status: string implements HasLabel {
case Active = 'active';
public function label(): string {
return match($this) {
Status::Active => 'Active User',
Status::Inactive => 'Deactivated',
Status::Pending => 'Awaiting Approval',
};
}
}
- Use
Status::from('active') for strict parsing (throws ValueError on invalid input) and Status::tryFrom('unknown') when the input may be untrusted and a null return is acceptable
- Enum cases can serve as default parameter values, array keys, and match expression subjects -- take full advantage of this
- Do NOT add
const arrays or class constants that duplicate what an enum already expresses -- delete them when migrating
4. Use Match Expressions and Nullsafe Operators Instead of Verbose Control Flow
The match expression and nullsafe operator ?-> eliminate entire categories of defensive boilerplate.
- Replace
switch statements with match expressions -- match is an expression (returns a value), uses strict comparison (===), and throws \UnhandledMatchError for unmatched subjects, forcing exhaustive handling:// Before
switch ($status) {
case 'active': $label = 'Active'; break;
case 'pending': $label = 'Pending'; break;
default: $label = 'Unknown';
}
// After
$label = match($status) {
'active' => 'Active',
'pending' => 'Pending',
default => 'Unknown',
};
- Multiple conditions can share an arm:
'active', 'verified' => 'Confirmed'
- For deeply nested nullable chains, replace nested
isset + null checks with the nullsafe operator:// Before
$city = null;
if ($user !== null && $user->getAddress() !== null) {
$city = $user->getAddress()->getCity();
}
// After
$city = $user?->getAddress()?->getCity();
- The nullsafe operator short-circuits the entire chain on the first null -- do NOT chain it through side-effectful methods, only through pure accessors
- Combine nullsafe with the null coalescing operator for defaults:
$city = $user?->getAddress()?->getCity() ?? 'Unknown'
- Avoid nesting
match expressions more than two levels deep -- extract to a named method when the logic grows complex
5. Leverage Union Types, Intersection Types, and the never Return Type
PHP 8.0+ type system features eliminate docblock-only type hints and make types machine-verifiable.
- Use union types when a parameter or return value legitimately accepts multiple types --
int|string is a real type, not a comment:function findById(int|string $id): User|null {}
- Prefer
?Type (nullable shorthand) over Type|null for single-nullable types -- they are equivalent but ?User is more idiomatic
- PHP 8.1 intersection types (
TypeA&TypeB) are used when a value must satisfy multiple interfaces simultaneously -- common in service layer contracts:function process(Countable&Iterator $collection): void {}
- The
never return type declares that a function never returns normally (always throws or calls exit) -- use it on exception factory methods and abort helpers:function fail(string $message): never {
throw new \RuntimeException($message);
}
- PHP 8.2
true, false, and null as standalone return types let you express exact return semantics: function isEnabled(): true communicates that the function unconditionally returns true
- Use PHPStan at level 8 or Psalm at level 1 to enforce that all type annotations are correct and that no
mixed types are hiding real type errors -- add these as CI gates, not optional checks
6. Apply Named Arguments and First-Class Callables
Named arguments and first-class callable syntax reduce coupling to parameter order and eliminate verbose closures.
- Named arguments are essential when calling functions with many optional parameters -- they communicate intent at the call site:
// Before
array_slice($items, 0, 5, true);
// After
array_slice(array: $items, offset: 0, length: 5, preserve_keys: true);
- Named arguments make refactoring safer -- if the callee adds a new parameter with a default, existing named-argument call sites remain valid without changes
- First-class callable syntax (
Closure::fromCallable replacement) allows passing any callable as a closure without wrapping it in an anonymous function:// Before
$trimmed = array_map(fn($s) => trim($s), $strings);
// After
$trimmed = array_map(trim(...), $strings);
- First-class callables work on static methods, instance methods, and built-in functions:
strlen(...), $obj->method(...), ClassName::staticMethod(...)
- Do NOT use named arguments when the parameter name is unstable (e.g., a third-party function where the name is not part of the public API) -- parameter name changes are breaking changes
7. Structure Error Handling with Typed Exceptions and Result Patterns
Modern PHP moves away from returning false or null on failure and toward typed exceptions and explicit result types.
- Create a hierarchy of domain exceptions rather than throwing generic
\Exception:App\Exception\DomainException (base)
App\Exception\User\UserNotFoundException
App\Exception\User\UserAlreadyExistsException
App\Exception\Payment\InsufficientFundsException
- Catch exceptions at the boundary where you can meaningfully handle them -- not deep inside domain logic
- Use
finally for cleanup operations (closing resources, releasing locks) regardless of whether an exception occurred
- For operations that can fail without being exceptional (e.g., parsing user input), consider a simple Result value object instead of exception-driven flow:
readonly class Result {
private function __construct(
private readonly mixed $value,
private readonly ?string $error,
) {}
public static function ok(mixed $value): static {
return new static($value, null);
}
public static function fail(string $error): static {
return new static(null, $error);
}
public function isOk(): bool { return $this->error === null; }
public function unwrap(): mixed { return $this->value; }
public function error(): ?string { return $this->error; }
}
- Exceptions should be exceptional -- IO failures, constraint violations, programming errors are exceptions; "no results found" is not
- Always include context in exception messages:
"User with ID {$id} not found in repository" is actionable; "Not found" is not
8. Enforce Idioms with Automated Tooling (PHPStan, Psalm, Rector, PHP-CS-Fixer)
Idioms that are not automatically enforced degrade over time. Tooling makes modern PHP mandatory, not aspirational.
- PHPStan: Start at level 5, move to level 8 over 2--4 sprints as violations are resolved. Use the
phpstan/phpstan-strict-rules extension for additional opinionated checks. Configure treatPhpDocTypesAsCertain: false to prevent false negatives
- Psalm: An alternative to PHPStan with stronger taint analysis. Use
errorLevel="1" (strictest) for new projects. Psalm's @psalm-immutable annotation integrates with the readonly workflow
- Rector: Automate PHP 8.x upgrades and idiom migrations. Create a
rector.php config with SetList::PHP_82, SetList::CODE_QUALITY, SetList::DEAD_CODE, and SetList::EARLY_RETURN rule sets. Run Rector on CI in dry-run mode to detect regressions
- PHP-CS-Fixer: Use the
@PHP82Migration and @PSR12 rulesets. Add declare_strict_types, modernize_types_casting, no_unused_imports, ordered_imports fixers
- Configure pre-commit hooks (using
captainhook/captainhook or brainmaestro/composer-git-hooks) to run PHP-CS-Fixer and PHPStan before every commit
- Add a
Makefile or composer.json scripts section with lint, analyse, fix, and test targets so every developer runs the same commands
- Track static analysis violations in CI as a quality gate -- a PR that introduces new PHPStan errors at the configured level should fail the pipeline
Output Format
When advising a user on PHP modern idioms, structure the response as follows:
## PHP Modern Idioms Audit
### PHP Version & Strict Mode Status
- Detected PHP Version: [version from composer.json]
- strict_types declared: [yes/no, and in how many files if no]
- Recommended target: PHP [recommended version based on context]
### Current Code Pattern Analysis
| Pattern (Legacy) | Modern Replacement | PHP Version | Impact |
|-------------------------------|----------------------------|-------------|----------|
| Constructor assignment boilerplate | Constructor promotion | 8.0+ | High |
| switch statements | match expressions | 8.0+ | Medium |
| Nested null checks (isset) | Nullsafe operator (?->) | 8.0+ | High |
| Class constant pseudo-enums | Backed enums | 8.1+ | High |
| Mutable DTO classes | readonly properties/classes | 8.1/8.2+ | High |
| Closure wrapping callables | First-class callables | 8.1+ | Low |
| Union types in docblocks only | Native union types | 8.0+ | Medium |
### Recommended Migration Priority (Ordered by ROI)
1. [Highest priority modernization with rationale]
2. [Second priority with rationale]
3. ...
### Implementation
#### [Pattern Name]
**Before:**
```php
[concrete legacy code snippet]
After:
[concrete modern PHP code snippet]
Rationale: [Why this is better -- type safety, reduced boilerplate, tooling support, etc.]
Tooling Configuration
PHPStan (phpstan.neon):
[minimal working config]
Rector (rector.php):
[minimal working config]
PHP-CS-Fixer (.php-cs-fixer.php):
[minimal working config]
Trade-offs and Risks
| Decision |
Benefit |
Risk |
Mitigation |
| [specific decision] |
[concrete benefit] |
[real risk] |
[specific mitigation] |
---
## Rules
1. **NEVER recommend PHP 8.x features without confirming the runtime supports them.** PHP 8.1 enums throw a parse error on PHP 8.0. PHP 8.2 readonly classes throw a parse error on PHP 8.1. Always check `composer.json` `"require"` and the actual runtime version first.
2. **ALWAYS add `declare(strict_types=1)` to every new file.** Without it, PHP silently coerces `"123abc"` to `123` in an `int` parameter, hiding data integrity bugs. This is non-negotiable in modern PHP.
3. **NEVER use `mixed` as a return type or parameter type unless interfacing with a genuinely untyped external system.** `mixed` disables static analysis for that code path. Prefer union types, generics via docblocks (`@template T`), or template types recognized by PHPStan/Psalm.
4. **NEVER use `array` as a type hint when the shape of the array is known.** Prefer typed value objects, DTOs with constructor promotion, or at minimum a PHPStan/Psalm array shape annotation `array{name: string, age: int}` for complex arrays that cannot yet be migrated to objects.
5. **ALWAYS use `match` over `switch` for new code.** `match` uses strict comparison, is an expression, and throws `\UnhandledMatchError` for unmatched subjects -- all of which catch bugs that `switch` silently ignores with its fall-through behavior and loose comparison.
6. **NEVER add `readonly` to a property that must be mutated after construction.** This forces workarounds using reflection (which defeats the purpose). Design the immutability boundary before applying `readonly`.
7. **ALWAYS use `Status::from()` instead of casting or comparing raw strings to enum values.** `from()` throws `ValueError` on invalid input immediately, surfacing bad data at the boundary rather than propagating corrupted state.
8. **NEVER make PHPStan or Psalm optional in CI.** Static analysis must be a hard gate. Teams that run it only locally tolerate `mixed` proliferation and nullable bugs. A PHPStan level 6+ failure should block a PR merge.
9. **NEVER chain the nullsafe operator (`?->`) through methods that have side effects.** If any method in the chain writes to a database, sends an email, or modifies state, a silent short-circuit can leave the system in an inconsistent state. Reserve `?->` for pure accessor chains.
10. **ALWAYS use Rector in CI dry-run mode to detect newly introduced legacy patterns.** Rector with the `SetList::CODE_QUALITY` ruleset will catch new instances of legacy patterns (array-based enums, manual constructor assignments, superfluous docblocks) before they accumulate into technical debt.
---
## Edge Cases
### Legacy Codebase with No `strict_types` in Existing Files
Adding `declare(strict_types=1)` to existing files will break any code that relied on silent type coercion -- `$obj->setAge("42")` now throws `TypeError`. Do not add it globally in a single commit. Use Rector's `DeclareStrictTypesRector` with a scope limited to files that have passing tests. Add it file by file as tests verify each file's behavior. Budget 1--2 hours per 1000 lines of code for this migration.
### Enums in Doctrine Entities (Database Layer)
Doctrine ORM supports backed enums as column types natively since Doctrine DBAL 3.2 and ORM 2.13. Use the enum backing type as the Doctrine column type: `#[Column(type: 'string', enumType: Status::class)]`. Be aware that if an invalid value exists in the database (from before the enum was introduced), Doctrine will throw a `ValueError` on hydration -- sanitize the database before enabling this mapping.
### Readonly Properties and Serialization (JSON, Serialize)
`readonly` properties work with `json_encode` transparently. However, `unserialize()` and many ORMs that use reflection-based hydration will fail to set readonly properties after construction because readonly prevents assignment after the constructor has run. Use a named constructor (static factory) or a custom `__set_state()` method. For API platform or Symfony serializer, configure the denormalization to use the constructor (object_to_populate not supported with readonly).
### Named Arguments in Variadic Functions
Named arguments cannot be combined with a preceding variadic argument: `function f(string ...$names)` does not allow `f(first: 'Alice')` -- the variadic consumes positional arguments. Additionally, named arguments break when a library function renames its parameters between versions (e.g., `str_contains` renamed in a patch release is theoretical but real in extensions). Only use named arguments for functions whose parameter names are part of the stable public API.
### Intersection Types and Nullable Combinations
PHP does not support nullable intersection types: `?Countable&Iterator` is a syntax error. Use a DNF (Disjunctive Normal Form) type in PHP 8.2 instead: `(Countable&Iterator)|null`. If the PHP version is below 8.2, extract the intersection into a named interface that both `Countable` and `Iterator` extend -- `interface CountableIterator extends Countable, Iterator {}` -- and use `?CountableIterator`.
### match with No default and Partial Enums
When using `match` against an enum without a `default` arm, `\UnhandledMatchError` is thrown if a new enum case is added later and the match is not updated. This is usually desirable -- it surfaces the omission immediately. However, in some contexts (feature flags, plugin systems) you want to handle new cases gracefully. In those cases, add an explicit `default => null` or `default => throw new UnsupportedCaseException(...)` so the failure mode is explicit and descriptive rather than an uncaught `UnhandledMatchError`.
### Fibers (PHP 8.1) and Their Appropriate Use
PHP Fibers are cooperative concurrency primitives, not threads. They do NOT make synchronous I/O concurrent -- a blocking `file_get_contents()` inside a fiber still blocks the entire process. Fibers are appropriate when building async frameworks (ReactPHP, Revolt event loop) where the scheduler drives fiber suspension. In application code, never introduce Fibers directly -- use them only through an async framework's abstraction layer. Recommending Fibers to a user writing standard Laravel or Symfony controllers is incorrect and misleading.
---
## Example
**Input:** "I inherited a PHP 7.4 codebase with a `UserService` class that uses class constants for user status, manual constructor assignment, isset-chained null checks, and switch statements for generating status labels. We're deploying on PHP 8.2. How do I modernize this?"
**Output:**
---
## PHP Modern Idioms Audit
### PHP Version & Strict Mode Status
- Inherited PHP version: 7.4
- Target runtime: PHP 8.2
- `strict_types` declared: Likely absent (typical of PHP 7.4 era code)
- Recommended target: PHP 8.2 with full strict types and PHPStan level 6+
---
### Current Code Pattern Analysis
Here is the legacy code as it typically looks in a PHP 7.4-era `UserService`:
```php
<?php
class User
{
const STATUS_ACTIVE = 'active';
const STATUS_INACTIVE = 'inactive';
const STATUS_PENDING = 'pending';
/** @var int */
private $id;
/** @var string */
private $email;
/** @var string */
private $status;
/** @var Address|null */
private $address;
public function __construct(int $id, string $email, string $status, ?Address $address)
{
$this->id = $id;
$this->email = $email;
$this->status = $status;
$this->address = $address;
}
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function getStatus(): string { return $this->status; }
public function getAddress(): ?Address { return $this->address; }
}
class UserService
{
public function getStatusLabel(User $user): string
{
switch ($user->getStatus()) {
case User::STATUS_ACTIVE:
return 'Active User';
case User::STATUS_INACTIVE:
return 'Deactivated';
case User::STATUS_PENDING:
return 'Awaiting Approval';
default:
return 'Unknown';
}
}
public function getUserCity(User $user): ?string
{
if ($user->getAddress() !== null) {
$address = $user->getAddress();
if (isset($address)) {
return $address->getCity();
}
}
return null;
}
public function setUserActive(User $user): void
{
// Mutation directly on the object
$user->status = User::STATUS_ACTIVE; // bypasses accessor, common in legacy code
}
}
Modernized PHP 8.2 Code
Step 1 -- Introduce a backed enum to replace class constants:
<?php
declare(strict_types=1);
namespace App\Enum;
enum UserStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Pending = 'pending';
public function label(): string
{
return match($this) {
UserStatus::Active => 'Active User',
UserStatus::Inactive => 'Deactivated',
UserStatus::Pending => 'Awaiting Approval',
};
}
}
Rationale: The match expression inside label() is exhaustive -- if a new case (Banned, Suspended) is added to the enum without updating label(), PHP throws \UnhandledMatchError at runtime, immediately surfacing the omission. With the old switch/constants pattern, a missing case fell through to default: return 'Unknown' -- silently returning wrong data.
Step 2 -- Rewrite the User class with constructor promotion, readonly, and the enum:
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\UserStatus;
readonly class User
{
public function __construct(
public int $id,
public string $email,
public UserStatus $status,
public ?Address $address = null,
) {}
public function withStatus(UserStatus $status): static
{
return new static($this->id, $this->email, $status, $this->address);
}
}
Rationale: The readonly class declaration eliminates 8 lines of boilerplate (4 property declarations, 4 constructor assignments). All getters are replaced by public readonly properties -- they can be read anywhere but cannot be mutated outside the constructor. The withStatus() method provides a safe mutation path that returns a new immutable instance, making state transitions traceable and testable.
Step 3 -- Rewrite UserService with the nullsafe operator and updated method signatures:
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\UserStatus;
final class UserService
{
public function getStatusLabel(User $user): string
{
return $user->status->label();
}
public function getUserCity(User $user): ?string
{
return $user->address?->getCity();
}
public function activateUser(User $user): User
{
return $user->withStatus(UserStatus::Active);
}
}
Rationale: getStatusLabel() is now a one-liner that delegates to the enum's own method -- the service no longer needs to know about status string values at all. getUserCity() collapses four lines of nested null checking into a single nullsafe chain. activateUser() now returns a new User instance instead of mutating the original, which makes the service stateless and the transition auditable.
Tooling Configuration
PHPStan (phpstan.neon):
parameters:
level: 6
paths:
- src
strictRules: true
checkMissingIterableValueType: true
treatPhpDocTypesAsCertain: false
Rector (rector.php):
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->paths([__DIR__ . '/src']);
$rectorConfig->sets([
SetList::PHP_82,
SetList::CODE_QUALITY,
SetList::DEAD_CODE,
SetList::EARLY_RETURN,
]);
};
PHP-CS-Fixer (.php-cs-fixer.php):
<?php
$finder = PhpCsFixer\Finder::create()->in(__DIR__ . '/src');
return (new PhpCsFixer\Config())
->setRules([
'@PHP82Migration' => true,
'@PSR12' => true,
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'trailing_comma_in_multiline' => ['elements' => ['arguments', 'arrays', 'parameters']],
])
->setFinder($finder);
Migration Priority and Risk Matrix
| Modernization |
ROI |
Breaking Risk |
Migration Effort |
Add declare(strict_types=1) |
Very High |
Medium -- type coercions become TypeErrors |
File by file with test coverage |
| Replace constants with enums |
High |
Low -- Rector handles most cases |
1--2 hours per enum cluster |
| Constructor promotion + readonly |
High |
Low -- pure refactor, same API surface |
Rector automates this fully |
| Replace switch with match |
Medium |
Low -- behavior identical if default present |
Rector ChangeOrIfContinueToMultiContinueRector |
| Nullsafe operator chains |
Medium |
Low -- pure refactor |
Manual, ~30 min per class |
| PHPStan at level 6 |
Very High |
None -- analysis only |
4--8 hours to resolve initial violations |
Recommended order: Run Rector with SetList::PHP_82 first to apply all mechanical transformations automatically. Then add PHPStan at level 5 and fix violations. Then manually refactor to enums and readonly classes where Rector did not fully cover the pattern. The entire migration for a 10,000-line codebase should take 2--4 working days, not weeks.
1---2name: php-modern-idioms3description: Guides expert-level php modern idioms implementation: php and best-practices decision frameworks, production-ready patterns, and concrete templates for php modern idioms workflows. Use when the user asks about php modern idioms, php modern idioms configuration, or php best practices for php projects. Do NOT use when the user needs a different languages runtimes capability -- check sibling skills in the languages runtimes subcategory.4license: Apache-2.05---6# PHP Modern Idioms78## When to Use910**Use this skill when:**11- The user is writing or reviewing PHP 8.0+ code and wants to apply idiomatic patterns -- named arguments, match expressions, nullsafe operators, fibers, enums, readonly properties, intersection types, first-class callables, and constructor property promotion12- The user is migrating a PHP 7.x codebase to PHP 8.x and needs to know which legacy patterns to replace and how to replace them systematically13- The user asks how to eliminate verbose boilerplate in PHP classes (e.g., getters/setters, constructor assignments, array-based pseudo-enums) using modern language features14- The user wants to improve type safety in PHP without overcomplicating the codebase -- covering union types, intersection types, never return types, and strict_types declarations15- The user is building or refactoring a PHP library or application and wants production-grade patterns for error handling, value objects, data transfer objects, and domain modeling16- The user asks about PHP coding standards and which tools (PHPStan, Psalm, PHP-CS-Fixer, Rector) to configure for enforcing modern idioms automatically17- The user wants to write expressive, readable PHP that leverages functional-style patterns -- array functions, immutability, pipelines -- without reaching for external FP libraries unnecessarily1819**Do NOT use this skill when:**20- The user needs help with PHP framework internals (Laravel, Symfony, Laminas) -- those have dedicated framework-specific skills21- The user is asking about PHP performance profiling or Swoole/FrankenPHP async architecture -- use the PHP runtime performance skill22- The user wants guidance on PHP database access patterns (Doctrine ORM, PDO, query builders) -- use the PHP persistence skill23- The user is asking about PHP deployment, containerization, or PHP-FPM tuning -- use the PHP infrastructure skill24- The user needs general object-oriented design patterns not specific to PHP -- use the OOP design patterns skill25- The user is working on PHP 5.x or 7.3 and below code that cannot be upgraded -- PHP 8.x features do not apply and recommending them would cause errors26- The user is asking about PHP security hardening (input validation, SQL injection, CSP headers) -- use the PHP security skill2728---2930## Process3132### 1. Establish the PHP Version and Strict Mode Baseline3334Before recommending any specific idiom, confirm the PHP version because feature availability is version-gated.3536- Check the declared PHP version in `composer.json` under `"require": { "php": "^8.x" }` -- this is the authoritative source37- If the version is below 8.0, use Rector to automate the upgrade path with the `SetList::PHP_80`, `SetList::PHP_81`, `SetList::PHP_82` rulesets before applying idioms manually38- Every PHP file in a modern codebase should begin with `declare(strict_types=1);` -- this converts implicit type coercions into `TypeError` exceptions, surfacing bugs that silent coercion would hide39- In `php.ini` or per-pool FPM config, set `error_reporting = E_ALL` and `display_errors = Off` (log instead) to ensure no warnings are silently swallowed40- Run `php -v` and `php --ini` to confirm the active PHP binary matches the project's requirement -- version mismatches between CLI and FPM are a common source of confusion41- If using Composer, add a platform config: `"config": { "platform": { "php": "8.2.0" } }` to prevent installing packages incompatible with your runtime4243---4445### 2. Apply Constructor Property Promotion and Readonly Properties4647Constructor property promotion and readonly properties are the single highest-ROI modernization for most PHP codebases.4849- Replace the classic pattern of declaring properties, assigning them in `__construct`, and providing getters with promoted properties:50 ```php51 // Before (PHP 7.x)52 class UserDto {53 public string $name;54 public string $email;55 public function __construct(string $name, string $email) {56 $this->name = $name;57 $this->email = $email;58 }59 }60 61 // After (PHP 8.0+)62 class UserDto {63 public function __construct(64 public readonly string $name,65 public readonly string $email,66 ) {}67 }68 ```69- Use `readonly` on promoted properties whenever the value should not change after construction -- this enforces immutability at the language level, not by convention70- PHP 8.2 introduced readonly classes -- annotate the entire class with `readonly` when every property should be immutable, avoiding per-property annotation:71 ```php72 readonly class Money {73 public function __construct(74 public int $amountInCents,75 public string $currency,76 ) {}77 }78 ```79- For value objects that need a modified copy, implement `with()` methods that return a new instance rather than mutating state:80 ```php81 public function withCurrency(string $currency): static {82 return new static($this->amountInCents, $currency);83 }84 ```85- Avoid using `public` visibility on mutable properties -- prefer `private` or `protected` with explicit mutation methods or use readonly to enforce immutability86- Trailing commas in parameter lists (PHP 8.0+) should be used consistently to produce clean diffs when adding parameters later8788---8990### 3. Replace Array-Based Pseudo-Enums with Backed Enums9192PHP 8.1 native enums eliminate the most common PHP anti-pattern: constants arrays used to simulate enumerated types.9394- Use a `string`-backed enum when values are stored in a database or serialized to JSON -- the backing type appears in the enum declaration:95 ```php96 enum Status: string {97 case Active = 'active';98 case Inactive = 'inactive';99 case Pending = 'pending';100 }101 ```102- Use an `int`-backed enum when the values map to integer codes in a legacy system or API103- Use a pure (unit) enum when no serialization is needed and the identity of the case is sufficient104- Enums can implement interfaces, which is critical for type-safe service dispatch:105 ```php106 interface HasLabel {107 public function label(): string;108 }109 enum Status: string implements HasLabel {110 case Active = 'active';111 public function label(): string {112 return match($this) {113 Status::Active => 'Active User',114 Status::Inactive => 'Deactivated',115 Status::Pending => 'Awaiting Approval',116 };117 }118 }119 ```120- Use `Status::from('active')` for strict parsing (throws `ValueError` on invalid input) and `Status::tryFrom('unknown')` when the input may be untrusted and a null return is acceptable121- Enum cases can serve as default parameter values, array keys, and match expression subjects -- take full advantage of this122- Do NOT add `const` arrays or class constants that duplicate what an enum already expresses -- delete them when migrating123124---125126### 4. Use Match Expressions and Nullsafe Operators Instead of Verbose Control Flow127128The `match` expression and nullsafe operator `?->` eliminate entire categories of defensive boilerplate.129130- Replace `switch` statements with `match` expressions -- `match` is an expression (returns a value), uses strict comparison (`===`), and throws `\UnhandledMatchError` for unmatched subjects, forcing exhaustive handling:131 ```php132 // Before133 switch ($status) {134 case 'active': $label = 'Active'; break;135 case 'pending': $label = 'Pending'; break;136 default: $label = 'Unknown';137 }138 139 // After140 $label = match($status) {141 'active' => 'Active',142 'pending' => 'Pending',143 default => 'Unknown',144 };145 ```146- Multiple conditions can share an arm: `'active', 'verified' => 'Confirmed'`147- For deeply nested nullable chains, replace nested `isset` + null checks with the nullsafe operator:148 ```php149 // Before150 $city = null;151 if ($user !== null && $user->getAddress() !== null) {152 $city = $user->getAddress()->getCity();153 }154 155 // After156 $city = $user?->getAddress()?->getCity();157 ```158- The nullsafe operator short-circuits the entire chain on the first null -- do NOT chain it through side-effectful methods, only through pure accessors159- Combine nullsafe with the null coalescing operator for defaults: `$city = $user?->getAddress()?->getCity() ?? 'Unknown'`160- Avoid nesting `match` expressions more than two levels deep -- extract to a named method when the logic grows complex161162---163164### 5. Leverage Union Types, Intersection Types, and the never Return Type165166PHP 8.0+ type system features eliminate docblock-only type hints and make types machine-verifiable.167168- Use union types when a parameter or return value legitimately accepts multiple types -- `int|string` is a real type, not a comment:169 ```php170 function findById(int|string $id): User|null {}171 ```172- Prefer `?Type` (nullable shorthand) over `Type|null` for single-nullable types -- they are equivalent but `?User` is more idiomatic173- PHP 8.1 intersection types (`TypeA&TypeB`) are used when a value must satisfy multiple interfaces simultaneously -- common in service layer contracts:174 ```php175 function process(Countable&Iterator $collection): void {}176 ```177- The `never` return type declares that a function never returns normally (always throws or calls `exit`) -- use it on exception factory methods and abort helpers:178 ```php179 function fail(string $message): never {180 throw new \RuntimeException($message);181 }182 ```183- PHP 8.2 `true`, `false`, and `null` as standalone return types let you express exact return semantics: `function isEnabled(): true` communicates that the function unconditionally returns `true`184- Use PHPStan at level 8 or Psalm at level 1 to enforce that all type annotations are correct and that no `mixed` types are hiding real type errors -- add these as CI gates, not optional checks185186---187188### 6. Apply Named Arguments and First-Class Callables189190Named arguments and first-class callable syntax reduce coupling to parameter order and eliminate verbose closures.191192- Named arguments are essential when calling functions with many optional parameters -- they communicate intent at the call site:193 ```php194 // Before195 array_slice($items, 0, 5, true);196 197 // After198 array_slice(array: $items, offset: 0, length: 5, preserve_keys: true);199 ```200- Named arguments make refactoring safer -- if the callee adds a new parameter with a default, existing named-argument call sites remain valid without changes201- First-class callable syntax (`Closure::fromCallable` replacement) allows passing any callable as a closure without wrapping it in an anonymous function:202 ```php203 // Before204 $trimmed = array_map(fn($s) => trim($s), $strings);205 206 // After207 $trimmed = array_map(trim(...), $strings);208 ```209- First-class callables work on static methods, instance methods, and built-in functions: `strlen(...)`, `$obj->method(...)`, `ClassName::staticMethod(...)`210- Do NOT use named arguments when the parameter name is unstable (e.g., a third-party function where the name is not part of the public API) -- parameter name changes are breaking changes211212---213214### 7. Structure Error Handling with Typed Exceptions and Result Patterns215216Modern PHP moves away from returning `false` or `null` on failure and toward typed exceptions and explicit result types.217218- Create a hierarchy of domain exceptions rather than throwing generic `\Exception`:219 ```220 App\Exception\DomainException (base)221 App\Exception\User\UserNotFoundException222 App\Exception\User\UserAlreadyExistsException223 App\Exception\Payment\InsufficientFundsException224 ```225- Catch exceptions at the boundary where you can meaningfully handle them -- not deep inside domain logic226- Use `finally` for cleanup operations (closing resources, releasing locks) regardless of whether an exception occurred227- For operations that can fail without being exceptional (e.g., parsing user input), consider a simple Result value object instead of exception-driven flow:228 ```php229 readonly class Result {230 private function __construct(231 private readonly mixed $value,232 private readonly ?string $error,233 ) {}234 235 public static function ok(mixed $value): static {236 return new static($value, null);237 }238 239 public static function fail(string $error): static {240 return new static(null, $error);241 }242 243 public function isOk(): bool { return $this->error === null; }244 public function unwrap(): mixed { return $this->value; }245 public function error(): ?string { return $this->error; }246 }247 ```248- Exceptions should be exceptional -- IO failures, constraint violations, programming errors are exceptions; "no results found" is not249- Always include context in exception messages: `"User with ID {$id} not found in repository"` is actionable; `"Not found"` is not250251---252253### 8. Enforce Idioms with Automated Tooling (PHPStan, Psalm, Rector, PHP-CS-Fixer)254255Idioms that are not automatically enforced degrade over time. Tooling makes modern PHP mandatory, not aspirational.256257- **PHPStan**: Start at level 5, move to level 8 over 2--4 sprints as violations are resolved. Use the `phpstan/phpstan-strict-rules` extension for additional opinionated checks. Configure `treatPhpDocTypesAsCertain: false` to prevent false negatives258- **Psalm**: An alternative to PHPStan with stronger taint analysis. Use `errorLevel="1"` (strictest) for new projects. Psalm's `@psalm-immutable` annotation integrates with the readonly workflow259- **Rector**: Automate PHP 8.x upgrades and idiom migrations. Create a `rector.php` config with `SetList::PHP_82`, `SetList::CODE_QUALITY`, `SetList::DEAD_CODE`, and `SetList::EARLY_RETURN` rule sets. Run Rector on CI in dry-run mode to detect regressions260- **PHP-CS-Fixer**: Use the `@PHP82Migration` and `@PSR12` rulesets. Add `declare_strict_types`, `modernize_types_casting`, `no_unused_imports`, `ordered_imports` fixers261- Configure pre-commit hooks (using `captainhook/captainhook` or `brainmaestro/composer-git-hooks`) to run PHP-CS-Fixer and PHPStan before every commit262- Add a `Makefile` or `composer.json` scripts section with `lint`, `analyse`, `fix`, and `test` targets so every developer runs the same commands263- Track static analysis violations in CI as a quality gate -- a PR that introduces new PHPStan errors at the configured level should fail the pipeline264265---266267## Output Format268269When advising a user on PHP modern idioms, structure the response as follows:270271```272## PHP Modern Idioms Audit273274### PHP Version & Strict Mode Status275- Detected PHP Version: [version from composer.json]276- strict_types declared: [yes/no, and in how many files if no]277- Recommended target: PHP [recommended version based on context]278279### Current Code Pattern Analysis280281| Pattern (Legacy) | Modern Replacement | PHP Version | Impact |282|-------------------------------|----------------------------|-------------|----------|283| Constructor assignment boilerplate | Constructor promotion | 8.0+ | High |284| switch statements | match expressions | 8.0+ | Medium |285| Nested null checks (isset) | Nullsafe operator (?->) | 8.0+ | High |286| Class constant pseudo-enums | Backed enums | 8.1+ | High |287| Mutable DTO classes | readonly properties/classes | 8.1/8.2+ | High |288| Closure wrapping callables | First-class callables | 8.1+ | Low |289| Union types in docblocks only | Native union types | 8.0+ | Medium |290291### Recommended Migration Priority (Ordered by ROI)2922931. [Highest priority modernization with rationale]2942. [Second priority with rationale]2953. ...296297### Implementation298299#### [Pattern Name]300301**Before:**302```php303[concrete legacy code snippet]304```305306**After:**307```php308[concrete modern PHP code snippet]309```310311**Rationale:** [Why this is better -- type safety, reduced boilerplate, tooling support, etc.]312313### Tooling Configuration314315**PHPStan (`phpstan.neon`):**316```yaml317[minimal working config]318```319320**Rector (`rector.php`):**321```php322[minimal working config]323```324325**PHP-CS-Fixer (`.php-cs-fixer.php`):**326```php327[minimal working config]328```329330### Trade-offs and Risks331332| Decision | Benefit | Risk | Mitigation |333|----------|---------|------|------------|334| [specific decision] | [concrete benefit] | [real risk] | [specific mitigation] |335```336337---338339## Rules3403411. **NEVER recommend PHP 8.x features without confirming the runtime supports them.** PHP 8.1 enums throw a parse error on PHP 8.0. PHP 8.2 readonly classes throw a parse error on PHP 8.1. Always check `composer.json` `"require"` and the actual runtime version first.3423432. **ALWAYS add `declare(strict_types=1)` to every new file.** Without it, PHP silently coerces `"123abc"` to `123` in an `int` parameter, hiding data integrity bugs. This is non-negotiable in modern PHP.3443453. **NEVER use `mixed` as a return type or parameter type unless interfacing with a genuinely untyped external system.** `mixed` disables static analysis for that code path. Prefer union types, generics via docblocks (`@template T`), or template types recognized by PHPStan/Psalm.3463474. **NEVER use `array` as a type hint when the shape of the array is known.** Prefer typed value objects, DTOs with constructor promotion, or at minimum a PHPStan/Psalm array shape annotation `array{name: string, age: int}` for complex arrays that cannot yet be migrated to objects.3483495. **ALWAYS use `match` over `switch` for new code.** `match` uses strict comparison, is an expression, and throws `\UnhandledMatchError` for unmatched subjects -- all of which catch bugs that `switch` silently ignores with its fall-through behavior and loose comparison.3503516. **NEVER add `readonly` to a property that must be mutated after construction.** This forces workarounds using reflection (which defeats the purpose). Design the immutability boundary before applying `readonly`.3523537. **ALWAYS use `Status::from()` instead of casting or comparing raw strings to enum values.** `from()` throws `ValueError` on invalid input immediately, surfacing bad data at the boundary rather than propagating corrupted state.3543558. **NEVER make PHPStan or Psalm optional in CI.** Static analysis must be a hard gate. Teams that run it only locally tolerate `mixed` proliferation and nullable bugs. A PHPStan level 6+ failure should block a PR merge.3563579. **NEVER chain the nullsafe operator (`?->`) through methods that have side effects.** If any method in the chain writes to a database, sends an email, or modifies state, a silent short-circuit can leave the system in an inconsistent state. Reserve `?->` for pure accessor chains.35835910. **ALWAYS use Rector in CI dry-run mode to detect newly introduced legacy patterns.** Rector with the `SetList::CODE_QUALITY` ruleset will catch new instances of legacy patterns (array-based enums, manual constructor assignments, superfluous docblocks) before they accumulate into technical debt.360361---362363## Edge Cases364365### Legacy Codebase with No `strict_types` in Existing Files366367Adding `declare(strict_types=1)` to existing files will break any code that relied on silent type coercion -- `$obj->setAge("42")` now throws `TypeError`. Do not add it globally in a single commit. Use Rector's `DeclareStrictTypesRector` with a scope limited to files that have passing tests. Add it file by file as tests verify each file's behavior. Budget 1--2 hours per 1000 lines of code for this migration.368369### Enums in Doctrine Entities (Database Layer)370371Doctrine ORM supports backed enums as column types natively since Doctrine DBAL 3.2 and ORM 2.13. Use the enum backing type as the Doctrine column type: `#[Column(type: 'string', enumType: Status::class)]`. Be aware that if an invalid value exists in the database (from before the enum was introduced), Doctrine will throw a `ValueError` on hydration -- sanitize the database before enabling this mapping.372373### Readonly Properties and Serialization (JSON, Serialize)374375`readonly` properties work with `json_encode` transparently. However, `unserialize()` and many ORMs that use reflection-based hydration will fail to set readonly properties after construction because readonly prevents assignment after the constructor has run. Use a named constructor (static factory) or a custom `__set_state()` method. For API platform or Symfony serializer, configure the denormalization to use the constructor (object_to_populate not supported with readonly).376377### Named Arguments in Variadic Functions378379Named arguments cannot be combined with a preceding variadic argument: `function f(string ...$names)` does not allow `f(first: 'Alice')` -- the variadic consumes positional arguments. Additionally, named arguments break when a library function renames its parameters between versions (e.g., `str_contains` renamed in a patch release is theoretical but real in extensions). Only use named arguments for functions whose parameter names are part of the stable public API.380381### Intersection Types and Nullable Combinations382383PHP does not support nullable intersection types: `?Countable&Iterator` is a syntax error. Use a DNF (Disjunctive Normal Form) type in PHP 8.2 instead: `(Countable&Iterator)|null`. If the PHP version is below 8.2, extract the intersection into a named interface that both `Countable` and `Iterator` extend -- `interface CountableIterator extends Countable, Iterator {}` -- and use `?CountableIterator`.384385### match with No default and Partial Enums386387When using `match` against an enum without a `default` arm, `\UnhandledMatchError` is thrown if a new enum case is added later and the match is not updated. This is usually desirable -- it surfaces the omission immediately. However, in some contexts (feature flags, plugin systems) you want to handle new cases gracefully. In those cases, add an explicit `default => null` or `default => throw new UnsupportedCaseException(...)` so the failure mode is explicit and descriptive rather than an uncaught `UnhandledMatchError`.388389### Fibers (PHP 8.1) and Their Appropriate Use390391PHP Fibers are cooperative concurrency primitives, not threads. They do NOT make synchronous I/O concurrent -- a blocking `file_get_contents()` inside a fiber still blocks the entire process. Fibers are appropriate when building async frameworks (ReactPHP, Revolt event loop) where the scheduler drives fiber suspension. In application code, never introduce Fibers directly -- use them only through an async framework's abstraction layer. Recommending Fibers to a user writing standard Laravel or Symfony controllers is incorrect and misleading.392393---394395## Example396397**Input:** "I inherited a PHP 7.4 codebase with a `UserService` class that uses class constants for user status, manual constructor assignment, isset-chained null checks, and switch statements for generating status labels. We're deploying on PHP 8.2. How do I modernize this?"398399**Output:**400401---402403## PHP Modern Idioms Audit404405### PHP Version & Strict Mode Status406- Inherited PHP version: 7.4407- Target runtime: PHP 8.2408- `strict_types` declared: Likely absent (typical of PHP 7.4 era code)409- Recommended target: PHP 8.2 with full strict types and PHPStan level 6+410411---412413### Current Code Pattern Analysis414415Here is the legacy code as it typically looks in a PHP 7.4-era `UserService`:416417```php418<?php419420class User421{422 const STATUS_ACTIVE = 'active';423 const STATUS_INACTIVE = 'inactive';424 const STATUS_PENDING = 'pending';425426 /** @var int */427 private $id;428429 /** @var string */430 private $email;431432 /** @var string */433 private $status;434435 /** @var Address|null */436 private $address;437438 public function __construct(int $id, string $email, string $status, ?Address $address)439 {440 $this->id = $id;441 $this->email = $email;442 $this->status = $status;443 $this->address = $address;444 }445446 public function getId(): int { return $this->id; }447 public function getEmail(): string { return $this->email; }448 public function getStatus(): string { return $this->status; }449 public function getAddress(): ?Address { return $this->address; }450}451452class UserService453{454 public function getStatusLabel(User $user): string455 {456 switch ($user->getStatus()) {457 case User::STATUS_ACTIVE:458 return 'Active User';459 case User::STATUS_INACTIVE:460 return 'Deactivated';461 case User::STATUS_PENDING:462 return 'Awaiting Approval';463 default:464 return 'Unknown';465 }466 }467468 public function getUserCity(User $user): ?string469 {470 if ($user->getAddress() !== null) {471 $address = $user->getAddress();472 if (isset($address)) {473 return $address->getCity();474 }475 }476 return null;477 }478479 public function setUserActive(User $user): void480 {481 // Mutation directly on the object482 $user->status = User::STATUS_ACTIVE; // bypasses accessor, common in legacy code483 }484}485```486487---488489### Modernized PHP 8.2 Code490491**Step 1 -- Introduce a backed enum to replace class constants:**492493```php494<?php495496declare(strict_types=1);497498namespace App\Enum;499500enum UserStatus: string501{502 case Active = 'active';503 case Inactive = 'inactive';504 case Pending = 'pending';505506 public function label(): string507 {508 return match($this) {509 UserStatus::Active => 'Active User',510 UserStatus::Inactive => 'Deactivated',511 UserStatus::Pending => 'Awaiting Approval',512 };513 }514}515```516517**Rationale:** The `match` expression inside `label()` is exhaustive -- if a new case (`Banned`, `Suspended`) is added to the enum without updating `label()`, PHP throws `\UnhandledMatchError` at runtime, immediately surfacing the omission. With the old `switch`/constants pattern, a missing case fell through to `default: return 'Unknown'` -- silently returning wrong data.518519---520521**Step 2 -- Rewrite the `User` class with constructor promotion, readonly, and the enum:**522523```php524<?php525526declare(strict_types=1);527528namespace App\Entity;529530use App\Enum\UserStatus;531532readonly class User533{534 public function __construct(535 public int $id,536 public string $email,537 public UserStatus $status,538 public ?Address $address = null,539 ) {}540541 public function withStatus(UserStatus $status): static542 {543 return new static($this->id, $this->email, $status, $this->address);544 }545}546```547548**Rationale:** The `readonly` class declaration eliminates 8 lines of boilerplate (4 property declarations, 4 constructor assignments). All getters are replaced by public readonly properties -- they can be read anywhere but cannot be mutated outside the constructor. The `withStatus()` method provides a safe mutation path that returns a new immutable instance, making state transitions traceable and testable.549550---551552**Step 3 -- Rewrite `UserService` with the nullsafe operator and updated method signatures:**553554```php555<?php556557declare(strict_types=1);558559namespace App\Service;560561use App\Entity\User;562use App\Enum\UserStatus;563564final class UserService565{566 public function getStatusLabel(User $user): string567 {568 return $user->status->label();569 }570571 public function getUserCity(User $user): ?string572 {573 return $user->address?->getCity();574 }575576 public function activateUser(User $user): User577 {578 return $user->withStatus(UserStatus::Active);579 }580}581```582583**Rationale:** `getStatusLabel()` is now a one-liner that delegates to the enum's own method -- the service no longer needs to know about status string values at all. `getUserCity()` collapses four lines of nested null checking into a single nullsafe chain. `activateUser()` now returns a new `User` instance instead of mutating the original, which makes the service stateless and the transition auditable.584585---586587### Tooling Configuration588589**PHPStan (`phpstan.neon`):**590```yaml591parameters:592 level: 6593 paths:594 - src595 strictRules: true596 checkMissingIterableValueType: true597 treatPhpDocTypesAsCertain: false598```599600**Rector (`rector.php`):**601```php602<?php603604declare(strict_types=1);605606use Rector\Config\RectorConfig;607use Rector\Set\ValueObject\SetList;608609return static function (RectorConfig $rectorConfig): void {610 $rectorConfig->paths([__DIR__ . '/src']);611612 $rectorConfig->sets([613 SetList::PHP_82,614 SetList::CODE_QUALITY,615 SetList::DEAD_CODE,616 SetList::EARLY_RETURN,617 ]);618};619```620621**PHP-CS-Fixer (`.php-cs-fixer.php`):**622```php623<?php624625$finder = PhpCsFixer\Finder::create()->in(__DIR__ . '/src');626627return (new PhpCsFixer\Config())628 ->setRules([629 '@PHP82Migration' => true,630 '@PSR12' => true,631 'declare_strict_types' => true,632 'no_unused_imports' => true,633 'ordered_imports' => ['sort_algorithm' => 'alpha'],634 'trailing_comma_in_multiline' => ['elements' => ['arguments', 'arrays', 'parameters']],635 ])636 ->setFinder($finder);637```638639---640641### Migration Priority and Risk Matrix642643| Modernization | ROI | Breaking Risk | Migration Effort |644|---|---|---|---|645| Add `declare(strict_types=1)` | Very High | Medium -- type coercions become TypeErrors | File by file with test coverage |646| Replace constants with enums | High | Low -- Rector handles most cases | 1--2 hours per enum cluster |647| Constructor promotion + readonly | High | Low -- pure refactor, same API surface | Rector automates this fully |648| Replace switch with match | Medium | Low -- behavior identical if default present | Rector `ChangeOrIfContinueToMultiContinueRector` |649| Nullsafe operator chains | Medium | Low -- pure refactor | Manual, ~30 min per class |650| PHPStan at level 6 | Very High | None -- analysis only | 4--8 hours to resolve initial violations |651652**Recommended order:** Run Rector with `SetList::PHP_82` first to apply all mechanical transformations automatically. Then add PHPStan at level 5 and fix violations. Then manually refactor to enums and readonly classes where Rector did not fully cover the pattern. The entire migration for a 10,000-line codebase should take 2--4 working days, not weeks.