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

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

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:

OperationAssertionNegative
CreateassertCreated(?Closure)assertNothingCreated()
UpdateassertUpdated(?$event, ?array $keys)assertNothingUpdated()
Delete · RestoreassertDeleted(?$event), assertRestored(?$event)assertNothingDeleted(), assertNothingRestored()
Publish · Unpublish · Postpone · Reschedule · Cancel · CompleteassertPublished(?$event), assertUnpublished(), assertPostponed(), assertRescheduled(), assertCancelled(), assertCompleted()assertNothingPublished(), assertNothingUnpublished(), assertNothingPostponed(), assertNothingRescheduled(), assertNothingCancelled(), assertNothingCompleted()
TransitionassertTransitioned(?$event, ?string $name)assertNothingTransitioned()
MoveOnlineassertMovedOnline(?$event)assertNothingMovedOnline()
TransferOrganizerassertTransferred(?$event, ?Model $to)assertNothingTransferred()
SetPosterassertPosterChanged(?$event, ?bool $removed)assertNoPosterChanged()
CategorizeassertCategorized(?$event, ?$category)assertNothingCategorized()
AttachLabels · SyncLabelsassertLabelled(?$event, ?array $labels)assertNothingLabelled()
DetachLabelsassertUnlabelled(?$event, ?array $labels)assertNothingUnlabelled()
CreateVenue · UpdateVenue · DeleteVenue · RestoreVenue · GeocodeVenueassertVenueCreated(?Closure), assertVenueUpdated(), assertVenueDeleted(), assertVenueRestored(), assertVenueGeocoded()assertNoVenueCreated(), assertNoVenueUpdated(), assertNoVenueDeleted(), assertNoVenueRestored(), assertNoVenueGeocoded()
CreateTier · UpdateTier · DeleteTier · RestoreTierassertTierCreated(?Closure), assertTierUpdated(), assertTierDeleted(), assertTierRestored()assertNoTierCreated(), assertNoTierUpdated(), assertNoTierDeleted(), assertNoTierRestored()
ReorderTiersassertTiersReordered(?$event)assertNoTiersReordered()
RsvpassertRsvped(?$event, ?$attendee, ?RsvpStatus)assertNothingRsvped()
AddAttendee · RemoveAttendeeassertAttendeeAdded(?$event, ?$attendee), assertAttendeeRemoved(?$event, ?$attendee)assertNoAttendeeAdded(), assertNoAttendeeRemoved()
ChangeTier · ChangeGuestsassertTierChanged(?$event), assertGuestsChanged(?$event)assertNoTierChanged(), assertNoGuestsChanged()
ConfirmRsvp · ReleaseHold · RequestPayment · RotateCodeassertRsvpConfirmed(?$event), assertHoldReleased(?$event), assertPaymentRequested(?$event), assertCodeRotated(?$event)assertNoRsvpConfirmed(), assertNoHoldReleased(), assertNoPaymentRequested(), assertNoCodeRotated()
ConfirmPaymentassertPaymentConfirmed(?string $reference)assertNoPaymentConfirmed()
PromoteWaitlistassertPromoted(?$event)assertNothingPromoted()
ForgetAttendeeassertAttendeeForgotten(?$attendee)assertNoAttendeeForgotten()
CheckIn · ManualCheckInassertCheckedIn(?$event, ?$attendee)assertNothingCheckedIn()
UndoCheckInassertCheckInUndone(?$event)assertNoCheckInUndone()
AddCoHost · RemoveCoHostassertCoHostAdded(?$event, ?$host), assertCoHostRemoved(?$event, ?$host)assertNoCoHostAdded(), assertNoCoHostRemoved()
SendInvitation · RevokeInvitationassertInvited(?$event, ?$invitee), assertInvitationRevoked(?$event, ?$invitee)assertNothingInvited(), assertNoInvitationRevoked()
RespondToInvitationassertInvitationAnswered(?$event, ?RsvpResponse)assertNoInvitationAnswered()
Sweep · RepairassertSwept(), assertRepaired()assertNothingSwept(), assertNothingRepaired()
anyassertPerformed(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):

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

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

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

Factories

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
FactoryStates
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

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

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

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.