DEV Community

Andrej Mihaliak
Andrej Mihaliak

Posted on AI-assisted

Lifecycle for Laravel — Eloquent status transitions with quotas, expiry and rollbacks

A status column on an Eloquent model usually starts as an enum and a couple of if checks. Then the rules pile up: who may publish, "at most five active listings per customer", listings that expire after 30 days, a support agent who needs to undo a wrong click. Soon nobody can say who changed a status, or why.

What it does

Lifecycle for Laravel is a state machine for Eloquent models with the business rules around it built in. You declare a model's states and the named transitions between them once, in a definition class. Every apply, check, scheduled run and rollback then goes through the same guard pipeline: Gate abilities, actor rules, required reasons, validated payloads, time windows, limits and your own guards. For the same input, check() and apply() return the same denials, so the button your UI shows matches what the server accepts.

Install

composer require roundly-consulting/lifecycle-for-laravel
php artisan vendor:publish --tag="lifecycle-migrations"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Define, attach, apply

Define the lifecycle once (php artisan make:lifecycle ListingLifecycle generates the class):

use RoundlyConsulting\Lifecycle\Definition\LifecycleBuilder;
use RoundlyConsulting\Lifecycle\Definition\LifecycleDefinition;

final class ListingLifecycle extends LifecycleDefinition
{
    public function define(LifecycleBuilder $lifecycle): void
    {
        $lifecycle->states(ListingStatus::class)   // a backed enum: draft, active, closed, expired
            ->initial(ListingStatus::Draft);

        $lifecycle->state(ListingStatus::Active)
            ->ttl('30 days')->expiresVia('expire')
            ->quota(5, scope: 'user_id');           // at most 5 active listings per user

        $lifecycle->transition('publish')->from(ListingStatus::Draft)->to(ListingStatus::Active)->ability('publish');
        $lifecycle->transition('close')->from(ListingStatus::Active)->to(ListingStatus::Closed)->requiresReason();
        $lifecycle->transition('expire')->from(ListingStatus::Active)->to(ListingStatus::Expired)->systemOnly();
    }
}
Enter fullscreen mode Exit fullscreen mode

Attach it to the model:

use RoundlyConsulting\Lifecycle\Concerns\HasLifecycle;
use RoundlyConsulting\Lifecycle\Contracts\LifecycleSubject;

final class Listing extends Model implements LifecycleSubject
{
    use HasLifecycle;

    public function lifecycleDefinitions(): array
    {
        return ['status' => ListingLifecycle::class];
    }

    protected function casts(): array
    {
        return ['status' => ListingStatus::class];
    }
}
Enter fullscreen mode Exit fullscreen mode

Then move it through its states:

use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

Lifecycles::for($listing)->by($user)->apply('publish');      // draft → active, checked by the Gate

Lifecycles::for($listing)->can('close');                     // true
Lifecycles::for($listing)->because('Sold elsewhere')->apply('close');

Lifecycles::for($listing)->rollback();                       // undo the last transition
Lifecycles::for($listing)->history();                        // who did what, when and why
Enter fullscreen mode Exit fullscreen mode

Saving a new status straight onto the model throws by default, so nothing gets around the rules by accident. Writes that skip Eloquent events (a query-builder update(), raw SQL) are picked up and recorded the next time the package touches that record, or for a whole table with php artisan lifecycle:adopt.

Quotas that hold under concurrent requests

A rule like "five active listings per user" is usually a count() before the save, and two requests arriving at the same moment both pass it. Here the quota is checked inside the transition's transaction, behind a lock row per scope value, so concurrent requests take turns. The maximum can also come from the subject, for example the customer's plan:

$lifecycle->state(ListingStatus::Active)
    ->quota(5, scope: 'user_id')                                              // static
    ->quota(fn (Listing $l): int => $l->user->plan->max_active, scope: 'user_id', name: 'plan'); // per subject
Enter fullscreen mode Exit fullscreen mode

The package's test suite runs concurrent processes against PostgreSQL and MySQL that try to push more listings into the state than the quota allows; exactly the quota gets in. The docs cover the per-database details. Cooldowns, "reopen at most 3 times", a minimum time in a state, rate limits and deadlines sit next to quotas.

Expiry and scheduled transitions

A state can expire after a duration, at a date stored on the model or when a closure says so, with a grace period and warnings:

$lifecycle->state(ListingStatus::Active)
    ->ttl('30 days')                  // or ->ttl(fn (Listing $l) => $l->plan->duration) or ->expiresAtAttribute('ends_at')
    ->grace('3 days')
    ->warnBefore('7 days', '1 day')
    ->expiresVia('expire');

$lifecycle->transition('expire')->from(ListingStatus::Active)->to(ListingStatus::Expired)->systemOnly();
Enter fullscreen mode Exit fullscreen mode

Each warning lead fires the LifecycleExpiring event at most once per expiry, so a reminder email doesn't go out twice. extend(), renew(), expireAt() and neverExpire() change the expiry of the current stay. Transitions can also be planned for later:

$scheduled = Lifecycles::for($post)->by($editor)->because('Launch')->with(['channel' => 'web'])->schedule('publish', $publishAt);
Lifecycles::for($post)->cancelScheduled('publish');     // bool
Enter fullscreen mode Exit fullscreen mode

One command, lifecycle:sweep, scheduled every minute, runs due expiries and scheduled transitions, inline or through your queue, each in its own transaction, so one failing record doesn't hold up the others. Schedules that give up are listed and can be retried.

Undo and history

rollback() undoes the last transition and rollbackTo() goes back further. Rollback windows, irreversible transitions and conflict checks on snapshotted columns are declared on the transition, and a handler that implements CompensatesTransition can release whatever it reserved. History rows can't be updated or deleted through Eloquent; only lifecycle:prune and force-deleting a record remove them.

Testing

Lifecycles::fake() replaces the engine behind the facade and in the container. It still runs the checks that need no database against your real definition (unknown or wrong-source transitions, system-only moves, actor rules, reasons, payload rules), so a test can't pass code the engine would refuse on those grounds. Nothing is written, and there is an assertion for every kind of call:

it('reopens a listing', function (): void {
    Lifecycles::fake();
    $listing = Listing::factory()->create(['status' => ListingStatus::Closed]);

    $this->actingAs($owner)->post("/listings/{$listing->id}/reopen", ['reason' => 'Back in stock'])->assertRedirect();

    Lifecycles::assertTransitioned($listing, 'reopen', fn (TransitionResult $result) => $result->record->reason === 'Back in stock');
    expect($listing->fresh()->status)->toBe(ListingStatus::Closed);   // the fake never writes
});
Enter fullscreen mode Exit fullscreen mode

For CI, lifecycle:validate reports unreachable states, dead ends and missing columns, and warns when the sweep isn't scheduled:

php artisan lifecycle:validate --strict
Enter fullscreen mode Exit fullscreen mode

Requirements

PHP ^8.4, Laravel 12 or 13, and MySQL or PostgreSQL. MIT license. Runtime dependencies are Laravel components plus two of our own packages, enums-for-laravel and package-toolkit-for-laravel.

Links

Lifecycle for Laravel is one of the open-source Laravel packages we maintain at Roundly Consulting; the registry lists the rest.

Top comments (1)

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

A lock row per scope value is the right call for quotas. A plain count check races under load. The case I would test next is a plan downgrade. The closure runs on the way in. So a user can sit at eight active listings under a new limit of five. Is that allowed overage, or can the definition flag the excess?