---
title: "Testing — Events for Laravel | Roundly"
description: "Events::fake() records every operation and still performs it — every assertion pair, recorded operations, Pest expectations, factories and pitfalls."
url: https://roundly-consulting.com/open-source/docs/events-for-laravel/testing
language: en
---

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

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

# Testing

Events::fake() swaps the manager for a Testing\\EventsFake and returns it:

```php
use RoundlyConsulting\Events\Enums\RsvpStatus;
use RoundlyConsulting\Events\Facades\Events;
use RoundlyConsulting\Events\Models\Event;

it('lets a member join the meetup', function (): void {
    $events = Events::fake();
    $member = User::factory()->create();
    $event = Event::factory()->published()->capacity(1)->create();

    $this->actingAs($member)->post("/meetups/{$event->uuid}/join")->assertRedirect();

    $events->assertRsvped($event, $member, RsvpStatus::Going);
    $events->assertNothingCancelled();
    expect($member)->toBeGoingTo($event);
});
```

- It is a recording, still-performing fake: rows, seats, policies, domain events and notices are real, however the operation was called — the facade, an injected EventsManager, a handle, a sub-handle, a builder or a model trait. Calling an action class directly is not recorded.
- It is swapped in behind the facade and in the container, so an injected EventsManager is faked too.
- A call that throws records nothing, and neither does one a surrounding transaction rolls back: inside a transaction the operation is recorded when it commits — at once outside one, and your test’s RefreshDatabase transaction counts as outside.
- Reads are not recorded, and a check-in code never is.

## Assertions

Every EventOperation has an assertion pair. ?$event, ?$attendee and the other arguments are filters, and null means any. Every assertion is on the facade too, so Events::assertRsvped(…) works:

