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

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 note

Manager methods

MethodReturnsDoes
make()TicketBuilderFluent builder for a new ticket.
create(CreateTicketData $data)TicketThe same code path as the builder.
find(string $reference, bool $withTrashed = false)?TicketA live ticket by reference (SUP-42); with withTrashed, a soft-deleted one too.
findOrFail(string $reference, bool $withTrashed = false)TicketElse TicketNotFoundException.
for(Ticket|string $ticket)TicketHandleBy 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)ProjectTicketsOne project’s tickets, categories, fields and tags.
categories()Accessors\CategoriesThe category tree and its defaults.
fields()Accessors\FieldsCustom field definitions.
tags()Accessors\TagsGlobal and project tags.
guests()Accessors\GuestsGuest requesters — resolve, find, update, convert, links.
sweep()SweepResultThe due-date sweep behind tickets:sweep.
Tickets::fake()TicketsFakeFacade 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();
MethodReturnsDoes
by(?Model $actor) / asSystem()TicketHandleA new handle that attributes the following calls; by(null) = the authenticated user, asSystem() = nobody.
ticket()TicketThe bound instance, updated in place by each call (not re-read).
update(UpdateTicketData $data)TicketHandleSubject, description, priority, category, due date, requester, host meta — only what is given.
prioritize(BackedEnum|string $priority)TicketHandleChange the priority.
categorize(TicketCategory|int|null $category)TicketHandleCategory defaults are not re-applied; newly required fields need a value.
due(?CarbonInterface $at)TicketHandleSet, move or clear; moving re-arms the overdue and due-soon flags.
changeRequester(Model|GuestData $requester)TicketHandleThe previous requester’s automatic watch goes; a previous guest requester’s links are revoked.
assign(Model $assignee) / unassign() / claim()TicketHandleAssignment — see Assignment and watchers.
transition(string $name, ?string $reason = null, array $payload = [])TransitionResultAny transition of the configured lifecycle (lifecycle’s result).
start(), wait(), hold($reason), resume(), resolve($resolution), reopen($reason), close($reason), dismiss($reason)TicketHandleThe shipped lifecycle’s transitions.
can(string $transition)boolAllowed now for this actor.
allowedTransitions()list<string>Every transition allowed now.
status()TicketStateThe current state.
reply(string $body, ?string $transition = null, array $attachments = [], ?string $channel = null, ?string $reason = null, array $payload = [], array $meta = [])CommentPublic 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 = [])CommentInternal note — agents for this ticket and the system only.
editComment(Comment $comment, string $body)CommentRecorded as comment_edited; another ticket’s comment throws TicketCommentNotFoundException.
deleteComment(Comment $comment)TicketHandleRecorded 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)MergeResultMerge this ticket into the target (see Merging tickets).
guestLink(?Model $guest = null)stringFor the guest requester, or a guest watcher while watchers.can_view is on; anyone else throws GuestAccessDeniedException.
revokeGuestAccess()TicketHandleVoid every link issued so far.
delete() / restore()TicketHandleSoft delete / restore.

Sub-accessors

