State Pattern in Laravel: A Subscription Lifecycle Without a Package

9 min read

$subscription->status === 'active' is harmless once. It becomes expensive when a cancellation endpoint, renewal job, payment webhook and support tool all need to answer a slightly different question: is this transition legal now? The string is then not data alone. It is a distributed state machine, with rules hidden in controllers and match expressions.

The State pattern gives each meaningful lifecycle stage a small object that owns its allowed behaviour. This is not an argument for a state-machine package. For a subscription with four or five states, plain PHP makes the transition graph explicit, is easy to test, and keeps Laravel at the edges.

We will model this lifecycle:

text
trial ──start paid plan──> active ──payment fails──> past_due
  │                          │                         │
  └──────cancel──────────────┴────cancel───────────────┴──> cancelled
                                                        │
                                                 payment received
                                                        ▼
                                                     active

The important rule is not that every subscription has a status. A state decides whether an operation is meaningful. A cancelled subscription should not silently become active because an old webhook was retried.

Persist a closed vocabulary first

Use a backed enum at the persistence boundary. It prevents typo states, gives migrations and queries a single vocabulary, and lets the resolver fail deliberately if old data is corrupt.

php
<?php

declare(strict_types=1);

namespace App\Subscriptions;

enum SubscriptionStatus: string
{
    case Trial = 'trial';
    case Active = 'active';
    case PastDue = 'past_due';
    case Cancelled = 'cancelled';
}

The model stores facts, not the workflow. status, ends_at, and cancelled_at remain ordinary columns. The one useful model method delegates an intent to a lifecycle service, so HTTP controllers, console commands and webhook jobs all travel through the same door.

php
<?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);
    }
}

Do not put a switch into this model and call it State. The model is a good place for relationships and casts. A branching workflow grows better in focused classes with names that match the business language.

Give operations a real contract

An interface should describe the transitions the application actually performs. Not every state supports every transition, which is precisely why throwing a domain exception is useful. It turns an accidental retry or stale UI action into an observable business failure instead of a surprising write.

php
<?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.");
    }
}

The result is small but useful: it lets the lifecycle publish one generic event without every state needing event-dispatch dependencies. Another application may prefer transition-specific events. The boundary is the same: state classes decide policy; infrastructure publishes consequences.

Keep each state boring and local

The active state can cancel a plan or react to a failed payment. It does not create invoices, send mail, or call Stripe. Those are effects triggered after the durable transition succeeds.

php
<?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');
    }
}

Idempotency is a business decision, not a pattern feature. Here, cancelling twice is harmless because providers commonly retry cancellation callbacks. Paying a cancelled subscription is rejected because reactivating requires an explicit purchase flow. Write those choices down; otherwise a future “cleanup” can alter billing semantics.

TrialSubscription follows the same contract. Its paymentReceived() can move to Active, while paymentFailed() may be invalid because no charge was attempted. The classes are deliberately not a hierarchy with a clever base class. Repeated three-line status() methods cost less than an inheritance tree that hides the rules.

Resolve the persisted state in one place

The resolver is a map, not reflection. An explicit match means a new enum case forces a visible change here, which is a useful review checkpoint.

php
<?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(),
        };
    }
}

Do not store the PHP class name in the database. It couples persisted customer data to namespaces and refactors, and it lets implementation details escape into reports, SQL and integrations. Persist a stable business value; map it in PHP.

Make the transition atomic, publish after commit

The lifecycle action owns coordination. It locks the subscription row so a payment webhook and a cancellation request cannot both read active and overwrite one another. It writes audit data and the new state in one transaction, then dispatches only after the transaction commits.

php
<?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);
        }
    }
}

In production, make SubscriptionTransitioned implement ShouldDispatchAfterCommit, or use an outbox when consumers must never miss an event. Dispatching inside a transaction risks a queued listener observing the old row, or sending a cancellation email for a transaction that later rolls back. Also make the inbound provider event idempotent with a unique external-event record; a row lock does not stop the same webhook arriving again tomorrow.

Test arrows, not container magic

Feature tests should exercise the action and its persistence boundary. A dataset makes the legal graph readable. Unit tests for an individual state are still helpful when a rule has complex calculations, but they do not prove the resolver, cast and transaction orchestration agree.

php
<?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);
});

Test no-op policy too. If a duplicate cancellation must not create another audit row or event, assert that exact outcome. That is where payment and support systems usually leak money or emails.

Pitfalls and the alternatives

The common failure is building one class per status before there is behaviour to own. Four states with identical methods are ceremony, not architecture. A second failure is letting a state perform I/O directly: a PastDueSubscription that calls a payment provider becomes difficult to retry safely and impossible to keep transactional. Finally, do not confuse state with history. The current state is a fast answer to “what can happen now?”; append-only transition records answer “what happened and why?” Keep both when the business needs auditability.

For two values and one operation, an enum plus a focused model method is clearer:

php
public function cancel(): void
{
    if ($this->status === SubscriptionStatus::Cancelled) {
        return;
    }

    $this->update(['status' => SubscriptionStatus::Cancelled, 'cancelled_at' => now()]);
}

For a large graph maintained by non-developers, timed transitions, guards, visual workflow review or hundreds of combinations, use a state-machine package or a dedicated workflow service. That buys tooling at the cost of framework conventions and another abstraction. Use State when behaviour—not merely labels—varies by status, the graph is small enough to understand in code, and you want every legal arrow to have an obvious home.

The useful boundary is simple: persist stable status values, resolve the current policy object, make one atomic transition, and publish its consequences only after it is durable. Controllers then express intent; the lifecycle remains the single source of truth.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.