php
artykuł··zaktualizowano ·7 min czytania

Maszyna stanów w PHP: jawny cykl życia zamówienia na enumach, blokadach i zdarzeniach

#php #laravel #design-patterns #state-machine #fsm #order-management

Zamówienie, subskrypcja i wniosek kredytowy mają cykl życia, więc każde z nich da się opisać jako maszynę stanów. W większości projektów ta maszyna jest niejawna. To po prostu kolumna status i warunki if w serwisach, które ją zmieniają. Poniżej pokazuję, jak zrobić ją jawną w PHP 8.3 i Laravelu, jak bezpiecznie wykonywać przejścia przy współbieżności i gdzie trzymać efekty uboczne.

Wersja niejawna

class OrderService
{
    public function markPaid(Order $order): void
    {
        if ($order->status !== 'payment_pending') {
            throw new \LogicException("Cannot mark order {$order->id} as paid");
        }
        // ...
    }

    public function cancel(Order $order): void
    {
        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {
            throw new \LogicException("Cannot cancel order {$order->id}");
        }
        // ...
    }
}

Każda metoda sprawdza to, co sama uważa za zabronione. Pełnej listy dozwolonych przejść nigdzie nie ma. Jest tylko suma warunków ze wszystkich metod. Kiedy więc pojawia się nowe miejsce zmieniające status, czy to kontroler, komenda importu, czy panel admina, nie ma się czego trzymać, dopóki autor nie znajdzie i nie skopiuje właściwego warunku. Na dodatek cancel() opiera się na liście zakazów. Status dodany później da się domyślnie anulować, a biznes raczej by tego nie chciał.

Przejścia w jednym miejscu

W enumie z wartościami zmieszczą się i stany, i graf przejść:

enum OrderStatus: string
{
    case Draft = 'draft';
    case PaymentPending = 'payment_pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
    case Refunded = 'refunded';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft => [self::PaymentPending, self::Cancelled],
            self::PaymentPending => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Refunded],
            self::Shipped => [self::Delivered],
            self::Delivered => [self::Refunded],
            self::Cancelled, self::Refunded => [],
        };
    }

    public function canTransitionTo(self $to): bool
    {
        return in_array($to, $this->allowedTransitions(), true);
    }

    public function isFinal(): bool
    {
        return $this->allowedTransitions() === [];
    }
}

Teraz reguły to lista dozwolonych przejść. Nowy case bez własnej gałęzi w match rzuci UnhandledMatchError przy pierwszym wywołaniu allowedTransitions() dla tego stanu, a PHPStan (od poziomu 4) albo Psalm wyłapią niepełny match jeszcze wcześniej.

W modelu ustaw rzutowanie kolumny na enum (protected $casts = ['status' => OrderStatus::class];). Wtedy reszta kodu nie porównuje już gołych stringów.

Wykonanie przejścia

Samo sprawdzenie to za mało. Dwa żądania mogą odczytać to samo zamówienie w payment_pending, oba przejdą canTransitionTo(Paid) i oba zapiszą zmianę. Dlatego przejście czyta i zapisuje wiersz pod blokadą:

final class OrderTransitions
{
    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order
    {
        return DB::transaction(function () use ($orderId, $to, $reason): Order {
            $order = Order::query()->lockForUpdate()->findOrFail($orderId);
            $from = $order->status;

            if (! $from->canTransitionTo($to)) {
                throw InvalidTransition::between($order->id, $from, $to);
            }

            $order->status = $to;
            $order->status_changed_at = now();
            $order->save();

            $order->statusHistory()->create([
                'from' => $from->value,
                'to' => $to->value,
                'reason' => $reason,
            ]);

            OrderStatusChanged::dispatch($order->id, $from, $to);

            return $order;
        });
    }
}

