$subscription->status === 'active' jest nieszkodliwe raz. Drogo robi się wtedy, gdy endpoint anulowania, job odnowienia, webhook płatności i narzędzie supportu muszą odpowiedzieć na trochę inne pytanie: czy to przejście jest teraz legalne? String nie jest już wtedy tylko daną. To rozproszona maszyna stanów, której reguły ukrywają się w kontrolerach i wyrażeniach match.
Wzorzec State daje każdemu znaczącemu etapowi cyklu życia mały obiekt, który posiada własne dozwolone zachowanie. Nie jest to argument za pakietem do maszyn stanów. Dla subskrypcji z czterema lub pięcioma stanami zwykłe PHP czyni graf przejść jawnym, łatwo się testuje i pozostawia Laravel na obrzeżach.
Zamodelujemy taki cykl:
trial ──start paid plan──> active ──payment fails──> past_due
│ │ │
└──────cancel──────────────┴────cancel───────────────┴──> cancelled
│
payment received
▼
active
Najważniejsze nie jest to, że każda subskrypcja ma status. Stan rozstrzyga, czy operacja ma sens. Anulowana subskrypcja nie powinna cicho wracać do active, bo stary webhook został ponowiony.
Najpierw zapisuj zamknięty słownik
Na granicy trwałości użyj enuma opartego na wartości. Chroni przed literówkami w statusach, daje migracjom i zapytaniom jeden słownik oraz pozwala resolverowi celowo zawieść, jeżeli stare dane są uszkodzone.
<?php
declare(strict_types=1);
namespace App\Subscriptions;
enum SubscriptionStatus: string
{
case Trial = 'trial';
case Active = 'active';
case PastDue = 'past_due';
case Cancelled = 'cancelled';
}
Model przechowuje fakty, a nie workflow. status, ends_at i cancelled_at pozostają zwykłymi kolumnami. Jedna użyteczna metoda modelu deleguje intencję do serwisu cyklu życia, dzięki czemu kontrolery HTTP, polecenia konsolowe i joby webhooków przechodzą przez te same drzwi.
<?php
declare(strict_types=1);
namespace App\Models;
use App\Subscriptions\SubscriptionLifecycle;
use App\Subscriptions\SubscriptionStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Subscription extends Model
{
protected $fillable = ['status', 'ends_at', 'cancelled_at'];
protected function casts(): array
{
return [
'status' => SubscriptionStatus::class,
'ends_at' => 'immutable_datetime',
'cancelled_at' => 'immutable_datetime',
];
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function cancel(SubscriptionLifecycle $lifecycle): void
{
$lifecycle->cancel($this);
}
}
Nie wkładaj do tego modelu switch i nie nazywaj go State. Model jest dobrym miejscem na relacje i casty. Rozgałęziony workflow lepiej rośnie w skupionych klasach, których nazwy odpowiadają językowi biznesowemu.
Nadaj operacjom prawdziwy kontrakt
Interfejs powinien opisywać przejścia, które aplikacja rzeczywiście wykonuje. Nie każdy stan obsługuje każde przejście; właśnie dlatego wyjątek domenowy jest przydatny. Zamienia przypadkowe ponowienie albo nieaktualną akcję z UI w widoczny błąd biznesowy zamiast zaskakującego zapisu.
<?php
declare(strict_types=1);
namespace App\Subscriptions;
use App\Models\Subscription;
interface SubscriptionState
{
public function status(): SubscriptionStatus;
public function cancel(Subscription $subscription): TransitionResult;
public function paymentFailed(Subscription $subscription): TransitionResult;
public function paymentReceived(Subscription $subscription): TransitionResult;
}
final readonly class TransitionResult
{
public function __construct(
public SubscriptionStatus $from,
public SubscriptionStatus $to,
) {
}
}
final class InvalidSubscriptionTransition extends \DomainException
{
public static function from(SubscriptionStatus $from, string $operation): self
{
return new self("Cannot {$operation} a subscription in {$from->value} state.");
}
}
Wynik jest mały, ale przydatny: daje akcji informacje do wyemitowania jednego ogólnego eventu bez wstrzykiwania zależności do dispatchowania do każdego stanu. Inna aplikacja może woleć eventy specyficzne dla przejść. Granica zostaje taka sama: klasy stanu decydują o polityce, infrastruktura publikuje konsekwencje.
Niech każdy stan będzie nudny i lokalny
Stan aktywny może anulować plan albo zareagować na nieudaną płatność. Nie tworzy faktur, nie wysyła maila i nie wywołuje Stripe. Są to efekty uruchamiane po udanym trwałym przejściu.
<?php
declare(strict_types=1);
namespace App\Subscriptions\States;
use App\Models\Subscription;
use App\Subscriptions\InvalidSubscriptionTransition;
use App\Subscriptions\SubscriptionState;
use App\Subscriptions\SubscriptionStatus;
use App\Subscriptions\TransitionResult;
final class ActiveSubscription implements SubscriptionState
{
public function status(): SubscriptionStatus
{
return SubscriptionStatus::Active;
}
public function cancel(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), SubscriptionStatus::Cancelled);
}
public function paymentFailed(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), SubscriptionStatus::PastDue);
}
public function paymentReceived(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), $this->status());
}
}
final class PastDueSubscription implements SubscriptionState
{
public function status(): SubscriptionStatus
{
return SubscriptionStatus::PastDue;
}
public function cancel(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), SubscriptionStatus::Cancelled);
}
public function paymentFailed(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), $this->status());
}
public function paymentReceived(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), SubscriptionStatus::Active);
}
}
final class CancelledSubscription implements SubscriptionState
{
public function status(): SubscriptionStatus
{
return SubscriptionStatus::Cancelled;
}
public function cancel(Subscription $subscription): TransitionResult
{
return new TransitionResult($this->status(), $this->status());
}
public function paymentFailed(Subscription $subscription): TransitionResult
{
throw InvalidSubscriptionTransition::from($this->status(), 'record a failed payment for');
}
public function paymentReceived(Subscription $subscription): TransitionResult
{
throw InvalidSubscriptionTransition::from($this->status(), 'record a payment for');
}
}
Idempotencja jest decyzją biznesową, nie funkcją wzorca. Tutaj dwukrotne anulowanie jest nieszkodliwe, bo dostawcy często ponawiają callbacki anulowania. Płatność za anulowaną subskrypcję odrzucamy, bo ponowna aktywacja wymaga jawnego przepływu zakupu. Zapisz te wybory; w przeciwnym razie przyszłe „sprzątanie” może zmienić semantykę rozliczeń.
TrialSubscription realizuje ten sam kontrakt. Jego paymentReceived() może przejść do Active, podczas gdy paymentFailed() może być niepoprawne, ponieważ nie próbowano pobrać opłaty. Klasy celowo nie są hierarchią ze sprytną klasą bazową. Powtórzone trzywierszowe metody status() kosztują mniej niż drzewo dziedziczenia ukrywające reguły.
Rozwiąż zapisany stan w jednym miejscu
Resolver jest mapą, nie refleksją. Jawny match sprawia, że nowy przypadek enuma wymusza widoczną zmianę w tym miejscu, co jest dobrym punktem kontrolnym w review.
<?php
declare(strict_types=1);
namespace App\Subscriptions;
use App\Subscriptions\States\ActiveSubscription;
use App\Subscriptions\States\CancelledSubscription;
use App\Subscriptions\States\PastDueSubscription;
use App\Subscriptions\States\TrialSubscription;
final class SubscriptionStateResolver
{
public function resolve(SubscriptionStatus $status): SubscriptionState
{
return match ($status) {
SubscriptionStatus::Trial => new TrialSubscription(),
SubscriptionStatus::Active => new ActiveSubscription(),
SubscriptionStatus::PastDue => new PastDueSubscription(),
SubscriptionStatus::Cancelled => new CancelledSubscription(),
};
}
}
Nie zapisuj w bazie nazwy klasy PHP. Wiąże ona trwałe dane klienta z namespace'ami i refaktorami oraz wypuszcza szczegóły implementacji do raportów, SQL i integracji. Zapisuj stabilną wartość biznesową; mapuj ją w PHP.
Wykonaj przejście atomowo, opublikuj po commicie
Akcja cyklu życia koordynuje całość. Blokuje wiersz subskrypcji, więc webhook płatności i żądanie anulowania nie mogą jednocześnie odczytać active i nadpisać się wzajemnie. Zapisuje audyt i nowy stan w jednej transakcji, a następnie dispatchuje dopiero po commicie.
<?php
declare(strict_types=1);
namespace App\Subscriptions;
use App\Events\SubscriptionTransitioned;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;
final readonly class SubscriptionLifecycle
{
public function __construct(private SubscriptionStateResolver $states)
{
}
public function cancel(Subscription $subscription): void
{
$this->transition($subscription, 'cancel');
}
public function paymentFailed(Subscription $subscription): void
{
$this->transition($subscription, 'paymentFailed');
}
public function paymentReceived(Subscription $subscription): void
{
$this->transition($subscription, 'paymentReceived');
}
private function transition(Subscription $subscription, string $operation): void
{
$result = DB::transaction(function () use ($subscription, $operation): TransitionResult {
$lockedSubscription = Subscription::query()
->lockForUpdate()
->findOrFail($subscription->getKey());
$result = $this->states->resolve($lockedSubscription->status)->{$operation}($lockedSubscription);
if ($result->from !== $result->to) {
$lockedSubscription->forceFill([
'status' => $result->to,
'cancelled_at' => $result->to === SubscriptionStatus::Cancelled ? now() : null,
])->save();
$lockedSubscription->invoices()->create([
'type' => 'subscription_transition',
'metadata' => ['from' => $result->from->value, 'to' => $result->to->value],
]);
}
return $result;
});
if ($result->from !== $result->to) {
SubscriptionTransitioned::dispatch($subscription->getKey(), $result->from, $result->to);
}
}
}
W produkcji niech SubscriptionTransitioned implementuje ShouldDispatchAfterCommit albo użyj outboxa, gdy konsumenci nie mogą nigdy pominąć eventu. Dispatch wewnątrz transakcji grozi tym, że kolejkowany listener zobaczy stary wiersz albo wyśle mail o anulowaniu dla transakcji, która później się wycofa. Zadbaj też o idempotencję eventu wejściowego od dostawcy poprzez unikalny rekord zewnętrznego eventu; blokada wiersza nie zatrzyma tego samego webhooka jutro.
Testuj strzałki, nie magię kontenera
Testy feature powinny wykonywać akcję i jej granicę trwałości. Dataset czyni legalny graf czytelnym. Testy jednostkowe pojedynczego stanu nadal pomagają, gdy reguła ma złożone obliczenia, ale nie dowodzą, że resolver, cast i koordynacja transakcji są zgodne.
<?php
use App\Events\SubscriptionTransitioned;
use App\Models\Subscription;
use App\Subscriptions\InvalidSubscriptionTransition;
use App\Subscriptions\SubscriptionLifecycle;
use App\Subscriptions\SubscriptionStatus;
use Illuminate\Support\Facades\Event;
use function Pest\Laravel\assertDatabaseHas;
it('moves subscriptions through legal transitions', function (SubscriptionStatus $from, string $operation, SubscriptionStatus $to): void {
Event::fake();
$subscription = Subscription::factory()->create(['status' => $from]);
app(SubscriptionLifecycle::class)->{$operation}($subscription);
assertDatabaseHas('subscriptions', [
'id' => $subscription->id,
'status' => $to->value,
]);
Event::assertDispatched(SubscriptionTransitioned::class);
})->with([
'active payment failure' => [SubscriptionStatus::Active, 'paymentFailed', SubscriptionStatus::PastDue],
'past due recovery' => [SubscriptionStatus::PastDue, 'paymentReceived', SubscriptionStatus::Active],
'trial cancellation' => [SubscriptionStatus::Trial, 'cancel', SubscriptionStatus::Cancelled],
]);
it('does not resurrect a cancelled subscription from a late payment webhook', function (): void {
$subscription = Subscription::factory()->create(['status' => SubscriptionStatus::Cancelled]);
expect(fn () => app(SubscriptionLifecycle::class)->paymentReceived($subscription))
->toThrow(InvalidSubscriptionTransition::class);
$subscription->refresh();
expect($subscription->status)->toBe(SubscriptionStatus::Cancelled);
});
Testuj też politykę no-op. Jeżeli duplikat anulowania nie może tworzyć kolejnego rekordu audytu ani eventu, sprawdź dokładnie ten wynik. To właśnie tam systemy płatności i supportu najczęściej tracą pieniądze albo wysyłają podwójne maile.
Pułapki i alternatywy
Najczęstsza porażka to budowanie jednej klasy na status, zanim pojawi się zachowanie, które ma ona posiadać. Cztery stany z identycznymi metodami to ceremonia, nie architektura. Drugim błędem jest bezpośrednie wykonywanie I/O przez stan: PastDueSubscription, który woła dostawcę płatności, staje się trudny do bezpiecznego ponowienia i niemożliwy do utrzymania w transakcji. Na koniec nie myl stanu z historią. Bieżący stan szybko odpowiada na pytanie „co może wydarzyć się teraz?”, a append-only rekordy przejść odpowiadają „co się wydarzyło i dlaczego?”. Trzymaj oba, gdy biznes potrzebuje audytu.
Dla dwóch wartości i jednej operacji czytelniejszy jest enum z konkretną metodą modelu:
public function cancel(): void
{
if ($this->status === SubscriptionStatus::Cancelled) {
return;
}
$this->update(['status' => SubscriptionStatus::Cancelled, 'cancelled_at' => now()]);
}
Dla dużego grafu utrzymywanego przez osoby nietechniczne, przejść czasowych, guardów, wizualnego review workflow albo setek kombinacji użyj pakietu do maszyn stanów lub wydzielonego serwisu workflow. Dostajesz narzędzia kosztem konwencji frameworka i kolejnej abstrakcji. Wybierz State, gdy zachowanie — a nie tylko etykiety — różni się zależnie od statusu, graf jest wystarczająco mały, by zrozumieć go w kodzie, i chcesz, by każda legalna strzałka miała oczywisty dom.
Użyteczna granica jest prosta: zapisuj stabilne wartości statusu, rozwiązuj bieżący obiekt polityki, wykonuj jedno atomowe przejście i publikuj jego konsekwencje dopiero, gdy jest trwałe. Kontrolery wyrażają wtedy intencję; cykl życia pozostaje jedynym źródłem prawdy.