AccessorMethods
fields() → TicketFieldsset($key, $value), setMany(array), forget(array|string), get($key), all(), definitions(), ticket()
tags() → TicketTagsattach($tags), detach($tags), sync(array), all(), ticket()
watchers() → TicketWatchersadd($model), remove($model), watch(), unwatch(), has($model), all(), ticket()
links() → TicketLinksadd($other, LinkType), remove($other, LinkType), all(): list<TicketLinkView>, ticket()
attachments() → TicketAttachmentsadd($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()

MethodReturnsDoes
make(array|string $name)CategoryBuilderdescription(), project(), under(), position(), active(), inactive(), defaults(priority:, assignee:, dueIn:), data(), create().
create(CategoryData $data)TicketCategoryThe DTO form of the builder.
update(TicketCategory $category, UpdateCategoryData $data)TicketCategoryOnly the given fields; no parent (use move()) and no project (fixed).
move(TicketCategory $category, TicketCategory|int|null $parent, ?int $position = null)TicketCategoryA null parent moves it to the root.
delete(TicketCategory $category, TicketCategory|int|null $reassignTo = null)TicketCategorySoft delete; reassignTo is needed while live tickets use it.
restore(TicketCategory $category)TicketCategoryBring it back as it was.
find(int $id)?TicketCategoryBy id.
tree(?Model $project = null)CollectionRoots 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()

MethodReturnsDoes
make(string $key, FieldType $type)FieldBuilderlabel(), help(), options(), rules(), required(), encrypted(), default(), requesterVisible(), requesterEditable(), agentOnly(), position(), active(), inactive(), project(), category(), data(), define().
define(FieldData $data)TicketFieldThe DTO form of the builder.
update(TicketField $field, UpdateFieldData $data)TicketFieldOnly the given attributes.
delete(TicketField $field) / restore(TicketField $field)TicketFieldA soft delete keeps the values hidden; a restore brings them back.
prune(bool $dryRun = false, int $chunk = 200)intOrphaned values forgotten (or counted).
find(int|string $idOrKey, ?Model $project = null)?TicketFieldA 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()

MethodReturnsDoes
create(TagData|string $name, ?string $color = null, ?Model $project = null)TicketTagCreate a tag (re-creating a deleted name restores it, on no ticket).
findOrCreate(string $name, ?Model $project = null)TicketTagConcurrent calls yield one tag.
rename(TicketTag $tag, string $name) / recolor(TicketTag $tag, ?string $color)TicketTagThe slug follows renames.
update(TicketTag $tag, UpdateTagData $data)TicketTagName and colour.
delete(TicketTag $tag)TicketTagReturns the trashed tag and takes it off every ticket.
find(int|string $idOrSlug, ?Model $project = null)?TicketTagA live tag by id or slug (a name is slugified): the project’s, else the global one.
all(?Model $project = null)CollectionThe global tags plus the project’s, by name.

Tickets::guests()

MethodReturnsDoes
resolve(GuestData|array $fields)TicketGuestFind or create a guest from their fields — validated and normalised.
find(GuestData|array $fields)?TicketGuestA read-only match by normalised identifying value — never creates, fills or validates; not recorded by the fake.
update(TicketGuest $guest, GuestData|array $fields)TicketGuestChange a guest’s fields.
convert(TicketGuest $guest, Model $user)intMove every ticket and watch to a registered user; returns the tickets moved.
linkFor(Ticket|string $ticket, ?TicketGuest $guest = null)stringThe same as Tickets::for($ticket)->guestLink().

Tickets::project($project)

MethodReturnsDoes
project()Model&TicketProjectThe project.
make()TicketBuilderA builder already in the project.
tickets()BuilderThe project’s tickets as a query.
find(string $reference)?TicketA ticket of this project.
for(Ticket|string $ticket)TicketHandleA ticket of another project throws.
categories() / tags()CollectionGlobal plus the project’s.
fields(TicketCategory|int|null $category = null)arrayThe 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:

TraitForAdds
Concerns\RequestsTicketsthe host userrequestedTickets(), assignedTickets(), watchedTickets(), visibleTickets(), openTicket(): TicketBuilder
Concerns\HasTicketsthe host project (implements TicketProject)tickets(), ticketCategories(), ticketFields(), ticketTags(), ticketing(): ProjectTickets, openTicket()
Concerns\IsTicketAssigneeany assigneeticketAssigneeName(), ticketNotifiables(), isEligibleTicketAssignee(Ticket)
Concerns\HasConfigurableKeythe package’s Ticket and TicketGuest (keep it on a subclass)configuredKeyType(): KeyType, newUniqueId()

Data objects

Every DTO is final readonly:

DTOFields
CreateTicketDatasubject, requester (Model|GuestData), ?description, ?category, ?priority, ?project, ?assignee, ?dueAt, ?channel, ?actor, system, fields, tags, watchers, attachments (uploads or local paths), meta (host-owned).
UpdateTicketDatasubject, description, priority, category, dueAt, requester, meta (replaces the whole map; [] clears) — each defaults to the Support\Optional sentinel (not given), so null can clear.
GuestDatafields: array<string, scalar|null>.
CategoryData / UpdateCategoryDataname, ?description, ?parent, ?project, position, active, ?defaultPriority, ?defaultAssignee, ?defaultDueIn (CarbonInterval|string). The update form has each field Optional, and no parent or project.
FieldData / UpdateFieldDatakey, 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 / UpdateTagDataname, ?color, ?project / name, color (Optional).
TransitionData, ReplyDatatransition, ?reason, payload / body, attachments, ?channel, meta.
MergeResultsource, target, comments (moved), attachments (moved).
TicketLinkViewlink (id), ticket (the other end), type, label, direction (outgoing / incoming).
SweepResultoverdue, dueSoon — tickets newly flagged by this sweep; total(): int adds them up.
FieldDefinitionA 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

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.