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

Tickets::fake() swaps the manager for a TicketsFake and returns it:

use RoundlyConsulting\Tickets\Facades\Tickets;

$fake = Tickets::fake();

// … exercise your app …

$fake->assertCreated(fn (Ticket $ticket) => $ticket->subject === 'Printer jammed');
$fake->assertReplied('TCK-1', role: 'agent');
$fake->assertTransitioned('TCK-1', 'resolve');
$fake->assertNothingMerged();

// …or straight on the facade — every assertion is documented there, so the IDE completes it
Tickets::assertAssigned('TCK-1', $agent);
  • It is a recording, still-performing fake: every call runs for real — rows, events, lifecycle, policies, notifications — and each successful operation is recorded. A call that throws records nothing.
  • It is swapped in behind the facade and in the container, so an injected TicketsManager is faked too. It records the facade, an injected manager, a handle, a sub-accessor, a builder or a model method ($ticket->assignTo(), $user->openTicket()); calling an action class directly is not recorded.
  • Because it performs, a test using the fake needs the database (the published migrations) like any other.
  • It does not fake Laravel’s events or notifications — use Event::fake() / Notification::fake() next to it.

Recorded operations

$fake->recorded(?TicketOperation $operation = null) — or Tickets::recorded() — returns the raw RecordedTicketOperation records: operation, context and result. The context carries the handle’s actor and system flag plus the call’s own arguments (data, priority, transition, assignee, tags, …). Each record has ticket(), concerns(Ticket|string|null $ticket) and model(string $key):

use RoundlyConsulting\Tickets\Enums\TicketOperation;

$replies = Tickets::recorded(TicketOperation::Reply);
expect($replies)->toHaveCount(1)
    ->and($replies[0]->concerns('TCK-1'))->toBeTrue()
    ->and($replies[0]->context['data']->body)->toContain('technician');

Naming

  • assert<Verb>() pairs with assertNothing<Verb>() — assertCreated() / assertNothingCreated().
  • assert<Noun…><Verb>() (two or more words) pairs with assertNo<Noun…><Verb>() — assertTagsSynced() / assertNoTagsSynced(), assertDueSet() / assertNoDueSet().
  • assertNothingHappened() stands alone.

Transitions through a reply

assertTransitioned() counts an explicit transition (transition(), resolve(), …) and a published reply’s transition:. A held reply’s transition: applied nothing, so it does not count; neither does an automation, which is no call of yours:

Tickets::for($ticket)->by($agent)->reply('Please send a photo.', transition: 'wait');

$fake->assertTransitioned($ticket, 'wait');   // passes — unless the reply was held

Assertions

A $ticket argument takes a model or a reference; null means any. Every assertion is documented on the facade too, so Tickets::assertAssigned(…) works:

OperationAssertionNegative
anything—assertNothingHappened()
createassertCreated(?Closure $callback)assertNothingCreated()
updateassertUpdated($ticket)assertNothingUpdated()
priorityassertPrioritized($ticket, $priority)assertNothingPrioritized()
categoryassertCategorized($ticket, $category)assertNothingCategorized()
due dateassertDueSet($ticket)assertNoDueSet()
requesterassertRequesterChanged($ticket)assertNoRequesterChanged()
delete / restoreassertDeleted($ticket), assertRestored($ticket)assertNothingDeleted(), assertNothingRestored()
transition (also a published reply’s transition:)assertTransitioned($ticket, ?string $transition)assertNothingTransitioned()
reply / noteassertReplied($ticket, AuthorRole|string|null $role), assertNoted($ticket)assertNothingReplied(), assertNothingNoted()
message edit / deleteassertCommentUpdated(?Comment), assertCommentDeleted(?Comment)assertNoCommentUpdated(), assertNoCommentDeleted()
assign / unassign / claimassertAssigned($ticket, ?Model $assignee), assertUnassigned($ticket), assertClaimed($ticket)assertNothingAssigned(), assertNothingUnassigned(), assertNothingClaimed()
watcher added / removedassertWatched($ticket, ?Model $watcher), assertUnwatched($ticket, ?Model $watcher)assertNothingWatched(), assertNothingUnwatched()
tags attached / detached / syncedassertTagged($ticket, ?string $tag), assertUntagged(…), assertTagsSynced(…)assertNothingTagged(), assertNothingUntagged(), assertNoTagsSynced()
link / unlinkassertLinked($ticket, ?LinkType $type), assertUnlinked(…)assertNothingLinked(), assertNothingUnlinked()
mergeassertMerged($source, $target)assertNothingMerged()
attachment added / removedassertAttached($ticket), assertAttachmentRemoved($ticket)assertNothingAttached(), assertNoAttachmentRemoved()
field values set / forgottenassertFieldsSet($ticket, ?string $key), assertFieldsForgotten($ticket, ?string $key)assertNoFieldsSet(), assertNoFieldsForgotten()
category created / updated / moved / deleted / restoredassertCategoryCreated(?Closure), assertCategoryUpdated(?TicketCategory), assertCategoryMoved(…), assertCategoryDeleted(…), assertCategoryRestored(…)assertNoCategoryCreated(), assertNoCategoryUpdated(), assertNoCategoryMoved(), assertNoCategoryDeleted(), assertNoCategoryRestored()
field defined / updated / deleted / restoredassertFieldDefined(?string $key), assertFieldUpdated(…), assertFieldDeleted(…), assertFieldRestored(…)assertNoFieldDefined(), assertNoFieldUpdated(), assertNoFieldDeleted(), assertNoFieldRestored()
pruneassertFieldsPruned(?bool $dryRun, ?int $chunk)assertNoFieldsPruned()
tag created (also findOrCreate) / updated / deletedassertTagCreated(?string $name), assertTagUpdated(?TicketTag), assertTagDeleted(?TicketTag)assertNoTagCreated(), assertNoTagUpdated(), assertNoTagDeleted()
guest resolved / updated / convertedassertGuestResolved(?Closure), assertGuestUpdated(?TicketGuest), assertGuestConverted(?TicketGuest)assertNoGuestResolved(), assertNoGuestUpdated(), assertNoGuestConverted()
guest link / access revokedassertGuestLinkIssued($ticket, ?TicketGuest $guest), assertGuestAccessRevoked($ticket)assertNoGuestLinkIssued(), assertNoGuestAccessRevoked()
sweepassertSwept(?Closure $callback)assertNothingSwept()
$fake->assertFieldsPruned(dryRun: true);             // a dry run happened, any chunk
$fake->assertFieldsPruned(dryRun: false, chunk: 500);
$fake->assertSwept(fn (SweepResult $result) => $result->overdue === 2);
$fake->assertGuestLinkIssued('TCK-3');

Links the package mints for its own guest notifications are not recorded — assertGuestLinkIssued sees only the links your code asked for. Reads are never recorded: find(), for(), Tickets::guests()->find() and the other lookups leave assertNothingHappened() passing.

Factories

Every model has one under RoundlyConsulting\Tickets\Database\Factories, and the five swappable ones build the configured class. TicketFactory numbers its rows outside every real sequence (scope factory, references FAC-n), defaults the requester to a new field-less TicketGuest, the channel to api and the priority to normal, and has the states requestedBy(), assignedTo(), inProject() and inStatus(); TicketCategoryFactory has inactive(), under() and inProject():

use RoundlyConsulting\Tickets\Models\Ticket;

// Numbered outside every real sequence (FAC-n); no activity, events, notifications or defaults
$ticket = Ticket::factory()->requestedBy($customer)->assignedTo($agent)->inStatus('pending')->create();

Factory rows skip the package’s actions, so file through the facade where a test needs activity, events, notifications or defaults. Resolve a guest with fields through Tickets::guests()->resolve().

Testing a host app

  • No special harness: the published migrations are ordinary host migrations, so RefreshDatabase / LazilyRefreshDatabase work on SQLite in memory as well as on MySQL or PostgreSQL.
  • Define the Gate abilities your tests rely on (tickets.agent, …) — undefined abilities deny.
  • Notifications are queued after commit: with the sync queue they send inside the request; use Notification::fake() to assert them.
  • Keep files out of your storage with Storage::fake('local') for the attachments disk.
  • The API is off unless TICKETS_ROUTES=on is set before the app boots — use on / off values in phpunit.xml, because PHPUnit turns false into an empty string, which means default.
<!-- phpunit.xml -->
<env name="TICKETS_ROUTES" value="on"/>

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.