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:
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 noteManager 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:
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 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.