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

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 conflicts

A 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:

KindWritten byReversible
initialthe created hook, or initializing a NULL stateno
transitionapply() / transitionTo()yes
scheduledthe sweep running a schedule()yes
expirythe sweep running an expiryno — undo it with a forward transition such as reactivate
rollbacka rollback stepno (no redo)
adopteddrift adoptionno
Lifecycles::for($listing)->history(20);        // Collection<TransitionRecord>, newest first (default 50)
Lifecycles::for($listing)->lastTransition();   // ?TransitionRecord
$listing->lifecycleHistory()->get();           // the raw LifecycleTransition models

TransitionRecord 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));   // PruneResult
php 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 tables

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