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

Routes, form requests, controllers, role-aware resources, query-builder list filters, a policy, rate limits and guest endpoints — off until you switch them on:

TICKETS_ROUTES=on
  • Prefix tickets.routes.prefix (api), route names tickets.*, middleware tickets.routes.middleware (['api', 'auth', 'throttle:tickets-api']).
  • {ticket} is the reference ([A-Z][A-Z0-9]{0,9}-[0-9]+). Controllers look tickets up through the model seam, so the API works without SubstituteBindings and with a swapped ticket model. A soft-deleted ticket is 404, except on restore.
  • To change a route, publish routes/tickets.php (--tag=tickets-routes); the published copy replaces the package’s for good.

With Sanctum

// config/tickets.php
'routes' => [
    'enabled' => env('TICKETS_ROUTES') ?: 'off',
    'middleware' => ['api', 'auth:sanctum', 'throttle:tickets-api'],
    // …
],

Any guard works the same way (auth:api, …). Without an auth middleware every endpoint would answer anonymous callers — tickets:doctor warns.

Rate limits

Defined only with the routes on, applied only while their throttle: middleware is listed: tickets-api allows tickets.routes.rate_limit (120) requests a minute per signed-in user (per IP otherwise); tickets-guest allows tickets.routes.guest.rate_limit (30) a minute per IP and ticket.

Routes

Method & pathNameAbilityNotes
GET /ticketsindexviewAnyScoped by visibleTo; filters, sorts and paging below.
POST /ticketsstorecreate201
POST /tickets/bulkbulkper ticketBulk actions, below.
GET /tickets/{ticket}showviewDetailed: plus field values and attachments.
PATCH /tickets/{ticket}updateview + update (with the touched attributes)—
DELETE /tickets/{ticket}destroyview + deleteSoft; 204.
POST /tickets/{ticket}/restorerestoreview + restore—
GET /tickets/{ticket}/transitionstransitions.indexviewThe transitions this user may run now: name, label, to, to_label, requires_reason, payload.
POST /tickets/{ticket}/transitions/{transition}transitions.storeview + transition{reason?, payload?}
PUT /tickets/{ticket}/assigneeassignee.updateview + assign{assignee: {type, id}} | {claim: true}
DELETE /tickets/{ticket}/assigneeassignee.destroyview + unassign—
GET /tickets/{ticket}/commentscomments.indexview (+ viewInternal with ?internal=1)—
POST /tickets/{ticket}/repliesreplies.storereply (+ transition){body, channel?, attachments[]?, transition?, reason?, payload?}; 201 — also when the host’s moderation holds the reply, which then applies no transition.
POST /tickets/{ticket}/notesnotes.storeview + note{body, channel?, attachments[]?}; 201
PATCH /tickets/{ticket}/comments/{comment}comments.updateview + updateComment{body}; the comment must be this ticket’s; recorded as comment_edited.
DELETE /tickets/{ticket}/comments/{comment}comments.destroyview + deleteComment204; recorded as comment_deleted.
GET /tickets/{ticket}/fieldsfields.indexviewRole-filtered.
PUT /tickets/{ticket}/fieldsfields.updateview + update (fields){fields: {key: value}}; null clears a value.
GET /tickets/{ticket}/watcherswatchers.indexview + manageWatchersAgents only.
POST /tickets/{ticket}/watcherswatchers.storeview + manageWatchers{watcher: {type, id}}; 201
DELETE /tickets/{ticket}/watchers/{alias:key}watchers.destroyview + manageWatchers204
POST / DELETE /tickets/{ticket}/watchwatch / unwatchview + manageWatchers (self)204
PUT /tickets/{ticket}/tagstags.syncview + tag{tags: [names]}
GET /tickets/{ticket}/linkslinks.indexview + link—
POST /tickets/{ticket}/linkslinks.storeview + link on both tickets{ticket: reference, type}; 201
DELETE /tickets/{ticket}/links/{link}links.destroyview + link204; removes exactly that row, from either end.
POST /tickets/{ticket}/mergemergeview + merge on the source and on the target{target: reference, reason?}
GET /tickets/{ticket}/attachmentsattachments.indexviewShort-lived signed URLs.
POST /tickets/{ticket}/attachmentsattachments.storeview + update (attachments) — agentsMultipart file; 201.
GET /tickets/{ticket}/attachments/{media}attachments.showview—
DELETE /tickets/{ticket}/attachments/{media}attachments.destroyview + update (attachments)204
GET /tickets/{ticket}/activityactivityviewRole-filtered.
GET / POST /ticket-categoriesadmin.categories.index / .storeviewAny / manageCategoriesThe tree; ?project=alias:key; inactive categories (with their subtrees) for agents only.
GET / PATCH / DELETE /ticket-categories/{id}admin.categories.show / .update / .destroyviewAny / manageCategoriesAn inactive category is 404 category_not_found for non-agents; delete takes {reassign_to?}.
POST /ticket-categories/{id}/moveadmin.categories.movemanageCategories{parent_id, position?}
GET / POST /ticket-fieldsadmin.fields.index / .storeviewAny / manageFields?project=, ?category=; role-filtered.
GET / PATCH / DELETE /ticket-fields/{id}admin.fields.show / .update / .destroyviewAny / manageFields—
GET / POST /ticket-tagsadmin.tags.index / .storeviewTags (agents) / manageTags—
PATCH / DELETE /ticket-tags/{id}admin.tags.update / .destroymanageTags—
GET /tickets/guest/{ticket}guest.showsigned linkSee Guests and guest links.
POST /tickets/guest/{ticket}/repliesguest.replysigned link—
POST /tickets/guest/{ticket}/transitions/{transition}guest.transitionsigned linkRequester transitions only.
GET /tickets/guest/{ticket}/attachments/{media}guest.attachmentsigned linkThe ticket’s files and its public thread’s.