lockForUpdate() dokłada FOR UPDATE do zapytania SELECT. Druga transakcja, która chce ten sam wiersz, czeka na commit pierwszej, potem czyta nowy status i sprawdzenie odrzuca przejście. Tak działa to w MySQL i PostgreSQL przy domyślnym poziomie izolacji. SQLite blokadę ignoruje, a PostgreSQL w REPEATABLE READ zamiast nowego statusu zwróci drugiej transakcji błąd serializacji. W tabeli historii zostaje, co się zmieniło, kiedy i dlaczego (jeśli chcesz wiedzieć kto, dodaj id użytkownika).

Tabela historii powinna być tylko do dopisywania, a jej wiersze potrzebują własnych identyfikatorów. W CookieDomain, moim systemie zgód cookies, dziennik zgód był append-only, ale kontroler brał consent_id odesłany z cookie, czyli id poprzedniej zgody, jako klucz główny nowego wiersza. Każda zmiana zgody po pierwszej kończyła się więc błędem 1062 Duplicate entry. Poprawka: każdy wiersz dostaje nowy UUID, a id poprzedniej zgody trafia do prev_consent_id.

Jeśli nie chcesz trzymać blokady, warunkowy update też sprawi, że przejście wykona się tylko raz. Omija jednak apply(), więc nie zapisze historii i nie wyśle OrderStatusChanged, a do tego update() na zapytaniu pomija zdarzenia modelu:

$affected = Order::whereKey($orderId)
    ->where('status', OrderStatus::PaymentPending->value)
    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);

if ($affected === 0) {
    // nie ma takiego zamówienia albo inne żądanie zmieniło status wcześniej
}

Dobrze działa na gorących ścieżkach, na przykład w webhookach płatności. Wersję z blokadą łatwiej rozbudować, kiedy przejście musi też odczytać inne dane albo zapisać kilka wierszy.

Powtórzone wywołania z systemów zewnętrznych

Operatorzy płatności ponawiają webhooki, a dokumentacja Stripe mówi, że endpoint może czasem dostać to samo zdarzenie więcej niż raz. Dlatego to samo zdarzenie „płatność udana” potrafi przyjść dwa razy. Za drugim razem zamówienie jest już w paid. Z Paid do Paid przejścia nie ma. Przy takich wywołaniach „już jest w stanie docelowym” powinno oznaczać sukces:

if ($from === $to) {
    return $order; // idempotentne powtórzenie, nic do zrobienia
}

Ten warunek wstaw zaraz po $from = $order->status;, przed canTransitionTo(), w osobnym wariancie apply() dla wywołań idempotentnych (webhooki, ponawiane joby), na przykład applyIdempotent(). Ogólna metoda apply() zostaje ścisła: użytkownik, który klika „Anuluj” przy zamówieniu już anulowanym, powinien dostać komunikat o błędzie. Porównanie stanów nie wystarczy, gdy powtórzone zdarzenie przyjdzie, kiedy zamówienie jest już w shipped. Dlatego Stripe radzi zapisywać ID obsłużonych zdarzeń i pomijać te, które już są w bazie.

Dopóki każdy zapis statusu idzie przez apply() albo warunkowy update, zamówienie przejdzie do paid raz, ale operator nadal może obciążyć klienta dwa razy: gdy klient kliknie „Zapłać” dwukrotnie, każde z dwóch żądań może utworzyć płatność u operatora. To trzeba zatrzymać tam, gdzie płatność powstaje. Twórz ją wyłącznie w przejściu Draft → PaymentPending, pod tą samą blokadą (wiersz zostaje zablokowany przez całe wywołanie HTTP, więc daj mu krótki timeout), a drugie żądanie niech zastanie PaymentPending i zwróci istniejącą płatność zamiast tworzyć nową. Blokada obejmuje tylko twoją bazę, więc wywołanie operatora potrzebuje klucza idempotencji opartego o ID zamówienia (Stripe przyjmuje go w nagłówku Idempotency-Key): jeśli proces padnie po utworzeniu płatności u operatora, a przed commitem, ponowienie z tym samym kluczem i tymi samymi parametrami dostanie z powrotem tę samą płatność. Stripe może usunąć klucz po 24 godzinach, a przy innych parametrach (na przykład zmienionej kwocie) zwraca błąd.

