---
title: "Testing — Lifecycle for Laravel | Roundly"
description: "Swap in Lifecycles::fake() to record transitions and refuse on demand, with every assertion — or test the real engine with factories and time travel."
url: https://roundly-consulting.com/open-source/docs/lifecycle-for-laravel/testing
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)

# Testing

Lifecycles::fake() replaces the manager behind the facade and in the container, so injected managers, handles and $model->transition() are faked too:

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

Lifecycles::fake()->denyNext('reopen', DenialCode::QuotaExceeded);

$this->post("/listings/{$listing->id}/reopen")->assertStatus(422);

Lifecycles::assertTransitionDenied($listing, 'reopen', DenialCode::QuotaExceeded);
Lifecycles::assertNotTransitioned($listing, 'reopen');
```

- It writes nothing and fires nothing — no rows, no events, no jobs — but it refuses what the real engine refuses without a database and keeps its own memory of what it was asked to do.
- Transitions run every real check that needs no database and no Gate (unknown transition, wrong source state, terminal state, system context, actor types and closures, the reason, payload validation), refuse a dirty lifecycle attribute and a soft-deleted subject, replay idempotency keys, and change the model’s attribute in memory.
- Freezes: a faked freeze() refuses later transitions (frozen) unless they ignoresFreeze(), until unfreeze() or its until. Both return what the real calls return.
- Schedules: schedule() runs the real schedule-time checks except the Gate (unknown, terminal or wrong-source transition, system context, freeze, actor rules, reason, payload, a required sensitive() key). cancelScheduled() returns true once for a schedule the fake made, and a faked transition that leaves the state forgets its schedules.
- Expiry: renew(), extend(), expireAt() and neverExpire() compute an instant without writing it (now + the state’s TTL for renew()); a state without an expiry throws ExpiryException. The fake keeps no expiry of its own, so for a stay entered under the fake it differs from the real engine: extend() extends from now, neverExpire() returns false (it reports whether the database holds a pending expiry), and extend() after a faked neverExpire() still extends from now instead of throwing ExpiryException.
- Rollbacks pop the fake’s own stack of applied transitions and refuse like the real rules that need no database (nothing to roll back, irreversible or non-compensating steps, a state a direct write changed since the last faked transition — not\_reversible at once, where the real canRollback() answers state\_mismatch until the write is adopted — windows, freezes, system-only steps, reasons), and give the fake’s schedules back as the real rollback does.
- Guards, Gate abilities, quotas, rate limits, deadlines, limits, seals, expected versions and other database-backed checks are skipped. A model created under the fake still starts in its initial state, and a direct write still throws. Database-backed handle reads (enteredAt(), isFrozen(), expiresAt(), history(), scheduled()) see no faked changes — use the assertions.

## Refusing on demand

```php
$fake = Lifecycles::fake();

$fake->denyNext('reopen', DenialCode::QuotaExceeded);         // refuse once
$fake->deny('publish', 'no_photos', 'Add a photo first.');    // refuse every time
```

A refused call throws TransitionDeniedException (or returns the refusal through attempt()), exactly like a real refusal, so toValidationException() works.

## Assertions

Call them on the facade or on the returned fake. Optional arguments are filters; null matches anything. Every assertion about a subject also takes a trailing lifecycle name, for models whose lifecycles share transition names. assertScheduled() and assertNothingScheduled() count accepted schedules only, and a refused schedule() or rollback() is not a transition denial — read it from recorded():

```php
Lifecycles::assertTransitioned($listing, 'reopen', fn (TransitionResult $result) => $result->record->reason === 'Back in stock');
Lifecycles::assertTransitionedTo($listing, ListingStatus::Active);
Lifecycles::assertTransitionDenied($listing, 'reopen', DenialCode::QuotaExceeded);
Lifecycles::assertRolledBack($listing);
Lifecycles::assertFrozen($listing);
Lifecycles::assertUnfrozen($listing);
Lifecycles::assertScheduled($listing, 'publish', $publishAt);
Lifecycles::assertScheduleCancelled($listing, 'publish');
Lifecycles::assertExpiryChanged($listing, ExpiryChange::Extend);
Lifecycles::assertAdopted($listing);
Lifecycles::assertSwept(1);                 // sweep() / schedules()->runDue()
Lifecycles::assertWarned(1);                // schedules()->warn()
Lifecycles::assertScheduleRetried($scheduleId);
Lifecycles::assertPruned();

// Several lifecycles sharing transition names: name the one you mean
Lifecycles::assertTransitioned($order, 'cancel', lifecycle: 'payment_status');

// The negative counterparts
Lifecycles::assertNotTransitioned($listing, 'reopen');
Lifecycles::assertNothingTransitioned();
Lifecycles::assertNothingRolledBack();
Lifecycles::assertNothingFrozen();
Lifecycles::assertNothingUnfrozen();
Lifecycles::assertNothingScheduled();
Lifecycles::assertNothingCancelled();
Lifecycles::assertNoExpiryChanged();
Lifecycles::assertNothingAdopted();
Lifecycles::assertNotSwept();
Lifecycles::assertNotWarned();
Lifecycles::assertNothingRetried();
Lifecycles::assertNotPruned();

Lifecycles::recorded();   // list<RecordedCall>: method, request, result, denied
```

## A feature test with the fake

```php
it('reopens a listing', function (): void {
    Lifecycles::fake();
    $listing = Listing::factory()->create(['status' => ListingStatus::Closed]);

    $this->actingAs($owner)->post("/listings/{$listing->id}/reopen", ['reason' => 'Back in stock'])->assertRedirect();

    Lifecycles::assertTransitioned($listing, 'reopen', fn (TransitionResult $result) => $result->record->reason === 'Back in stock');
    expect($listing->fresh()->status)->toBe(ListingStatus::Closed);   // the fake never writes
});
```

## Against the real engine

The engine is fast enough for feature tests on SQLite. Seed states with your factories — a declared non-initial state is accepted on creation — and travel through expiries with Carbon::setTestNow() and Lifecycles::sweep():

```php
use Illuminate\Support\Carbon;
use RoundlyConsulting\Lifecycle\Facades\Lifecycles;

it('expires an active listing after its TTL and grace period', function (): void {
    $listing = Listing::factory()->create(['status' => ListingStatus::Active]);

    Carbon::setTestNow(now()->addDays(34));
    Lifecycles::sweep();

    expect($listing->fresh()->status)->toBe(ListingStatus::Expired);
});
```

Assert on events with Event::fake(\[LifecycleTransitioned::class\]) and on history with Lifecycles::for($model)->history() / lastTransition(). Changing a saved model’s state in afterCreating or a seeder needs Lifecycles::allowDirectWrites(). When a test redefines a lifecycle at runtime, call Lifecycles::definitions()->flush().

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