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

Require the package. The service provider and the Lifecycles facade alias are auto-discovered:

composer require roundly-consulting/lifecycle-for-laravel

The migrations create polymorphic subject and actor columns. If your models (or users) use UUID or ULID keys, set the key types before migrating — all subjects share one key type, and so do all actors:

LIFECYCLE_KEY_TYPE=uuid          # your models with a lifecycle: bigint (default), uuid or ulid
LIFECYCLE_ACTOR_KEY_TYPE=bigint  # your users

The values are case-insensitive, and a blank value is not set, so it reads as bigint. Anything else throws the toolkit’s InvalidConfigurationException when the migrations run, before any table is created.

Publish and run the migrations. They are publish-only — the package never loads them itself — and forward-only:

php artisan vendor:publish --tag="lifecycle-migrations"
php artisan migrate

Optionally publish the config file and the translations (English and Slovak ship with the package):

php artisan vendor:publish --tag="lifecycle-config"
php artisan vendor:publish --tag="lifecycle-translations"

Your lifecycle columns

A lifecycle attribute is a plain string (or integer) column on your own table; the package adds its own tables for state records, history, schedules and quota locks. Add the column in a migration:

Schema::table('listings', function (Blueprint $table): void {
    $table->string('status', 64)->nullable()->index();   // int-backed enum: unsignedSmallInteger
});

No database default is needed: a new model starts in the initial state. Index the column together with any quota scope columns, for example $table->index(['status', 'user_id']), and list the model in lifecycle.subjects so lifecycle:validate checks it.

Schedule the sweep

If any state has a TTL, or you schedule transitions, run the sweep every minute. Pruning is optional:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('lifecycle:sweep')->everyMinute();
Schedule::command('lifecycle:prune')->daily();

lifecycle:sweep is isolatable (--isolated prevents parallel runs across servers), and two concurrent sweeps are still safe: each schedule runs at most once.

The package writes its rows on each subject’s own connection, and one sweep covers one connection’s package tables. Models on a connection other than the default need a sweep of that connection too:

// models on another database connection: one sweep per connection
Schedule::command('lifecycle:sweep --database=tenant')->everyMinute();

Check the installation

php artisan about                 # the "Lifecycle" section
php artisan lifecycle:validate    # every lifecycle of every model in lifecycle.subjects

php artisan about shows a Lifecycle section — key types, strict writes, the models in use, queued sweeps, whether lifecycle:sweep is scheduled (for the default connection), and history pruning. lifecycle:validate warns when a definition needs the sweep but it is not scheduled, and checks the sweep of each listed model’s own connection. A sweep scheduled as a closure or job is recognised when it is named lifecycle:sweep (->name('lifecycle:sweep')); an unnamed closure makes the answer unknown.

The publish tags are lifecycle-migrations, lifecycle-config, lifecycle-translations and lifecycle-stubs (the make:lifecycle stubs).

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.