Rollbacks and history
Lifecycles::for($listing)->by($user)->rollback(); // undo the last transition
Lifecycles::for($listing)->rollbackTo($record); // undo everything after a history row (or its id)
Lifecycles::for($listing)->canRollback(); // Decision
Lifecycles::for($listing)->canRollbackTo($record); // Decision
Lifecycles::for($listing)->rollback(force: true); // ignore snapshot conflictsA rollback reverts rows of the effective history, newest first. It is all or nothing: one refused row refuses the whole call (RollbackDeniedException). A row can be rolled back when all of these hold:
- It is a transition or scheduled row (an expiry is undone with a forward transition such as reactivate).
- Its transition still exists and is not irreversible().
- Its handler is null, implements CompensatesTransition, or is marked reversible(withoutCompensation: true).
- Its window has not passed (reversible(within:), else rollback.default_window).
- The subject is still in the state that row produced.
- It is not frozen (unless every reverted transition ignoresFreeze()) or sealed.
- The actor passes rollbackRequires() / rollbackGuard() — or the transition’s own actor rules (a systemOnly() transition needs system context), so nobody can undo what they could not have done.
- The reason fits history.reason_max_length.
- No snapshotted attribute changed since (unless force).
- The restored state’s quota allows it, counted in the partition the rollback restores.
- The subject is not soft-deleted: canRollback() answers subject_trashed and rollback() throws SubjectTrashedException.
A rollback restores the state, its entry time, the counters, snapshot attributes and stamp columns. It cancels the schedules the row created and re-opens the schedules it cancelled. It appends rollback rows; a rollback itself cannot be rolled back, and neither can initial or adopted rows. State hooks do not run and rate-limit hits are not refunded. After commit, LifecycleTransitioned (kind rollback) fires per rollback row and LifecycleRolledBack once per call. One rollbackTo() reverts at most rollback.max_steps rows.
History
History is append-only: updating or deleting a row through Eloquent throws HistoryIsAppendOnlyException, and only lifecycle:prune (or the force-delete purge) removes rows. Each row records the actor, reason, validated context (minus sensitive keys), snapshot, version and time:
| Kind | Written by | Reversible |
|---|---|---|
initial | the created hook, or initializing a NULL state | no |
transition | apply() / transitionTo() | yes |
scheduled | the sweep running a schedule() | yes |
expiry | the sweep running an expiry | no — undo it with a forward transition such as reactivate |
rollback | a rollback step | no (no redo) |
adopted | drift adoption | no |
Lifecycles::for($listing)->history(20); // Collection<TransitionRecord>, newest first (default 50)
Lifecycles::for($listing)->lastTransition(); // ?TransitionRecord
$listing->lifecycleHistory()->get(); // the raw LifecycleTransition modelsTransitionRecord exposes id, lifecycle, kind (TransitionKind), transition, from, to, actorType, actorId, system, reason, context, snapshot, version, revertsId, scheduleId, occurredAt and reverted.
Pruning
use RoundlyConsulting\Lifecycle\DataTransferObjects\PruneOptions;
Lifecycles::prune(new PruneOptions(historyOlderThanDays: 365, schedulesOlderThanDays: 30)); // PruneResultphp artisan lifecycle:prune # defaults from config
php artisan lifecycle:prune --history-days=365 --schedule-days=30 --dry-run
php artisan lifecycle:prune --database=tenant # another connection's package tablesPruning never resets limits or cooldowns — they live on the state record — but it drops rollback points and idempotency keys older than the cutoff. It keeps the mark a neverExpire() call leaves while that stay lasts. --database prunes another connection’s package tables.
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.