Handlers, hooks, stamps and snapshots
A transition handler runs inside the transaction after the state write; implement CompensatesTransition to make it reversible. Stamps set columns to “now” on entry, and snapshots capture attributes so a rollback can restore them:
use RoundlyConsulting\Lifecycle\Contracts\CompensatesTransition;
use RoundlyConsulting\Lifecycle\Contracts\TransitionHandler;
use RoundlyConsulting\Lifecycle\DataTransferObjects\RollbackContext;
use RoundlyConsulting\Lifecycle\DataTransferObjects\TransitionContext;
final readonly class ReserveStock implements TransitionHandler, CompensatesTransition
{
public function __construct(private Inventory $inventory) {}
public function handle(TransitionContext $context): void
{
$this->inventory->reserve($context->subject);
}
public function compensate(RollbackContext $context): void
{
$this->inventory->release($context->subject);
}
}
$lifecycle->state(OrderStatus::Paid)->stamps('paid_at')->onEnter(SendReceipt::class);
$lifecycle->transition('fulfil')
->from(OrderStatus::Paid)->to(OrderStatus::Fulfilled)
->handledBy(ReserveStock::class)
->snapshots('stock_note')
->reversible(within: '1 hour');The transaction
In one transaction on the subject’s connection, apply() does the following:
- 1. Locks the subject row.
- 2. Re-checks the transition.
- 3. Dispatches LifecycleTransitioning.
- 4. Writes the state with a compare-and-swap.
- 5. Runs onExit hooks, the handler and onEnter hooks.
- 6. Saves the model when it is dirty.
- 7. Appends the history row.
- 8. Updates the state record and the schedules.
Events then fire after commit. If anything throws, everything rolls back, including the in-memory model. Keep external side effects (emails, HTTP calls) in listeners of the after-commit events: a deadlock retry re-runs handlers.
Rules
- Handlers, guards and hooks given as class names are resolved from the container on every run, so constructor injection and container overrides work. Instances and closures work too.
- A handler’s model changes are saved after it; its exceptions roll everything back.
- A handler or hook may not change the lifecycle attribute being written: InvalidLifecycleUsageException::dirtyStateAttribute, and the transition rolls back — strict writes on or off. The same holds for a compensating handler during a rollback. Every other model, and every other lifecycle of the same model, keeps strict writes inside handlers.
- On entry into a quota’d state, a handler or hook that changes a quota scope column throws InvalidLifecycleUsageException::quotaScopeChanged.
- Without CompensatesTransition (or reversible(withoutCompensation: true)), a transition with a handler cannot be rolled back — lifecycle:validate warns irreversible_by_handler. A closure handler cannot compensate.
- Self-transitions run the handler but no hooks, because no state is left or entered. Initialization, adoption and rollbacks run no hooks; initialization and adoption set no stamps.
- onEnter / onExit hooks implement StateHook (__invoke(StateHookContext $context)) and receive the subject, lifecycle, state and the TransitionContext.
- Stamps are always snapshotted, so a rollback restores them.
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.