---
title: "Testing — Tickets for Laravel | Roundly"
description: "Tickets::fake() records every operation and still performs it — every assertion and its negative twin, recorded operations, factories and app setup."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/testing
language: en
---

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

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

# Testing

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

```php
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):

```php
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:

```php
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:

| Operation | Assertion | Negative |
| --- | --- | --- |
| anything | — | `assertNothingHappened()` |
| create | `assertCreated(?Closure $callback)` | `assertNothingCreated()` |
| update | `assertUpdated($ticket)` | `assertNothingUpdated()` |
| priority | `assertPrioritized($ticket, $priority)` | `assertNothingPrioritized()` |
| category | `assertCategorized($ticket, $category)` | `assertNothingCategorized()` |
| due date | `assertDueSet($ticket)` | `assertNoDueSet()` |
| requester | `assertRequesterChanged($ticket)` | `assertNoRequesterChanged()` |
| delete / restore | `assertDeleted($ticket), assertRestored($ticket)` | `assertNothingDeleted(), assertNothingRestored()` |
| transition (also a published reply’s transition:) | `assertTransitioned($ticket, ?string $transition)` | `assertNothingTransitioned()` |
| reply / note | `assertReplied($ticket, AuthorRole\|string\|null $role), assertNoted($ticket)` | `assertNothingReplied(), assertNothingNoted()` |
| message edit / delete | `assertCommentUpdated(?Comment), assertCommentDeleted(?Comment)` | `assertNoCommentUpdated(), assertNoCommentDeleted()` |
| assign / unassign / claim | `assertAssigned($ticket, ?Model $assignee), assertUnassigned($ticket), assertClaimed($ticket)` | `assertNothingAssigned(), assertNothingUnassigned(), assertNothingClaimed()` |
| watcher added / removed | `assertWatched($ticket, ?Model $watcher), assertUnwatched($ticket, ?Model $watcher)` | `assertNothingWatched(), assertNothingUnwatched()` |
| tags attached / detached / synced | `assertTagged($ticket, ?string $tag), assertUntagged(…), assertTagsSynced(…)` | `assertNothingTagged(), assertNothingUntagged(), assertNoTagsSynced()` |
| link / unlink | `assertLinked($ticket, ?LinkType $type), assertUnlinked(…)` | `assertNothingLinked(), assertNothingUnlinked()` |
| merge | `assertMerged($source, $target)` | `assertNothingMerged()` |
| attachment added / removed | `assertAttached($ticket), assertAttachmentRemoved($ticket)` | `assertNothingAttached(), assertNoAttachmentRemoved()` |
| field values set / forgotten | `assertFieldsSet($ticket, ?string $key), assertFieldsForgotten($ticket, ?string $key)` | `assertNoFieldsSet(), assertNoFieldsForgotten()` |
| category created / updated / moved / deleted / restored | `assertCategoryCreated(?Closure), assertCategoryUpdated(?TicketCategory), assertCategoryMoved(…), assertCategoryDeleted(…), assertCategoryRestored(…)` | `assertNoCategoryCreated(), assertNoCategoryUpdated(), assertNoCategoryMoved(), assertNoCategoryDeleted(), assertNoCategoryRestored()` |
| field defined / updated / deleted / restored | `assertFieldDefined(?string $key), assertFieldUpdated(…), assertFieldDeleted(…), assertFieldRestored(…)` | `assertNoFieldDefined(), assertNoFieldUpdated(), assertNoFieldDeleted(), assertNoFieldRestored()` |
| prune | `assertFieldsPruned(?bool $dryRun, ?int $chunk)` | `assertNoFieldsPruned()` |
| tag created (also findOrCreate) / updated / deleted | `assertTagCreated(?string $name), assertTagUpdated(?TicketTag), assertTagDeleted(?TicketTag)` | `assertNoTagCreated(), assertNoTagUpdated(), assertNoTagDeleted()` |
| guest resolved / updated / converted | `assertGuestResolved(?Closure), assertGuestUpdated(?TicketGuest), assertGuestConverted(?TicketGuest)` | `assertNoGuestResolved(), assertNoGuestUpdated(), assertNoGuestConverted()` |
| guest link / access revoked | `assertGuestLinkIssued($ticket, ?TicketGuest $guest), assertGuestAccessRevoked($ticket)` | `assertNoGuestLinkIssued(), assertNoGuestAccessRevoked()` |
| sweep | `assertSwept(?Closure $callback)` | `assertNothingSwept()` |

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

```php
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.

```xml
<!-- 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](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/tickets-for-laravel.md)
