Appointments::fake() swaps in a recording AppointmentsFake — behind the facade and in the container, so an injected AppointmentManager is faked too — and returns it. Operations still run against the database: availability checks, participants() reads and the package events keep working. Every write is recorded, whether it came through the facade, an injected manager, the builder, a for() handle or a model shortcut such as $appointment->cancel():
use RoundlyConsulting\Appointments\Enums\Status;
use RoundlyConsulting\Appointments\Facades\Appointments;
use RoundlyConsulting\Appointments\Models\Appointment;
$fake = Appointments::fake();
$this->post('/bookings', [...]);
$fake->assertScheduled(fn (Appointment $appointment) => $appointment->name === 'Consultation');
$fake->assertTransitioned(fn (Appointment $appointment, Status $to, Status $from) => $to === Status::Cancelled);
$fake->assertRescheduled(fn (Appointment $appointment, $previousStartsAt) => true);
$fake->assertParticipantAdded(fn (Appointment $appointment, $participant, $row) => $participant->is($user));
$fake->assertParticipantRemoved(fn (Appointment $appointment, $participant) => $participant->is($user));
$fake->assertNothingScheduled();
$fake->assertNothingRescheduled();
$fake->assertNothingTransitioned();
$fake->assertNoParticipantAdded();
$fake->assertNoParticipantRemoved();Every assertion
| Assertion | Callback receives | Recorded by |
|---|---|---|
assertScheduled(?Closure) · assertNothingScheduled() | Appointment | create(), schedule()->create(), createRecurring() — one per occurrence. |
assertRescheduled(?Closure) · assertNothingRescheduled() | Appointment, CarbonImmutable $previousStartsAt | for()->reschedule(). |
assertTransitioned(?Closure) · assertNothingTransitioned() | Appointment, Status $to, Status $from | for()->transition() and its shortcuts, plus the model’s confirm(), cancel(), complete(), decline(), markNoShow() and transitionTo(). |
assertParticipantAdded(?Closure) · assertNoParticipantAdded() | Appointment, Model $participant, Participant $row | for()->participants()->add(). |
assertParticipantRemoved(?Closure) · assertNoParticipantRemoved() | Appointment, Model $participant — a removed row is recorded as its model | for()->participants()->remove(). |
- Every callback is optional — without one, the assertion passes when anything of that kind was recorded.
- Every assertion also works statically on the facade, e.g. Appointments::assertScheduled().
- Participants listed on a new appointment are part of its scheduling; only participants()->add() counts as added.
- Asking for the status an appointment already has is a no-op, so it isn’t recorded as a transition.
- A refused call — one that throws — isn’t recorded. Status changes made by the approvals sync listener aren’t host calls and aren’t recorded either.
- Actions called directly bypass the fake.
Appointments::fake();
Appointments::for($appointment)->cancel(); // or $appointment->cancel() — recorded either way
Appointments::assertTransitioned(fn (Appointment $appointment, Status $to) => $to === Status::Cancelled);
Appointments::assertNothingScheduled();Model factory
The bundled Appointment model has a factory — a pending appointment starting tomorrow with a 60-minute duration — plus a withStatus() state:
use RoundlyConsulting\Appointments\Enums\Status;
use RoundlyConsulting\Appointments\Models\Appointment;
$appointment = Appointment::factory()->create(); // pending, starts tomorrow, 60 minutes
$confirmed = Appointment::factory()
->withStatus(Status::Confirmed)
->create(['starts_at' => now()->addWeek()]);Example tests
Fake only the events you assert on, and exercise the same builder and helpers your app uses:
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Appointments\Enums\Status;
use RoundlyConsulting\Appointments\Events\AppointmentStatusChanged;
use RoundlyConsulting\Appointments\Exceptions\SchedulingConflictException;
use RoundlyConsulting\Appointments\Facades\Appointments;
use RoundlyConsulting\Appointments\Models\Appointment;
it('confirms a booking and announces it', function () {
Event::fake([AppointmentStatusChanged::class]);
$appointment = Appointment::factory()->create();
Appointments::for($appointment)->confirm();
expect($appointment->status)->toBe(Status::Confirmed);
Event::assertDispatched(
AppointmentStatusChanged::class,
fn (AppointmentStatusChanged $e) => $e->from === Status::Pending && $e->to === Status::Confirmed,
);
});
it('blocks a double booking', function () {
$doctor = User::factory()->create();
Appointments::schedule('First')->startingAt('2026-07-01 09:00')->lasting(60)
->withParticipant($doctor)->create();
expect(fn () => Appointments::schedule('Clash')->startingAt('2026-07-01 09:30')->lasting(60)
->withParticipant($doctor)->preventConflicts()->create()
)->toThrow(SchedulingConflictException::class);
});Run the suite with the published migrations loaded (RefreshDatabase) — the appointments tables plus the provider tables from Installation.
The package’s own suite
composer testShow 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 cryptoBy 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.