---
title: "The Tickets facade — Tickets for Laravel | Roundly"
description: "Tour the Tickets facade — make() and create(), the for($ticket) handle, categories, fields, tags, guests, projects, visibleTo() and sweep()."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/facade
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)

# The Tickets facade

Everything goes through one facade, RoundlyConsulting\\Tickets\\Facades\\Tickets (global alias Tickets), sugar over the TicketsManager singleton. A ticket from filing to resolution:

```php
use Illuminate\Support\Facades\Gate;
use RoundlyConsulting\Tickets\Facades\Tickets;

// Who is an agent is your call: is_support stands for your own agent flag. An undefined ability denies.
Gate::define('tickets.agent', fn ($user) => $user->is_support);

// A category, once: its defaults apply to every ticket filed under it.
$hardware = Tickets::categories()->make(['en' => 'Hardware', 'sk' => 'Hardvér'])
    ->defaults(priority: 'high', dueIn: 'P1D')
    ->create();

// The customer opens a ticket — numbered TCK-1, high priority, due in a day.
$ticket = Tickets::make()
    ->subject('Printer on floor 3 is jammed')
    ->description('Paper jam error E-102 since this morning.')
    ->requester($customer)
    ->category($hardware)
    ->create();

// An agent takes it, answers the customer and leaves a note only agents see.
Tickets::for($ticket)->by($agent)->claim()->start();
Tickets::for($ticket)->by($agent)->reply('A technician is on the way.', transition: 'wait');
Tickets::for($ticket)->by($agent)->note('Roller worn out, order a spare.');

// The customer's reply puts it back in the queue; the agent resolves it.
Tickets::for($ticket)->by($customer)->reply('Works again, thanks!');
Tickets::for('TCK-1')->by($agent)->resolve('Roller replaced.');

Tickets::for('TCK-1')->status();   // TicketStatus::Resolved
Tickets::for('TCK-1')->thread();   // the two public replies — never the note
```

## Manager methods

| Method | Returns | Does |
| --- | --- | --- |
| `make()` | `TicketBuilder` | Fluent builder for a new ticket. |
| `create(CreateTicketData $data)` | `Ticket` | The same code path as the builder. |
| `find(string $reference, bool $withTrashed = false)` | `?Ticket` | A live ticket by reference (SUP-42); with withTrashed, a soft-deleted one too. |
| `findOrFail(string $reference, bool $withTrashed = false)` | `Ticket` | Else TicketNotFoundException. |
| `for(Ticket\|string $ticket)` | `TicketHandle` | By model or reference; an unknown or soft-deleted reference throws TicketNotFoundException. |
| `visibleTo(Model $user)` | `Builder<Ticket>` | The tickets the policy’s view allows the user (or guest). |
| `project(Model&TicketProject $project)` | `ProjectTickets` | One project’s tickets, categories, fields and tags. |
| `categories()` | `Accessors\Categories` | The category tree and its defaults. |
| `fields()` | `Accessors\Fields` | Custom field definitions. |
| `tags()` | `Accessors\Tags` | Global and project tags. |
| `guests()` | `Accessors\Guests` | Guest requesters — resolve, find, update, convert, links. |
| `sweep()` | `SweepResult` | The due-date sweep behind tickets:sweep. |
| `Tickets::fake()` | `TicketsFake` | Facade only — the recording, still-performing fake (see Testing). |

perform() is public on the manager too, but internal: it is the single funnel every handle, sub-accessor, builder and model method runs its action through, and the hook the fake records with. Don’t call it from host code.

## The for($ticket) handle

Tickets::for($ticket) returns an immutable TicketHandle. Mutators return the handle so you can chain them; creators and queries return their result. The bound Ticket is updated in place, so a chained call sees the previous one’s effect:

```php
Tickets::for('SUP-42')->by($agent)->claim()->prioritize('urgent')->due(now()->addHours(4))->wait();
```

