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
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();
}
}
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];
}
}
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
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
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();
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
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
});
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
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
- Docs: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel
- GitHub: https://github.com/roundly-consulting/lifecycle-for-laravel
- Registry of our Laravel packages, written for coding agents: https://github.com/roundly-consulting/roundly-for-laravel
Lifecycle for Laravel is one of the open-source Laravel packages we maintain at Roundly Consulting; the registry lists the rest.













