Maszyny stanów w PHP: modelowanie cyklu życia zamówienia bez spaghetti
Raport błędu brzmiał: "Klient został obciążony dwa razy za to samo zamówienie." Zamówienie było w statusie payment_pending. Timeout frontendu sprawił, że klient kliknął "Zapłać" ponownie. Drugie kliknięcie wyzwoliło nowy payment intent. Oba intenty zakończyły się sukcesem w ciągu 200 milisekund od siebie. Ani frontend, ani backend nie miał mechanizmu zapobiegającego drugiej płatności na zamówieniu będącym w trakcie procesu pobierania należności.
Naprawa nie była muteksem. Była maszyną stanów. Przejście z payment_pending do paid może nastąpić tylko raz, a gdy już nastąpiło, przejście payment_pending → paid po prostu nie istnieje. Druga próba płatności nie miała dokąd trafić.
Implicytna maszyna stanów, którą już masz
Każda aplikacja z konceptami workflow (zamówienia, subskrypcje, tickety supportu, wnioski kredytowe) ma już maszynę stanów. Po prostu implicytną: kolumna status w bazie danych i instrukcje if rozrzucone po serwisach, które ją sprawdzają.
// The implicit version — found in most codebases
class OrderService
{
public function processPayment(Order $order, PaymentResult $result): void
{
if ($order->status !== 'payment_pending') {
throw new \LogicException("Cannot process payment for order in status: {$order->status}");
}
// ...
}
public function cancel(Order $order): void
{
if (in_array($order->status, ['shipped', 'delivered', 'refunded'])) {
throw new \LogicException("Cannot cancel order in status: {$order->status}");
}
// ...
}
}
To działa, dopóki nowy developer nie doda logiki cancel() w kontrolerze, nie zapomni sprawdzić statusu i zamówienie zostanie anulowane po tym, jak już zostało wysłane. Dozwolone przejścia nigdzie nie są explicite. Istnieją tylko jako suma wszystkich checków if w wszystkich metodach.
Uczynienie maszyny explicytną
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';
}
final class OrderStateMachine
{
// The complete allowed transition graph — one place, one truth
private const TRANSITIONS = [
OrderStatus::Draft->value => [
OrderStatus::PaymentPending,
OrderStatus::Cancelled,
],
OrderStatus::PaymentPending->value => [
OrderStatus::Paid,
OrderStatus::Cancelled,
],
OrderStatus::Paid->value => [
OrderStatus::Shipped,
OrderStatus::Refunded,
],
OrderStatus::Shipped->value => [OrderStatus::Delivered],
OrderStatus::Delivered->value => [OrderStatus::Refunded],
OrderStatus::Cancelled->value => [], // terminal
OrderStatus::Refunded->value => [], // terminal
];
public function transition(Order $order, OrderStatus $to): void
{
if (!in_array($to, self::TRANSITIONS[$order->status->value] ?? [], strict: true)) {
throw new InvalidTransitionException(
from: $order->status,
to: $to,
orderId: $order->id,
);
}
$previousStatus = $order->status;
$order->status = $to;
$order->status_changed_at = now();
// Dispatch transition event — side effects happen in listeners, not here
event(new OrderStatusTransitioned(order: $order, from: $previousStatus, to: $to));
}
}
TRANSITIONS to cała specyfikacja twojego workflow. Żeby dodać nowe przejście, dodajesz jeden wpis. Żeby zrozumieć które przejścia są możliwe z danego stanu, czytasz jedną tablicę. Żeby udowodnić, że cancelled → refunded jest niemożliwe, patrzysz na pustą tablicę.
Efekty uboczne należą do listenerów
Klasyczny błąd po przyjęciu explicytnych maszyn stanów to wkładanie efektów ubocznych do metody transition. Maszyna stanów jest teraz sprzężona z emailem, inventory i analytics, testowanie przejścia wymaga mockowania trzech zależności. Co ważniejsze: jeśli wysyłanie emaila się nie powiedzie, czy zamówienie pozostaje nieopłacone?
Zamiast tego emituj event i pozwól listenerom decydować:
// In OrderEventSubscriber
public function onOrderStatusTransitioned(OrderStatusTransitioned $event): void
{
if ($event->to !== OrderStatus::Paid) {
return;
}
// Each listener is independently retryable, independently testable
$this->emailQueue->dispatch(new SendPaymentConfirmationEmail($event->order->id));
$this->inventoryQueue->dispatch(new ReserveOrderItems($event->order->id));
}
Nieudany job email nie cofa statusu płatności. Zamówienie jest opłacone. Email spróbuje ponownie. To są osobne sprawy.
Bezpieczne persystowanie stanu
W systemie współbieżnym (a każda aplikacja webowa nim jest) dwa requesty mogą jednocześnie próbować dokonać przejścia tego samego zamówienia. Ochrona na poziomie bazy danych:
public function transitionWithLock(int $orderId, OrderStatus $to): void
{
DB::transaction(function () use ($orderId, $to) {
// FOR UPDATE locks the row for the duration of this transaction
$order = Order::where('id', $orderId)->lockForUpdate()->firstOrFail();
$this->stateMachine->transition($order, $to);
$order->save();
});
}
lockForUpdate() uniemożliwia drugiemu równoległemu requestowi odczyt zamówienia payment_pending dopóki pierwsza transakcja nie zostanie zatwierdzona. Drugi request odczyta wtedy zamówienie paid, nie znajdzie żadnego dozwolonego przejścia i rzuci InvalidTransitionException. Zero podwójnego obciążenia.
Na co zwracam uwagę w code review
Kolumna status bez odpowiadającej definicji maszyny stanów to zobowiązanie czekające na wykorzystanie. Moje pytanie: czy aplikacja może osiągnąć nieprawidłową kombinację stanów? Zamówienie z status = 'shipped' i bez adresu wysyłki powinno być niemożliwe. Zamówienie z status = 'refunded' i payment_status = 'pending' jest również niemożliwe, jeśli maszyna jest poprawnie zdefiniowana. Jeśli odpowiedź na "czy to może osiągnąć nieprawidłowy stan" brzmi "teoretycznie, jeśli dwie rzeczy zdarzą się w odpowiedniej kolejności", masz implicytną maszynę. Uczyń ją explicytną.