| Method | Returns | Does |
| --- | --- | --- |
| `by(?Model $actor) / asSystem()` | `TicketHandle` | A new handle that attributes the following calls; by(null) = the authenticated user, asSystem() = nobody. |
| `ticket()` | `Ticket` | The bound instance, updated in place by each call (not re-read). |
| `update(UpdateTicketData $data)` | `TicketHandle` | Subject, description, priority, category, due date, requester, host meta — only what is given. |
| `prioritize(BackedEnum\|string $priority)` | `TicketHandle` | Change the priority. |
| `categorize(TicketCategory\|int\|null $category)` | `TicketHandle` | Category defaults are not re-applied; newly required fields need a value. |
| `due(?CarbonInterface $at)` | `TicketHandle` | Set, move or clear; moving re-arms the overdue and due-soon flags. |
| `changeRequester(Model\|GuestData $requester)` | `TicketHandle` | The previous requester’s automatic watch goes; a previous guest requester’s links are revoked. |
| `assign(Model $assignee) / unassign() / claim()` | `TicketHandle` | Assignment — see Assignment and watchers. |
| `transition(string $name, ?string $reason = null, array $payload = [])` | `TransitionResult` | Any transition of the configured lifecycle (lifecycle’s result). |
| `start(), wait(), hold($reason), resume(), resolve($resolution), reopen($reason), close($reason), dismiss($reason)` | `TicketHandle` | The shipped lifecycle’s transitions. |
| `can(string $transition)` | `bool` | Allowed now for this actor. |
| `allowedTransitions()` | `list<string>` | Every transition allowed now. |
| `status()` | `TicketState` | The current state. |
| `reply(string $body, ?string $transition = null, array $attachments = [], ?string $channel = null, ?string $reason = null, array $payload = [], array $meta = [])` | `Comment` | Public reply; a transition is applied atomically — and only if the reply publishes. Attachments are uploads or local paths. |
| `note(string $body, array $attachments = [], ?string $channel = null, array $meta = [])` | `Comment` | Internal note — agents for this ticket and the system only. |
| `editComment(Comment $comment, string $body)` | `Comment` | Recorded as comment\_edited; another ticket’s comment throws TicketCommentNotFoundException. |
| `deleteComment(Comment $comment)` | `TicketHandle` | Recorded as comment\_deleted. |
| `thread(bool $internal = false)` | `Collection<int, Comment>` | Oldest first; public only, or every message. |
| `activity(bool $internal = false)` | `Collection<int, TicketActivity>` | Oldest first; public entries only, or every entry — the same safe default as thread(). |
| `fields() / tags() / watchers() / links() / attachments()` | `TicketFields / TicketTags / …` | Sub-accessors — below. |
| `mergeInto(Ticket\|string $target, ?string $reason = null)` | `MergeResult` | Merge this ticket into the target (see Merging tickets). |
| `guestLink(?Model $guest = null)` | `string` | For the guest requester, or a guest watcher while watchers.can\_view is on; anyone else throws GuestAccessDeniedException. |
| `revokeGuestAccess()` | `TicketHandle` | Void every link issued so far. |
| `delete() / restore()` | `TicketHandle` | Soft delete / restore. |

## Sub-accessors

| Accessor | Methods |
| --- | --- |
| `fields() → TicketFields` | `set($key, $value), setMany(array), forget(array\|string), get($key), all(), definitions(), ticket()` |
| `tags() → TicketTags` | `attach($tags), detach($tags), sync(array), all(), ticket()` |
| `watchers() → TicketWatchers` | `add($model), remove($model), watch(), unwatch(), has($model), all(), ticket()` |
| `links() → TicketLinks` | `add($other, LinkType), remove($other, LinkType), all(): list<TicketLinkView>, ticket()` |
| `attachments() → TicketAttachments` | `add($file): Media, remove(Media\|string $media), all(), ticket()` |

Sub-accessor mutators return the sub-accessor, so they chain within their area: ->tags()->attach(\['vip'\])->detach(\['spam'\]). Every mutation of a soft-deleted ticket throws TicketTrashedException (lifecycle refuses its transitions with SubjectTrashedException).

