levelmax World 1 Strict Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 1.6 ~12 min Target: PHPStan level 5

Enums

Class constants for statuses look tidy: PaymentStatus::CAPTURED instead of 'captured'. But the constant is still a string. A method that takes string $status will take 'captured', 'CAPTURED', 'capturd', or a customer's surname. The constants are a convention, not a type.

Enums are types

enum PaymentStatus: string
{
    case Authorised = 'authorised';
    case Captured = 'captured';
    case Refunded = 'refunded';
    case Failed = 'failed';
}

Now function refund(PaymentStatus $status) accepts exactly four values, and PHP rejects anything else. Cases are singletons, so === compares them safely.

A backed enum also has a scalar value for storage. That's the boundary:

  • PaymentStatus::from('captured') gives you PaymentStatus::Captured, and throws a ValueError for anything that isn't a case.
  • PaymentStatus::tryFrom('capturd') gives you null instead, for when bad input is expected.
  • $status->value is the string to store.

Convert once, when the row comes in, and the rest of the code never sees a status string again.

Rules live with the cases

Enums can have methods and implement interfaces, so the rules about each status can sit with the status rather than in an if in some controller:

public function canRefund(): bool
{
    return match ($this) {
        self::Captured => true,
        self::Authorised, self::Refunded, self::Failed => false,
    };
}

List every case and PHPStan checks the match is exhaustive. Add Disputed next year and every match that forgot it is reported, rather than throwing an UnhandledMatchError in production.

Power-up

Before
<?php

declare(strict_types=1);

final class PaymentStatus
{
    public const AUTHORISED = 'authorised';
    public const CAPTURED = 'captured';
    public const REFUNDED = 'refunded';
    public const FAILED = 'failed';
}

// Somewhere far away: if ($status === 'captured' || $status === 'Captured') { ...
After
<?php

declare(strict_types=1);

enum PaymentStatus: string
{
    case Authorised = 'authorised';
    case Captured = 'captured';
    case Refunded = 'refunded';
    case Failed = 'failed';

    public function canRefund(): bool
    {
        return match ($this) {
            self::Captured => true,
            self::Authorised, self::Refunded, self::Failed => false,
        };
    }
}

Quick check

1. PaymentStatus::from('capturd') is called on an enum with no such case. What happens?
2. Why avoid a default arm in a match over an enum?

CHALLENGE

Order statuses are strings with constants for company. Nothing stops 'shiped' reaching the database, and the rules about each status live wherever someone last needed them.

Order and Dispatch (read only) were written for an enum. Turn src/OrderStatus.php into a string-backed enum with:

  • the cases Pending, Paid, Shipped and Cancelled, backed by their lowercase names
  • label(), returning the name for people: "Pending", "Paid", and so on
  • canCancel(), true only for pending and paid orders
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.