PHP Type Safety
70 custom static analysis rules enforcing platform standards automatically at build time across a million-line codebase.
Type safety in a large PHP monolith (~1M+ lines) is a long-term commitment. I’ve driven a multi-year effort to make the codebase more honest through static analysis and automation.
Approach
- Tooling: Upgraded and , integrated into CI so every contribution gets checked
- Automation: 70 custom rules (Psalm checkers, PHPStan rules, PHPCS sniffs, Rector refactorings), each one encoding a standard that previously existed only in code review comments
- Documentation: Wrote the platform’s PHP coding guidelines and unit testing documentation
Selected rules
Here are a few of the more interesting ones. Each rule catches a pattern that’s easy to get wrong and hard to spot in review.
Exception chain enforcement
Re-throwing a new exception without passing the original as previous loses the stack trace. This rule catches it at build time and suggests the fix.
// ❌ Fails CI, original exception is losttry { $response = $gateway->chargeCard($token, $amount);} catch (HttpClientException $e) { throw new PaymentFailedException('Charge failed');}
// ✅ Passes, stack trace preservedtry { $response = $gateway->chargeCard($token, $amount);} catch (HttpClientException $e) { throw new PaymentFailedException('Charge failed', previous: $e);}Logger context enforcement
If you catch an exception and log it, you need to actually pass the exception object in the logger context, not just the message string. The rule also catches cases where the exception variable isn’t even captured.
// ❌ Exception object not in logger contexttry { $service->process($payload);} catch (ProcessingException $e) { Logger::error('Processing failed');}
// ❌ Exception not assigned to a variabletry { $service->process($payload);} catch (ProcessingException) { Logger::error('Processing failed');}
// ✅ Exception passed in context arraytry { $service->process($payload);} catch (ProcessingException $e) { Logger::error('Processing failed for payload {id}', [ 'exception' => $e, ]);}DTO immutability
All must be annotated @psalm-immutable to prevent accidental mutation after construction. The plugin also auto-fixes: if the annotation is missing, it adds it.
// ❌ Mutable DTO, Psalm will flag thisclass UserResponseDTO { public function __construct( public string $name, public string $email, ) {}}
// ✅ Immutable, cannot be modified after construction/** @psalm-immutable */class UserResponseDTO { public function __construct( public readonly string $name, public readonly string $email, ) {}}Mockery trait enforcement
Test classes that use Mockery must include the MockeryPHPUnitIntegration trait. Without it, Mockery’s expectation verification doesn’t run, so mocks silently pass even when expectations aren’t met. This is a common footgun that’s hard to notice because tests still pass.
// ❌ Mockery used without the integration trait, expectations silently ignoredclass UserServiceTest extends TestCase { public function testCreatesUser(): void { $repo = Mockery::mock(UserRepository::class); $repo->shouldReceive('save')->once(); // If save() is never called, test still passes! }}
// ✅ Trait ensures Mockery expectations are actually verifiedclass UserServiceTest extends TestCase { use MockeryPHPUnitIntegration;
public function testCreatesUser(): void { $repo = Mockery::mock(UserRepository::class); $repo->shouldReceive('save')->once(); // Now if save() isn't called, test correctly fails }}Transport type boundaries
DTOs must not contain transport-layer types (e.g. protocol buffer or RPC types) in their properties. Transport types exist for serialisation only, so every field is nullable by design. Mixing them into domain objects erodes type safety.
// ❌ RPC transport type leaked into domain DTOclass OrderDTO { public function __construct( public int $orderId, public RpcOrderStatus $status, // every field nullable by design ) {}}
// ✅ Map to a domain type at the service boundaryclass OrderDTO { public function __construct( public int $orderId, public OrderStatus $status, // domain enum, non-nullable ) {}}Related writing
- Adding static analysis to a million-line PHP codebase: the rollout strategy: two zones, two rulesets, and why the baseline trap is worse than doing nothing
- Abstractions Outlive Authors: custom rules encode standards that outlive the author