NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Lifecycle for Laravel

Models with a lifecycle

A model with a lifecycle implements the LifecycleSubject contract, uses the HasLifecycle trait and maps each lifecycle attribute to its definition in lifecycleDefinitions():

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use RoundlyConsulting\Lifecycle\Concerns\HasLifecycle;
use RoundlyConsulting\Lifecycle\Contracts\LifecycleSubject;

final class Listing extends Model implements LifecycleSubject
{
    use HasLifecycle;
    use SoftDeletes;

    protected $guarded = [];

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

    protected function casts(): array
    {
        return ['status' => ListingStatus::class, 'published_at' => 'datetime'];
    }
}

A new model starts in the initial state. From then on, the state changes only through transitions:

use Illuminate\Support\Facades\Gate;
use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

Gate::define('publish', fn (User $user, Listing $listing): bool => $listing->user_id === $user->id);

$listing = Listing::query()->create(['user_id' => $user->id]);  // status = draft

Lifecycles::for($listing)->by($user)->apply('publish');

$listing->status;                         // ListingStatus::Active
$listing->published_at;                   // now
Lifecycles::for($listing)->expiresAt();   // now + 30 days (the sweep runs "expire" 3 days later)

Several lifecycles per model

The keys are attribute names; the first one is the default lifecycle for Lifecycles::for($model). One definition class may serve several models, and quotas count each model’s own table:

final class Order extends Model implements LifecycleSubject
{
    use HasLifecycle;

    public function lifecycleDefinitions(): array
    {
        // attribute => definition; the first one is the default lifecycle
        return ['status' => OrderLifecycle::class, 'payment_status' => PaymentLifecycle::class];
    }
}

Lifecycles::for($order)->apply('pay');                         // the first lifecycle (status)
Lifecycles::for($order, 'payment_status')->apply('authorize'); // another one

What happens on create

A NULL lifecycle attribute gets the initial state, a declared non-initial state is accepted (factories may create subjects in any state), and an undeclared value throws. Creation writes the state record and an initial history row and schedules the state’s TTL. Saving a changed status directly afterwards throws — see Strict writes and drift.

What the trait adds

  • Relations: lifecycleStates(), lifecycleHistory(), lifecycleSchedules() and lifecycleLatestTransitions() (MorphMany).
  • Shorthands: lifecycle(), transition(), transitionTo(), canTransition() and canTransitionTo() — they build the same handle as Lifecycles::for() and go through the same manager.
  • Query scopes: whereState(), whereNotState(), whereExpired(), whereNotExpired(), whereExpiringWithin(), whereInGrace(), whereFrozen(), whereInStateFor() and withLifecycle().
  • Model hooks: soft-deleting a subject pauses its schedules and restoring resumes them (a restore without model events, such as restoreQuietly(), is picked up by the next sweep); a force delete purges its records, history and schedules (history.purge_on_force_delete). Models without SoftDeletes work too.
  • A soft-deleted subject cannot be transitioned or rolled back until it is restored: the checks answer subject_trashed, and apply() and rollback() throw SubjectTrashedException.
$listing->transition('close', ['note' => 'Sold elsewhere']);   // name, payload, lifecycle
$listing->canTransition('reopen');                             // bool
$listing->lifecycle()->by($user)->because('Back in stock')->apply('reopen');
$listing->transitionTo(ListingStatus::Archived);

Reserved names

The trait’s method names are reserved on the model. A relation or attribute called lifecycle or transition must be renamed — or skip the shorthands and use the facade (Lifecycles::for($model)), which needs no trait method.

The optional cast

A plain enum cast works with the engine. AsLifecycleState reads the definition’s state (an enum case or a string) and stores only declared states — an undeclared value throws UnknownStateException:

use RoundlyConsulting\Lifecycle\Casts\AsLifecycleState;

protected function casts(): array
{
    return ['status' => AsLifecycleState::class];
}

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.