PHP Type Safety
36 custom static analysis plugins 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 PHPStan and Psalm, integrated into CI so every contribution gets checked
- Automation: 36 custom plugins (Psalm checkers, PHPStan rules, Rector refactorings) — each one encodes 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 lost
try {
$response = $gateway->chargeCard($token, $amount);
} catch (HttpClientException $e) {
throw new PaymentFailedException('Charge failed');
}
// ✅ Passes — stack trace preserved
try {
$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 context
try {
$service->process($payload);
} catch (ProcessingException $e) {
Logger::error('Processing failed');
}
// ❌ Exception not assigned to a variable
try {
$service->process($payload);
} catch (ProcessingException) {
Logger::error('Processing failed');
}
// ✅ Exception passed in context array
try {
$service->process($payload);
} catch (ProcessingException $e) {
Logger::error('Processing failed for payload {id}', [
'exception' => $e,
]);
}
DTO immutability
All data transfer objects 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 this
class 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 — 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 ignored
class 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 verified
class 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 — every field is nullable by design. Mixing them into domain objects erodes type safety.
// ❌ RPC transport type leaked into domain DTO
class OrderDTO {
public function __construct(
public int $orderId,
public RpcOrderStatus $status, // every field nullable by design
) {}
}
// ✅ Map to a domain type at the service boundary
class OrderDTO {
public function __construct(
public int $orderId,
public OrderStatus $status, // domain enum, non-nullable
) {}
}