There is no category-restore endpoint — use Tickets::categories()->restore().

Filing a ticket

POST /api/tickets
{
  "subject": "Printer on floor 3 is jammed",
  "description": "Paper jam error E-102.",
  "category_id": 4,
  "priority": "high",
  "channel": "web",
  "fields": {"asset_tag": "PRN-0042"},
  "project": {"type": "project", "id": 1}
}

Self-service files for the signed-in user. subject (≤ 255), description, category_id, priority, channel, project and fields (requester-editable ones for a non-agent) are open to anyone who may file. requester, guest (fields, instead of a requester), assignee, due_at (after now), tags and watchers are agent-only inputs; naming an assignee other than yourself needs the assign ability. Morph inputs are {type: alias, id} with an alias from the allow-lists — a class name, an unknown alias or a key that matches no record is 422. Attachments are multipart attachments[].

Bulk

POST /api/tickets/bulk
{"action": "transition", "tickets": ["TCK-1", "TCK-2"], "transition": "resolve", "payload": {"resolution": "Fixed"}}

action is assign (assignee or claim: true), unassign, transition (transition, reason?, payload?), priority (priority) or tags (tags.attach / tags.detach, up to 50 names each) — at most tickets.routes.bulk.max (100) distinct references. Each ticket runs on its own policy check and transaction, and one failing never stops the others:

{"data": [
  {"ticket": "TCK-1", "ok": true, "status": 200},
  {"ticket": "TCK-2", "ok": false, "status": 422, "code": "ticket_transition_denied", "message": "…"}
]}

A per-ticket validation failure reports code validation_failed; every other failure carries the code of the error table (ticket_not_found for an unknown reference or one the user may not view). assign without assignee and without claim: true is a 422 for the whole request.

Request bodies

EndpointBody
PATCH /tickets/{ticket}Any of subject (≤ 255), description (≤ 65535, nullable), priority, category_id (nullable), due_at (after now, nullable), requester ({type, id}) — only what is sent changes; host meta is not writable through the API.
POST …/transitions/{transition}{reason?: string ≤ 5000, payload?: object}; {transition} matches [a-z][a-z0-9_]{0,63}.
POST …/replies{body: string ≤ 65535, channel?, attachments[]?, transition?, reason?, payload?}
POST …/notes, POST /tickets/guest/{ticket}/replies{body, channel?, attachments[]?}
PATCH …/comments/{comment}{body}
GET …/comments?internal=1 for the internal thread (agents).
PUT …/fields{fields: {key: value, …}} (present; null clears a value).
POST …/watchers{watcher: {type, id}}; DELETE …/watchers/{alias}:{key}.
PUT …/tags{tags: [name, …]} (present, ≤ 50, each ≤ 64) — replaces the set.
POST …/links{ticket: reference, type: relates_to | duplicates | blocks | parent_of}
POST …/merge{target: reference, reason?: string ≤ 5000}
POST …/attachmentsMultipart file.
GET /ticketsper_page (1 – max_per_page), page, filter[…], sort

Uploads (attachments[], file) follow the bucket’s own mime types and size cap through media-library — tickets.attachments.* for ticket files, comments’ comments.media.* for message files — at most 10 files per request.