| Operation | Assertion | Negative |
| --- | --- | --- |
| Create | `assertCreated(?Closure)` | `assertNothingCreated()` |
| Update | `assertUpdated(?$event, ?array $keys)` | `assertNothingUpdated()` |
| Delete · Restore | `assertDeleted(?$event), assertRestored(?$event)` | `assertNothingDeleted(), assertNothingRestored()` |
| Publish · Unpublish · Postpone · Reschedule · Cancel · Complete | `assertPublished(?$event), assertUnpublished(), assertPostponed(), assertRescheduled(), assertCancelled(), assertCompleted()` | `assertNothingPublished(), assertNothingUnpublished(), assertNothingPostponed(), assertNothingRescheduled(), assertNothingCancelled(), assertNothingCompleted()` |
| Transition | `assertTransitioned(?$event, ?string $name)` | `assertNothingTransitioned()` |
| MoveOnline | `assertMovedOnline(?$event)` | `assertNothingMovedOnline()` |
| TransferOrganizer | `assertTransferred(?$event, ?Model $to)` | `assertNothingTransferred()` |
| SetPoster | `assertPosterChanged(?$event, ?bool $removed)` | `assertNoPosterChanged()` |
| Categorize | `assertCategorized(?$event, ?$category)` | `assertNothingCategorized()` |
| AttachLabels · SyncLabels | `assertLabelled(?$event, ?array $labels)` | `assertNothingLabelled()` |
| DetachLabels | `assertUnlabelled(?$event, ?array $labels)` | `assertNothingUnlabelled()` |
| CreateVenue · UpdateVenue · DeleteVenue · RestoreVenue · GeocodeVenue | `assertVenueCreated(?Closure), assertVenueUpdated(), assertVenueDeleted(), assertVenueRestored(), assertVenueGeocoded()` | `assertNoVenueCreated(), assertNoVenueUpdated(), assertNoVenueDeleted(), assertNoVenueRestored(), assertNoVenueGeocoded()` |
| CreateTier · UpdateTier · DeleteTier · RestoreTier | `assertTierCreated(?Closure), assertTierUpdated(), assertTierDeleted(), assertTierRestored()` | `assertNoTierCreated(), assertNoTierUpdated(), assertNoTierDeleted(), assertNoTierRestored()` |
| ReorderTiers | `assertTiersReordered(?$event)` | `assertNoTiersReordered()` |
| Rsvp | `assertRsvped(?$event, ?$attendee, ?RsvpStatus)` | `assertNothingRsvped()` |
| AddAttendee · RemoveAttendee | `assertAttendeeAdded(?$event, ?$attendee), assertAttendeeRemoved(?$event, ?$attendee)` | `assertNoAttendeeAdded(), assertNoAttendeeRemoved()` |
| ChangeTier · ChangeGuests | `assertTierChanged(?$event), assertGuestsChanged(?$event)` | `assertNoTierChanged(), assertNoGuestsChanged()` |
| ConfirmRsvp · ReleaseHold · RequestPayment · RotateCode | `assertRsvpConfirmed(?$event), assertHoldReleased(?$event), assertPaymentRequested(?$event), assertCodeRotated(?$event)` | `assertNoRsvpConfirmed(), assertNoHoldReleased(), assertNoPaymentRequested(), assertNoCodeRotated()` |
| ConfirmPayment | `assertPaymentConfirmed(?string $reference)` | `assertNoPaymentConfirmed()` |
| PromoteWaitlist | `assertPromoted(?$event)` | `assertNothingPromoted()` |
| ForgetAttendee | `assertAttendeeForgotten(?$attendee)` | `assertNoAttendeeForgotten()` |
| CheckIn · ManualCheckIn | `assertCheckedIn(?$event, ?$attendee)` | `assertNothingCheckedIn()` |
| UndoCheckIn | `assertCheckInUndone(?$event)` | `assertNoCheckInUndone()` |
| AddCoHost · RemoveCoHost | `assertCoHostAdded(?$event, ?$host), assertCoHostRemoved(?$event, ?$host)` | `assertNoCoHostAdded(), assertNoCoHostRemoved()` |
| SendInvitation · RevokeInvitation | `assertInvited(?$event, ?$invitee), assertInvitationRevoked(?$event, ?$invitee)` | `assertNothingInvited(), assertNoInvitationRevoked()` |
| RespondToInvitation | `assertInvitationAnswered(?$event, ?RsvpResponse)` | `assertNoInvitationAnswered()` |
| Sweep · Repair | `assertSwept(), assertRepaired()` | `assertNothingSwept(), assertNothingRepaired()` |
| any | `assertPerformed(EventOperation, ?Closure)` | `assertNotPerformed(EventOperation)` |
| — | `assertNothingHappened()` | — |

- assertUpdated()’s keys are UpdateEventData property names; the venue and ticket type closures receive the Venue or the AdmissionTier.
- assertPromoted() means a manual promotion that moved someone up; an automatic one shows as the operation that triggered it — a decline, a sweep, a capacity change. assertNothingPromoted() passes when nobody moved up, even after a manual promotion over an empty queue.
- assertAttendeeForgotten() is the AttendsEvents deletion hook (a force delete); assertCheckedIn() counts first check-ins; assertInvitationAnswered() with RsvpResponse::NotGoing means declined.

## Recorded operations

recorded(?EventOperation) returns the RecordedEventOperation records in call order — operation, context and result, plus event(), concerns(?Event $event) and model(string $key):

```php
use RoundlyConsulting\Events\Enums\EventOperation;
use RoundlyConsulting\Events\Testing\RecordedEventOperation;

$events->assertPerformed(EventOperation::Cancel, fn (RecordedEventOperation $op): bool => $op->context['reason'] === 'Venue flooded');
expect($events->recorded(EventOperation::Rsvp))->toHaveCount(2);
```

Context keys: event, actor, system and silent on handle calls; data (the DTO); reason, starts\_at, ends\_at, transition, keep\_venue, force, removed; organizer, keep\_as\_host; labels, category; venue, tier, order, rsvp, attendee, guests, host, invitee, response, reference and options. EventOperation has 50 cases, one per recorded write.

