API resources and validation
use RoundlyConsulting\Lifecycle\Http\Resources\LifecycleResource;
use RoundlyConsulting\Lifecycle\Http\Resources\TransitionRecordResource;
// One response feeds the buttons: state, freeze, expiry and every transition with its reasons
return LifecycleResource::make(Lifecycles::for($listing)->by($request->user()));
// A list: a model stands for its primary lifecycle, with the actor from auth
return LifecycleResource::collection(Listing::query()->withLifecycle()->get());
// Another lifecycle of every model in a list
return LifecycleResource::collectionFor(Order::query()->withLifecycle()->get(), 'payment_status');
// History rows (context and snapshot only on request)
return TransitionRecordResource::collection(Lifecycles::for($listing)->history());
(new TransitionRecordResource($record))->withContext();LifecycleResource returns everything a screen needs — build the handle with by() so transitions are checked for that actor; given a model, it uses the model’s primary lifecycle and the authenticated actor. collection() renders each model’s primary lifecycle; collectionFor($models, 'payment_status') renders the named one. allowed_transitions lists refused transitions too, with their denials, so a UI can render disabled buttons with reasons, a reason field (requires_reason), a form (payload_fields) and a countdown (available_at). Instants are ISO-8601 UTC; state and to are raw backing values. With withLifecycle(), a collection reads the state, freeze, expiry and last transition without a query per model:
{
"lifecycle": "status",
"state": "active",
"state_label": "Active",
"terminal": false,
"entered_at": "2026-10-02T08:00:00+00:00",
"version": 2,
"frozen": {"until": "2026-10-05T08:00:00+00:00", "reason": "maintenance"},
"expiry": {"expires_at": "2026-11-01T08:00:00+00:00", "due_at": "2026-11-04T08:00:00+00:00", "in_grace": false},
"allowed_transitions": [
{
"name": "close", "label": "Close", "to": "closed", "to_label": "Closed", "allowed": false,
"denials": [{"code": "frozen", "message": "This record is frozen.", "params": {"transition": "Close", "state": "Active"}, "retry_after": null, "errors": []}],
"requires_reason": false, "payload_fields": [], "available_at": null
}
],
"last_transition": {
"id": 2, "kind": "transition", "transition": "publish", "from": "draft", "to": "active",
"actor": {"id": 1, "type": "User"}, "system": false, "reason": "ready",
"occurred_at": "2026-10-02T08:00:00+00:00", "reverted": false
}
}TransitionRecordResource returns id, kind, transition, from, to, actor, system, reason, occurred_at and reverted; context and snapshot only after withContext(). actor is null for a row without one (system and initial rows).
The actor resource
actor renders through TransitionActorResource as {id, type}: the actor’s key and its model’s class basename (User) — never a PHP class name such as App\Models\User. With a morph map the alias is mapped to its class first, so 'member' => User::class renders User, not member. The resource wraps the TransitionRecord, not the actor model, so rendering history loads no actor and adds no query per row.
Swap it through lifecycle.resources.actor to add what your API needs, such as a name or an avatar. Your JsonResource subclass receives the same TransitionRecord and resolves the actor from actorType and actorId:
// config/lifecycle.php
'resources' => [
'actor' => App\Http\Resources\HistoryActorResource::class,
],
// app/Http/Resources/HistoryActorResource.php
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use RoundlyConsulting\Lifecycle\DataTransferObjects\TransitionRecord;
/** @property TransitionRecord $resource */
final class HistoryActorResource extends JsonResource
{
public function toArray(Request $request): array
{
$class = Relation::getMorphedModel((string) $this->resource->actorType) ?? $this->resource->actorType;
return [
'id' => $this->resource->actorId,
'type' => class_basename((string) $class),
'name' => $class === User::class ? User::query()->find($this->resource->actorId)?->name : null,
];
}
}This one reads one user per row; for long histories, load the page’s actors once (findMany() over the actorIds) and look them up. Blank or null uses the packaged resource. Anything that is not an instantiable JsonResource subclass — a model, the bare JsonResource, an abstract or missing class — throws InvalidLifecycleConfigurationException (notAResource) naming the key when the first row with an actor renders.
Validation rules
use RoundlyConsulting\Lifecycle\Rules\ValidState;
use RoundlyConsulting\Lifecycle\Rules\ValidTransition;
$request->validate([
'transition' => ['required', ValidTransition::for($listing)->by($request->user())],
'target' => ['required', ValidTransition::for($listing, 'status')->by($request->user())->toState()],
'status' => ['nullable', ValidState::of(Listing::class)], // the model's first lifecycle
'payment' => ['nullable', ValidState::of(new Order, 'payment_status')],
'filter' => ['nullable', ValidState::of(ListingLifecycle::class)], // a definition class
]);ValidTransition runs the full pipeline, actor rules included, and fails with each denial’s translated message; toState() validates a target state instead of a transition name. ValidState accepts a model class or instance (with an optional lifecycle) or a definition class and requires a declared state. Both are advisory like check() — apply() decides again under the lock.
Refusals as 422 and Retry-After
use RoundlyConsulting\Lifecycle\Exceptions\TransitionDeniedException;
// A refusal as a 422, with payload errors under their own keys ($request is a FormRequest)
try {
Lifecycles::for($listing)->by($request->user())->with($request->validated())->apply('reopen');
} catch (TransitionDeniedException $e) {
throw $e->toValidationException();
}Pass only validated input, never $request->all(). The transition validates the payload again with its own rules() and keeps only those keys.
toValidationException() exists on TransitionDeniedException (field transition) and RollbackDeniedException (field rollback). Both implement the toolkit’s HasRetryAfter: retryAfterSeconds() gives a Retry-After value for rate_limited or cooldown_active. decision() and denials() return the structured refusal.
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 cryptoBy 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.