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

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

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() and expireAt() return the instant the real call would set (now + the state’s TTL for renew()); a state without an expiry throws ExpiryException. The fake writes no expiry rows, so extend() on a model created under the fake extends from now.
  • 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, 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

$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():

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

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():

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

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.