## Pest expectations

Register them once — they are not auto-registered. Each reads the stored RSVP, so they work with or without Events::fake():

```php
// tests/Pest.php
RoundlyConsulting\Events\Testing\EventExpectations::register();

expect($user)->toBeGoingTo($event);
expect($user)->toBeWaitlistedFor($event);
expect($user)->toBeCheckedInTo($event);
```

## Factories

```php
use RoundlyConsulting\Events\Enums\RsvpStatus;
use RoundlyConsulting\Events\Facades\Events;
use RoundlyConsulting\Events\Models\AdmissionTier;
use RoundlyConsulting\Events\Models\Event;
use RoundlyConsulting\Events\Models\Rsvp;
use RoundlyConsulting\Events\Models\Venue;
use RoundlyConsulting\Money\Money;

Events::categories()->make('Jazz')->findOrCreate();   // categories are curated: create one before tagging

$venue = Venue::factory()->at(48.1439, 17.1097)->ownedBy($team)->create();
$event = Event::factory()->organizedBy($user)->at($venue)->capacity(60)->published()
    ->categorized('jazz')->labelled(['Live'])->create();

AdmissionTier::factory()->for($event)->key('supporter')->price(Money::ofMajor('15', 'EUR'))->create();
Rsvp::factory()->for($event)->by($member)->status(RsvpStatus::Waitlisted)->create();   // a row, no seats or counters
```

| Factory | States |
| --- | --- |
| `Event::factory()` | `organizedBy(), at(), online(), startingAt(CarbonImmutable $start, int $hours = 2), capacity(), maxGuests(), unlisted(), private(), withoutWaitlist(), published(), postponed(), rescheduled(), cancelled(), completed(), inPhase(EventPhase $phase, ?string $stamp = null), categorized(string), labelled(array)` |
| `Venue::factory()` | `withoutCoordinates(), at(float $latitude, float $longitude), ownedBy(Model $owner)` |
| `AdmissionTier::factory()` | `key(string), price(Money), capacity(?int), hidden()` |
| `Rsvp::factory()` | `by(Model $attendee), status(RsvpStatus)` |
| `EventHost::factory() · EventInvitation::factory()` | `host(Model) · invitee(Model), status(InvitationStatus)` |
| `ReminderDelivery::factory()` | — |

Factory states write rows directly — no seats, counters, codes or notices — so use the facade when a test needs the real effects. The swappable models’ factories build your configured subclass.

## Laravel’s Event facade and lifecycle’s fake

```php
use Illuminate\Support\Facades\Event as EventBus;
use RoundlyConsulting\Events\Events\EventPublished;

EventBus::fake([EventPublished::class]);   // fake only what you assert — the lifecycle bridge keeps working
// never EventBus::fake() bare, and never Lifecycles::fake() in an events test
```

- The package’s own effects run on lifecycle’s events and its own domain events. Fake only the events you assert: Event::fake(\[EventPublished::class\]) keeps the lifecycle bridge and the cancellation effects working, while a bare Event::fake() silences every listener — no sequence bump, no holds released, no notices.
- Never combine Events::fake(), or any events test, with Lifecycles::fake(): lifecycle’s fake persists no transition, so statuses never change.
- Assert the notices with Notification::fake().

## Time and payments

```php
use Carbon\CarbonImmutable;

CarbonImmutable::setTestNow('2026-11-14 18:30:00');   // RSVP and check-in windows, holds, reminders, completion
config(['events.payments.collector' => FakeCollector::class]);   // your test double for paid ticket types
```

Freeze the clock: RSVP windows, check-in windows, holds, reminders and automatic completion all read it, and wall-clock strings are read in the event’s timezone. The payment collector is called after the commit — inside your own test transaction or a host transaction it runs when that transaction commits.

## 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/events-for-laravel.md)
