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

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": {"type": "App\\Models\\User", "id": 1}, "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().

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 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.