Admin endpoints

Translatable text (name, description, label, help, an option label) is a string in the current locale or a locale map ({"en": "Hardware", "sk": "Hardvér"}). Morph inputs are {type: alias, id}; list filters take alias:key.

EndpointBody / query
GET /ticket-categories?project=alias:key — the global tree plus that project’s categories, roots first, children nested.
POST /ticket-categories{name, description?, parent_id?, project?: {type, id}, position?, is_active?, default_priority?, default_assignee?: {type, id}, default_due_in?: ISO-8601 (PT4H)}
PATCH /ticket-categories/{id}Any of name, description, position, is_active, default_priority, default_assignee, default_due_in (null clears description or a default); the parent moves through /move, the project never changes.
POST /ticket-categories/{id}/move{parent_id: id | null, position?} (parent_id must be present; null = to the root).
DELETE /ticket-categories/{id}{reassign_to?: id} — needed while live tickets use the category.
GET /ticket-fields?project=alias:key, ?category=id (the set a ticket of that category would get). Non-agents see active, requester-visible fields only.
POST /ticket-fields{key, type, label, help?, options?: {value: label}, rules?: [string], required?, encrypted?, default?, requester_visible?, requester_editable?, position?, is_active?, project?: {type, id}, category_id?} — at most 200 options and 20 rules.
PATCH /ticket-fields/{id}Any of type, label, help, options, rules, required, encrypted, default, requester_visible, requester_editable, position, is_active (key, project and category are fixed).
GET /ticket-tags?project=alias:key — the global tags plus the project’s, by name (agents).
POST /ticket-tags{name: string ≤ 64, color?: "#rrggbb", project?: {type, id}} — re-creating a deleted name restores it, on no ticket.
PATCH /ticket-tags/{id}Any of name, color (null clears it).

Resources

Role-aware: the viewer is an agent (the agent ability and the agent scope allow them on this ticket), the requester, a participant (a watcher) or a guest (through a signed link).

ResourceEveryone who may viewAgents also get
TicketResourcereference, number, subject, description, status, priority, category ({id, name, slug}), project ({type, id}), requester, assignee, channel, due_at, resolved_at, closed_at, merged_into (a reference), messages_count (public messages), created_at, updated_at; detailed responses add fields (requester-visible) and attachments.assigned_at, first_responded_at, last_activity_at, tags ({name, slug, color}), the requester in full, every message counted, every field.
TicketCommentResourceid, body, role, author, channel, attachments, created_at, updated_atinternal, status (comments’ moderation status).
TicketActivityResourceid, type, label, system, actor, properties (morph types as aliases), occurred_at — public entries only.visibility, internal entries, raw properties.
TicketLinkResourceid, type, label, direction (outgoing / incoming), ticket {reference, subject, status} — ticket {reference} only when the viewer may not view the other ticket.—
TicketAttachmentResourceid (uuid), name, mime_type, size, url, created_at—
TicketGuestResource{type: 'guest', name} — the name only for the guest themselves.{type: 'guest', id, name, fields} — every guest field; PII stays with agents.
TicketCategoryResourceid, name, description, slug, parent_id, project, position, is_active, childrentranslations {name, description} (the locale maps), defaults {priority, assignee, due_in}.
TicketFieldResourceid, key, type, label, help, options ([{value, label}], current locale), required, category_id, project, position.translations {label, help}, rules, default, encrypted, requester_visible, requester_editable, is_active
TicketTagResourceid, name, slug, color, project (only agents reach it).—

People (requester, assignee, author, actor, watcher) show as their alias from the allow-lists, never a class name: agents get {type, id, name}; anyone else {type, name}, with name: null for a guest who is not themselves. Requesters never see internal notes, held replies, internal activity, last_activity_at, tags or watcher lists, and a ticket’s host meta never appears in any resource. Enum values come with their translated label and colour, so a client renders a badge without its own lookup tables:

"status":   {"value": "pending", "label": "Waiting on requester", "category": "waiting", "color": "amber"},
"priority": {"value": "high", "label": "High", "weight": 30, "color": "orange"},
"channel":  {"value": "web", "label": "Web", "color": "blue"}

Response bodies

