Back to projects
work

PHP Type Safety

36 custom static analysis plugins 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 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
    ) {}
}