roundly-consulting/lifecycle-for-laravel
Lifecycle for Laravel adds configurable lifecycles to Eloquent models: define states, allowed transitions, guards, and hooks so business rules stay organized, predictable, and testable instead of scattered across controllers, observers, and service classes.
This package gives your Eloquent models an append-only transition history: every state change is written as a TransitionRecord, and the package ships API resources that render that history (and the current state) without you hand-rolling serializers. 1.1.0 is a small, additive release — the headline is that the actor on a history row now has a proper, swappable resource.
Minimal setup:
php artisan vendor:publish), then php artisan migrate to create the transitions table.saving hook — you want an explicit actor and timestamp per transition.LifecycleResource for a model's current state plus last_transition, and TransitionRecordResource for each row in the history list.First use case: an audit/history endpoint on an existing resource. Add last_transition to your show response, then a GET /orders/{order}/transitions endpoint returning the collection. You get {id, type} for the actor for free — 1.1.0's TransitionActorResource wraps the TransitionRecord, so rendering history loads no actor model and adds no query. That is the design constraint to internalize: the actor is a reference, not a hydrated relation.
Where to look first: the transitions migration (know your columns before writing queries/filters), then TransitionRecordResource to see the exact wire shape, then config/lifecycle.php for resources.actor.
Render history as a nested, not top-level, resource. LifecycleResource already carries last_transition; use TransitionRecordResource::collection($model->transitions) for the full timeline and keep both endpoints shaped from the same resource classes so actor.type is consistent everywhere.
Customize the actor once, in config. The day-one ask is almost always "we need the user's name on the history row". Don't fight the serializer in each endpoint — subclass JsonResource:
class ActorResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'type' => class_basename($this->resource::class),
'name' => $this->name,
'email' => $this->email,
];
}
}
// config/lifecycle.php
'resources' => [
'actor' => App\Http\Resources\ActorResource::class,
],
Because the resource receives the record (or a lazily-resolved actor reference), anything you add beyond id/type is your responsibility to eager load — see Gotchas.
Treat type as a contract, not an implementation detail. Since 1.1.0, type is the actor model's class basename (User), not the morph class (App\Models\User when no morph map is registered). If you do register a morph map, type is the basename of the mapped class, not the alias. If your frontend already keys off App\Models\User or user, either normalize on the client or map it in your own ActorResource:
'type' => match ($this->resource::class) {
\App\Models\User::class => 'user',
\App\Models\System::class => 'system',
},
Wire actor identity through consistently. Use the same actor-resolution path (auth guard → Auth::user(), or a system/service actor) for every record call, otherwise type becomes a grab-bag of classes and clients can't branch on it.
Version or alias the API shape. If you expose history publicly, document {id, type} as a stable enum of basenames and add new actor types there rather than letting model class names leak into your public API contract.
Upgrade pattern for 1.1.0. No migration or config change is required to adopt it — the new default applies immediately. Check your API tests for hardcoded App\Models\... or alias strings in actor.type, update them, and ship. If you have external consumers, this is a wire-format change even though it's listed under "Changed": announce it.
actor.type silently changed in 1.1.0. No exception, no deprecation — just different strings. Any client, cached payload, snapshot test, or stored JSON that persisted App\Models\User (or your morph alias) is now stale. Grep both your codebase and, if you store rendered history, your database.user); now it's the mapped class basename (User). If you added a morph map because you wanted clean API strings, this release moves you backwards — subclass the actor resource and re-map deliberately.null falls back to the packaged resource, but anything that isn't an instantiable JsonResource subclass throws InvalidLifecycleConfigurationException with reason notAResource. That includes: an interface, an abstract class, a class that doesn't exist, and a class whose constructor requires arguments. Keep your actor resource constructor-less.notAResource in production usually means a stale config cache. Always php artisan config:clear (or config:cache in deploy) after changing lifecycle.resources.actor. The exception message names the reason — read it before assuming a missing class.{id, type}. The moment you render name or avatar, either eager load on the host query (respecting the morph map: with('transitions.actor') won't work if actor isn't a real relation — check how the package exposes it) or guard with $this->whenLoaded(...) and accept null when not loaded.(type, id) pairs from the timeline and do one whereIn per actor class, then key a lookup array passed to the resource. This keeps the packaged resource's no-query property for the common case while making the rich case acceptable.last_transition and the history list must agree. Both go through TransitionRecordResource in 1.1.0, so they will — but only if you don't bypass the resource anywhere (raw $model->transitions->toArray(), custom serializers, broadcast events). Centralize.last_transition (null), and history order should be explicit (newest-first for feeds, oldest-first for audit). Don't rely on insert order.json assertions against MySQL or PG before shipping.lifecycle.resources.actor is swappable in 1.1.0. If you need to change the transition row shape itself, subclass TransitionRecordResource and use it at your endpoint — but then you own the consistency risk with LifecycleResource::last_transition, which will keep rendering the packaged shape unless you swap it too.How can I help you explore Laravel packages today?