Scoping is a security boundary. A handle refuses what is not its ticket’s: another ticket’s comment, a link this ticket is not part of, another owner’s media, a guest link for someone who is neither the guest requester nor a guest watcher. ProjectTickets::for() refuses a ticket of another project.

## Tickets::categories()

| Method | Returns | Does |
| --- | --- | --- |
| `make(array\|string $name)` | `CategoryBuilder` | description(), project(), under(), position(), active(), inactive(), defaults(priority:, assignee:, dueIn:), data(), create(). |
| `create(CategoryData $data)` | `TicketCategory` | The DTO form of the builder. |
| `update(TicketCategory $category, UpdateCategoryData $data)` | `TicketCategory` | Only the given fields; no parent (use move()) and no project (fixed). |
| `move(TicketCategory $category, TicketCategory\|int\|null $parent, ?int $position = null)` | `TicketCategory` | A null parent moves it to the root. |
| `delete(TicketCategory $category, TicketCategory\|int\|null $reassignTo = null)` | `TicketCategory` | Soft delete; reassignTo is needed while live tickets use it. |
| `restore(TicketCategory $category)` | `TicketCategory` | Bring it back as it was. |
| `find(int $id)` | `?TicketCategory` | By id. |
| `tree(?Model $project = null)` | `Collection` | Roots with children loaded — the global tree plus the project’s. |

A string name is the current locale’s; an array is a locale map (\['en' => 'Hardware', 'sk' => 'Hardvér'\]).

## Tickets::fields()

| Method | Returns | Does |
| --- | --- | --- |
| `make(string $key, FieldType $type)` | `FieldBuilder` | label(), help(), options(), rules(), required(), encrypted(), default(), requesterVisible(), requesterEditable(), agentOnly(), position(), active(), inactive(), project(), category(), data(), define(). |
| `define(FieldData $data)` | `TicketField` | The DTO form of the builder. |
| `update(TicketField $field, UpdateFieldData $data)` | `TicketField` | Only the given attributes. |
| `delete(TicketField $field) / restore(TicketField $field)` | `TicketField` | A soft delete keeps the values hidden; a restore brings them back. |
| `prune(bool $dryRun = false, int $chunk = 200)` | `int` | Orphaned values forgotten (or counted). |
| `find(int\|string $idOrKey, ?Model $project = null)` | `?TicketField` | A live field by id or key: with a project its field or the global one; without, the global one. |
| `for(Ticket\|TicketCategory $subject, ?Model $project = null)` | `array<string, FieldDefinition>` | The applicable set, keyed by field key, in order. |

## Tickets::tags()

| Method | Returns | Does |
| --- | --- | --- |
| `create(TagData\|string $name, ?string $color = null, ?Model $project = null)` | `TicketTag` | Create a tag (re-creating a deleted name restores it, on no ticket). |
| `findOrCreate(string $name, ?Model $project = null)` | `TicketTag` | Concurrent calls yield one tag. |
| `rename(TicketTag $tag, string $name) / recolor(TicketTag $tag, ?string $color)` | `TicketTag` | The slug follows renames. |
| `update(TicketTag $tag, UpdateTagData $data)` | `TicketTag` | Name and colour. |
| `delete(TicketTag $tag)` | `TicketTag` | Returns the trashed tag and takes it off every ticket. |
| `find(int\|string $idOrSlug, ?Model $project = null)` | `?TicketTag` | A live tag by id or slug (a name is slugified): the project’s, else the global one. |
| `all(?Model $project = null)` | `Collection` | The global tags plus the project’s, by name. |

## Tickets::guests()