Efekty uboczne idą do zdarzeń

Wysyłka maila, rezerwacja towaru albo powiadomienie analityki wewnątrz przejścia wiąże maszynę stanów z tymi systemami. Do tego nieudany mail nie może cofać statusu płatności, bo zamówienie jest opłacone niezależnie od tego, czy potwierdzenie wyszło.

Dlatego przejście tylko wysyła zdarzenie. Zdarzenie implementuje ShouldDispatchAfterCommit, więc listenery nie ruszą, jeśli transakcja zostanie wycofana, i nie zobaczą starego stanu wiersza, jeśli czytają przez inne połączenie:

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Events\Dispatchable;

final class OrderStatusChanged implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public readonly int $orderId,
        public readonly OrderStatus $from,
        public readonly OrderStatus $to,
    ) {}
}

final class SendPaymentConfirmation implements ShouldQueue
{
    public function handle(OrderStatusChanged $event): void
    {
        if ($event->to !== OrderStatus::Paid) {
            return;
        }

        $order = Order::findOrFail($event->orderId);
        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));
    }
}

Każdy listener w kolejce działa jako osobny job z własnymi próbami (Laravel wykonuje job raz, chyba że podniesiesz limit na listenerze albo opcją queue:work --tries). Przy jednej próbie job po awarii serwera pocztowego ląduje w failed_jobs i mail nie wyjdzie, dopóki ktoś nie zrobi queue:retry. Jeśli listener ma kilka prób i odstęp między nimi ($tries i $backoff), mail wyjdzie przy kolejnej próbie, jeśli serwer do tego czasu wróci, a status zamówienia w żadnym z tych przypadków się nie zmienia.

Testy, które sprawdzają graf

Testowanie każdej pary stanów względem kopii tej samej tabeli tylko powiela definicję. Więcej daje sprawdzanie własności grafu i tych reguł biznesowych, które są ważne:

public function test_every_status_is_reachable_from_draft(): void
{
    $seen = [OrderStatus::Draft];
    $queue = [OrderStatus::Draft];

    while ($queue !== []) {
        foreach (array_shift($queue)->allowedTransitions() as $next) {
            if (! in_array($next, $seen, true)) {
                $seen[] = $next;
                $queue[] = $next;
            }
        }
    }

    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);
    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));
}

public function test_shipped_order_cannot_be_cancelled(): void
{
    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));
}

Pierwszy test wyłapie status dodany do enuma, do którego nie prowadzi żadne przejście. Drugi pilnuje reguły biznesowej, więc zmiana tego fragmentu grafu wywali test.

Ograniczenia i alternatywy

Kilka niezależnych wymiarów, takich jak płatność, realizacja i fakturowanie, lepiej opisać kilkoma małymi maszynami niż jednym statusem ze wszystkimi kombinacjami. Cztery stany realizacji (nowe, wysłane, dostarczone, anulowane) razy cztery stany płatności (brak, oczekuje, opłacone, zwrócone) to już 16 kombinacji w jednym statusie. Flaga logiczna bez żadnych reguł, choćby is_archived, maszyny stanów w ogóle nie potrzebuje.

Ograniczenie CHECK w bazie zawęzi zbiór wartości, ale przejść nie upilnuje. Da się to zrobić triggerem, tylko że reguły biznesowe lądują wtedy w miejscu, którego większość zespołów PHP nie przegląda.

Są też gotowe komponenty: Symfony Workflow z typem state_machine i spatie/laravel-model-states dla Laravela. Symfony Workflow dokłada guardy, zdarzenia przejść i komendę do zrzutu grafu. Pakiet Spatie daje klasy stanów, własne klasy przejść i zdarzenia przejść. Enum z tego tekstu wystarcza, dopóki przejścia nie potrzebują własnych guardów ani logiki. Od tego miejsca te biblioteki zaczynają się opłacać.

koniec artykułu