Back to projects
work

PHP Type Safety

70 custom static analysis rules enforcing platform standards automatically at build time across a million-line codebase.

Psalm PHPStan Rector PHP

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 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 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, 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 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, so 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
) {}
}