| Method | Returns | Does |
| --- | --- | --- |
| `resolve(GuestData\|array $fields)` | `TicketGuest` | Find or create a guest from their fields — validated and normalised. |
| `find(GuestData\|array $fields)` | `?TicketGuest` | A read-only match by normalised identifying value — never creates, fills or validates; not recorded by the fake. |
| `update(TicketGuest $guest, GuestData\|array $fields)` | `TicketGuest` | Change a guest’s fields. |
| `convert(TicketGuest $guest, Model $user)` | `int` | Move every ticket and watch to a registered user; returns the tickets moved. |
| `linkFor(Ticket\|string $ticket, ?TicketGuest $guest = null)` | `string` | The same as Tickets::for($ticket)->guestLink(). |

## Tickets::project($project)

| Method | Returns | Does |
| --- | --- | --- |
| `project()` | `Model&TicketProject` | The project. |
| `make()` | `TicketBuilder` | A builder already in the project. |
| `tickets()` | `Builder` | The project’s tickets as a query. |
| `find(string $reference)` | `?Ticket` | A ticket of this project. |
| `for(Ticket\|string $ticket)` | `TicketHandle` | A ticket of another project throws. |
| `categories() / tags()` | `Collection` | Global plus the project’s. |
| `fields(TicketCategory\|int\|null $category = null)` | `array` | The field set a ticket of this project (and category) gets. |

## Model traits

The traits delegate to the manager, so everything they do is recorded by the fake too:

| Trait | For | Adds |
| --- | --- | --- |
| `Concerns\RequestsTickets` | the host user | `requestedTickets(), assignedTickets(), watchedTickets(), visibleTickets(), openTicket(): TicketBuilder` |
| `Concerns\HasTickets` | the host project (implements TicketProject) | `tickets(), ticketCategories(), ticketFields(), ticketTags(), ticketing(): ProjectTickets, openTicket()` |
| `Concerns\IsTicketAssignee` | any assignee | `ticketAssigneeName(), ticketNotifiables(), isEligibleTicketAssignee(Ticket)` |
| `Concerns\HasConfigurableKey` | the package’s Ticket and TicketGuest (keep it on a subclass) | `configuredKeyType(): KeyType, newUniqueId()` |

## Data objects

Every DTO is final readonly:

| DTO | Fields |
| --- | --- |
| `CreateTicketData` | subject, requester (Model\|GuestData), ?description, ?category, ?priority, ?project, ?assignee, ?dueAt, ?channel, ?actor, system, fields, tags, watchers, attachments (uploads or local paths), meta (host-owned). |
| `UpdateTicketData` | subject, description, priority, category, dueAt, requester, meta (replaces the whole map; \[\] clears) — each defaults to the Support\\Optional sentinel (not given), so null can clear. |
| `GuestData` | fields: array<string, scalar\|null>. |
| `CategoryData / UpdateCategoryData` | name, ?description, ?parent, ?project, position, active, ?defaultPriority, ?defaultAssignee, ?defaultDueIn (CarbonInterval\|string). The update form has each field Optional, and no parent or project. |
| `FieldData / UpdateFieldData` | key, type, label, ?help, options, rules, required, encrypted, default, requesterVisible, requesterEditable, position, active, ?project, ?category. The update form has each Optional — key, project and category are fixed. |
| `TagData / UpdateTagData` | name, ?color, ?project / name, color (Optional). |
| `TransitionData, ReplyData` | transition, ?reason, payload / body, attachments, ?channel, meta. |
| `MergeResult` | source, target, comments (moved), attachments (moved). |
| `TicketLinkView` | link (id), ticket (the other end), type, label, direction (outgoing / incoming). |
| `SweepResult` | overdue, dueSoon — tickets newly flagged by this sweep; total(): int adds them up. |
| `FieldDefinition` | A resolved field: id, key, type, label (locale map), options, rules, required, encrypted, default, requesterVisible, requesterEditable, categoryId; label(?string $locale), attributeDefinition(), validationRules(string $prefix). |

## The PHP API trusts its caller

The facade enforces the domain rules — a requester may not post a note, only an eligible assignee is assigned, a Closed ticket takes no message, merging needs the merge ability, the agent scope decides who is an agent for a ticket — but it never asks TicketPolicy. The policy is the authority of the HTTP API; a host calling the facade decides who may call what.

## 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)