EndpointBody
GET /ticketsA paginated TicketResource collection (data, links, meta).
POST /tickets (201), GET / PATCH /tickets/{ticket}, POST …/restore, POST …/transitions/{transition}, PUT / DELETE …/assigneeThe detailed TicketResource.
GET …/transitions{data: [{name, label, to, to_label, requires_reason, payload}]}
GET …/commentsTicketCommentResource collection, oldest first.
POST …/replies, POST …/notes (201), PATCH …/comments/{comment}The TicketCommentResource.
GET / PUT …/fields{data: [{key, label, type, value}]} — the fields that apply, in order (requester-visible ones for non-agents); value is null when unset.
GET / POST …/watchers (201){data: [person, …]} — every watcher, oldest first; a non-agent adding themselves gets their own entry only.
PUT …/tagsThe ticket’s tags, a TicketTagResource collection.
GET / POST …/links (201){data: [TicketLinkResource, …]} — every link, read from this ticket.
POST …/merge{source: TicketResource, target: TicketResource, moved: {comments, attachments}}
GET …/attachments, GET …/attachments/{media}, POST …/attachments (201)TicketAttachmentResource (collection).
GET …/activityTicketActivityResource collection, oldest first ([] with tickets.activity.enabled off).
POST /tickets/bulk{data: [{ticket, ok, status, code?, message?}]}
admin endpointsTicketCategoryResource (the tree for the index), TicketFieldResource, TicketTagResource; store answers 201.
every other DELETE, POST / DELETE …/watch204, no body.

Ticket list — filters, sorts, paging

GET /tickets is query-builder-for-laravel over visibleTo($user); every filter is one of the Ticket query scopes:

FilterValueScope
filter[status]list of state valueswithStatus
filter[status_category]list: open, active, waiting, resolved, closedwithStatusCategory
filter[priority]listwithPriority
filter[category]list of ids; filter[category_descendants]=1 adds subcategoriesinCategory
filter[assignee]list of alias:key, me, noneassignedTo / unassigned
filter[requester]list of alias:key (agents; anyone else matches only themselves), merequestedBy
filter[project]alias:key, noneinProject
filter[tag]list of slugs — agents only (400 for anyone else)taggedWith
filter[watching]mewatchedBy
filter[overdue]1 (0 adds nothing)overdue
filter[due_from], filter[due_to], filter[created_from], filter[created_to]a date or a datetime (inclusive; a date-only *_to such as 2026-10-17 runs to the end of that day)dueFrom / dueTo / createdFrom / createdTo
filter[channel]listviaChannel
filter[merged]booleanmerged
filter[search]text — the reference exactly, or subject / description (% and _ literal)search
filter[fields.<key>]listwhereField
filter[fields.<key>.from], filter[fields.<key>.to]number / decimal / date / datetime fields (inclusive)whereFieldBetween
filter[trashed]with, only — holders of the manage ability only—

The agent-only filters — tag, requester=alias:key and a field that is not requester-visible — match only the tickets the agent scope lets the viewer work on. An unknown filter or sort is 400 under the host’s query-builder.mode.unknown_filter = reject (the default); ignore drops it.

Sorts: created_at, updated_at, due_at, number, priority (the enum weights) and, for agents, last_activity_at. The default is -last_activity_at for agents and -created_at otherwise. Paging: per_page (default 20, at most tickets.routes.max_per_page) and page; the list eager-loads category, assignee, requester, tags and message counts in a fixed number of queries.

Errors

Mapped in the package’s controllers — never in your exception handler — to {message, code}. Configuration errors stay 500: they are the host’s to fix, never the client’s.

StatuscodeWhen
400(query-builder)Unknown filter or sort.
403forbiddenA policy denial; comments’ UnauthorizedCommentActionException (with comments.authorization on).
403access_deniedNot a participant of the ticket.
403guest_link_invalidAny guest link that does not work — one body for all.
404ticket_not_foundUnknown reference, or a ticket the user may not view (hidden = missing); on every ticket endpoint view is asked first.
404comment_not_found, attachment_not_found, link_not_found, category_not_found, field_not_found, tag_not_foundA record not of this ticket, or unknown.
409ticket_closedA message on a Closed ticket (or comments’ lock).
409ticket_trashedA mutation of a deleted ticket.
409field_type_locked, duplicate_referenceA locked field type; a duplicate project prefix.
422 / 429ticket_transition_deniedLifecycle refused (with denials); 429 with Retry-After when a denial says when to retry.
422unknown_transition, unknown_field, field_not_editable, unknown_*_type—
422invalid_assignee, invalid_link, invalid_merge, invalid_category, invalid_field, invalid_tag, invalid_watcher, invalid_project—
422comment_rejected, invalid_comment_bodycomments refused the body.
422Laravel’s validation bodyForm request or field-value validation — inputs named in the request’s language; guest fields under guest.<name>.

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.