---
title: "Applying transitions — Lifecycle for Laravel | Roundly"
description: "Apply named transitions with an actor, reason and payload — transitionTo(), attempt(), results, optimistic versions and idempotency keys."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/applying-transitions
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Lifecycle for Laravel](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)

# Applying transitions

Build a handle with the call’s context, then apply a transition by name — or by target state:

```php
use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

// A handle per subject (and lifecycle), with the call's context
Lifecycles::for($listing)
    ->by($user)                         // the actor (default: the authenticated user)
    ->because('Back in stock')          // the reason, stored in history
    ->with(['note' => 'Restocked'])     // the payload, validated by the transition's rules()
    ->apply('reopen');

Lifecycles::for($listing)->transitionTo(ListingStatus::Closed);   // by target state
Lifecycles::for($listing)->asSystem()->apply('expire');            // system context
```

## The trait shorthand

```php
$listing->transition('close', ['note' => 'Sold elsewhere']);   // name, payload, lifecycle
$listing->canTransition('reopen');                             // bool
$listing->lifecycle()->by($user)->because('Back in stock')->apply('reopen');
$listing->transitionTo(ListingStatus::Archived);
```

## Results and refusals

apply() returns a TransitionResult (subject, lifecycle, transition, from, to, record, replayed). A refused call throws TransitionDeniedException and leaves the model exactly as it was. attempt() returns the refusal instead of throwing:

```php
$attempt = Lifecycles::for($listing)->by($user)->attempt('close');

if (! $attempt->succeeded) {
    return back()->withErrors($attempt->decision->messages());
}
```

transitionTo() picks the one transition from the current state to the target. When none exists, the call is denied with no\_transition\_to\_state; when several exist, it throws AmbiguousTransitionException — call apply() with the name instead.

## Payloads and reasons

A payload is validated by the transition’s rules(), and only the validated keys reach guards, handlers and history — minus sensitive() keys, which are never stored. Sending a payload to a transition that declares no rules() throws InvalidLifecycleUsageException instead of silently dropping it:

```php
$lifecycle->transition('refund')
    ->from('captured')->to('refunded')
    ->requiresReason(5)
    ->rules(['amount' => 'required|integer|min:1', 'card_number' => 'nullable|string'])
    ->sensitive('card_number');

Lifecycles::for($payment)->because('Customer request')->with(['amount' => 500])->apply('refund');
```

Reasons are capped by history.reason\_max\_length and the stored context by history.max\_context\_bytes; with history.store\_payload off, no payload is stored at all.

## Optimistic versions

Each state change increases the record’s version by one. Send it with your form or API response and pass it back:

```php
Lifecycles::for($listing)->version();                         // e.g. 4, sent to the client
Lifecycles::for($listing)->expectingVersion(4)->apply('close'); // refused with stale_version if it moved on
```

## Idempotency keys

For webhooks and retries, an idempotency key applies a transition at most once:

```php
$result = Lifecycles::for($order)->idempotencyKey("payments:{$event->id}")->apply('pay');
$result->replayed;   // true when this key was already applied; nothing ran again
```

A replay returns the original result without guards, writes or events. Reusing the key for another transition throws IdempotencyConflictException. A replay does not check actor rules, so use keys that cannot be guessed or that include the actor.

## Dirty models

apply() saves the model after its handlers when anything is dirty, including changes you made before calling it. A dirty lifecycle attribute itself is a usage error, and so is a handler or hook that changes the state being written. Inside handlers, every other model (and every other lifecycle of the same model) keeps strict writes: change another subject’s state with a transition. If anything throws, everything rolls back, including the in-memory model — see Handlers, hooks, stamps and snapshots for the full transaction.

A soft-deleted subject throws SubjectTrashedException, and a transition into a quota’d state that would also change one of the quota’s scope columns throws InvalidLifecycleUsageException — see Limits, quotas and freezes.

## 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel.md)
