JSON API
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 & path | Name | Ability | Notes |
|---|---|---|---|
GET /tickets | index | viewAny | Scoped by visibleTo; filters, sorts and paging below. |
POST /tickets | store | create | 201 |
POST /tickets/bulk | bulk | per ticket | Bulk actions, below. |
GET /tickets/{ticket} | show | view | Detailed: plus field values and attachments. |
PATCH /tickets/{ticket} | update | view + update (with the touched attributes) | — |
DELETE /tickets/{ticket} | destroy | view + delete | Soft; 204. |
POST /tickets/{ticket}/restore | restore | view + restore | — |
GET /tickets/{ticket}/transitions | transitions.index | view | The transitions this user may run now: name, label, to, to_label, requires_reason, payload. |
POST /tickets/{ticket}/transitions/{transition} | transitions.store | view + transition | {reason?, payload?} |
PUT /tickets/{ticket}/assignee | assignee.update | view + assign | {assignee: {type, id}} | {claim: true} |
DELETE /tickets/{ticket}/assignee | assignee.destroy | view + unassign | — |
GET /tickets/{ticket}/comments | comments.index | view (+ viewInternal with ?internal=1) | — |
POST /tickets/{ticket}/replies | replies.store | reply (+ 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}/notes | notes.store | view + note | {body, channel?, attachments[]?}; 201 |
PATCH /tickets/{ticket}/comments/{comment} | comments.update | view + updateComment | {body}; the comment must be this ticket’s; recorded as comment_edited. |
DELETE /tickets/{ticket}/comments/{comment} | comments.destroy | view + deleteComment | 204; recorded as comment_deleted. |
GET /tickets/{ticket}/fields | fields.index | view | Role-filtered. |
PUT /tickets/{ticket}/fields | fields.update | view + update (fields) | {fields: {key: value}}; null clears a value. |
GET /tickets/{ticket}/watchers | watchers.index | view + manageWatchers | Agents only. |
POST /tickets/{ticket}/watchers | watchers.store | view + manageWatchers | {watcher: {type, id}}; 201 |
DELETE /tickets/{ticket}/watchers/{alias:key} | watchers.destroy | view + manageWatchers | 204 |
POST / DELETE /tickets/{ticket}/watch | watch / unwatch | view + manageWatchers (self) | 204 |
PUT /tickets/{ticket}/tags | tags.sync | view + tag | {tags: [names]} |
GET /tickets/{ticket}/links | links.index | view + link | — |
POST /tickets/{ticket}/links | links.store | view + link on both tickets | {ticket: reference, type}; 201 |
DELETE /tickets/{ticket}/links/{link} | links.destroy | view + link | 204; removes exactly that row, from either end. |
POST /tickets/{ticket}/merge | merge | view + merge on the source and on the target | {target: reference, reason?} |
GET /tickets/{ticket}/attachments | attachments.index | view | Short-lived signed URLs. |
POST /tickets/{ticket}/attachments | attachments.store | view + update (attachments) — agents | Multipart file; 201. |
GET /tickets/{ticket}/attachments/{media} | attachments.show | view | — |
DELETE /tickets/{ticket}/attachments/{media} | attachments.destroy | view + update (attachments) | 204 |
GET /tickets/{ticket}/activity | activity | view | Role-filtered. |
GET / POST /ticket-categories | admin.categories.index / .store | viewAny / manageCategories | The tree; ?project=alias:key; inactive categories (with their subtrees) for agents only. |
GET / PATCH / DELETE /ticket-categories/{id} | admin.categories.show / .update / .destroy | viewAny / manageCategories | An inactive category is 404 category_not_found for non-agents; delete takes {reassign_to?}. |
POST /ticket-categories/{id}/move | admin.categories.move | manageCategories | {parent_id, position?} |
GET / POST /ticket-fields | admin.fields.index / .store | viewAny / manageFields | ?project=, ?category=; role-filtered. |
GET / PATCH / DELETE /ticket-fields/{id} | admin.fields.show / .update / .destroy | viewAny / manageFields | — |
GET / POST /ticket-tags | admin.tags.index / .store | viewTags (agents) / manageTags | — |
PATCH / DELETE /ticket-tags/{id} | admin.tags.update / .destroy | manageTags | — |
GET /tickets/guest/{ticket} | guest.show | signed link | See Guests and guest links. |
POST /tickets/guest/{ticket}/replies | guest.reply | signed link | — |
POST /tickets/guest/{ticket}/transitions/{transition} | guest.transition | signed link | Requester transitions only. |
GET /tickets/guest/{ticket}/attachments/{media} | guest.attachment | signed link | The 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
| Endpoint | Body |
|---|---|
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 …/attachments | Multipart file. |
GET /tickets | per_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.
| Endpoint | Body / 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).
| Resource | Everyone who may view | Agents also get |
|---|---|---|
TicketResource | reference, 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. |
TicketCommentResource | id, body, role, author, channel, attachments, created_at, updated_at | internal, status (comments’ moderation status). |
TicketActivityResource | id, type, label, system, actor, properties (morph types as aliases), occurred_at — public entries only. | visibility, internal entries, raw properties. |
TicketLinkResource | id, type, label, direction (outgoing / incoming), ticket {reference, subject, status} — ticket {reference} only when the viewer may not view the other ticket. | — |
TicketAttachmentResource | id (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. |
TicketCategoryResource | id, name, description, slug, parent_id, project, position, is_active, children | translations {name, description} (the locale maps), defaults {priority, assignee, due_in}. |
TicketFieldResource | id, 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 |
TicketTagResource | id, 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
| Endpoint | Body |
|---|---|
GET /tickets | A paginated TicketResource collection (data, links, meta). |
POST /tickets (201), GET / PATCH /tickets/{ticket}, POST …/restore, POST …/transitions/{transition}, PUT / DELETE …/assignee | The detailed TicketResource. |
GET …/transitions | {data: [{name, label, to, to_label, requires_reason, payload}]} |
GET …/comments | TicketCommentResource 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 …/tags | The 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 …/activity | TicketActivityResource collection, oldest first ([] with tickets.activity.enabled off). |
POST /tickets/bulk | {data: [{ticket, ok, status, code?, message?}]} |
| admin endpoints | TicketCategoryResource (the tree for the index), TicketFieldResource, TicketTagResource; store answers 201. |
| every other DELETE, POST / DELETE …/watch | 204, 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:
| Filter | Value | Scope |
|---|---|---|
filter[status] | list of state values | withStatus |
filter[status_category] | list: open, active, waiting, resolved, closed | withStatusCategory |
filter[priority] | list | withPriority |
filter[category] | list of ids; filter[category_descendants]=1 adds subcategories | inCategory |
filter[assignee] | list of alias:key, me, none | assignedTo / unassigned |
filter[requester] | list of alias:key (agents; anyone else matches only themselves), me | requestedBy |
filter[project] | alias:key, none | inProject |
filter[tag] | list of slugs — agents only (400 for anyone else) | taggedWith |
filter[watching] | me | watchedBy |
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] | list | viaChannel |
filter[merged] | boolean | merged |
filter[search] | text — the reference exactly, or subject / description (% and _ literal) | search |
filter[fields.<key>] | list | whereField |
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.
| Status | code | When |
|---|---|---|
| 400 | (query-builder) | Unknown filter or sort. |
| 403 | forbidden | A policy denial; comments’ UnauthorizedCommentActionException (with comments.authorization on). |
| 403 | access_denied | Not a participant of the ticket. |
| 403 | guest_link_invalid | Any guest link that does not work — one body for all. |
| 404 | ticket_not_found | Unknown reference, or a ticket the user may not view (hidden = missing); on every ticket endpoint view is asked first. |
| 404 | comment_not_found, attachment_not_found, link_not_found, category_not_found, field_not_found, tag_not_found | A record not of this ticket, or unknown. |
| 409 | ticket_closed | A message on a Closed ticket (or comments’ lock). |
| 409 | ticket_trashed | A mutation of a deleted ticket. |
| 409 | field_type_locked, duplicate_reference | A locked field type; a duplicate project prefix. |
| 422 / 429 | ticket_transition_denied | Lifecycle refused (with denials); 429 with Retry-After when a denial says when to retry. |
| 422 | unknown_transition, unknown_field, field_not_editable, unknown_*_type | — |
| 422 | invalid_assignee, invalid_link, invalid_merge, invalid_category, invalid_field, invalid_tag, invalid_watcher, invalid_project | — |
| 422 | comment_rejected, invalid_comment_body | comments refused the body. |
| 422 | Laravel’s validation body | Form 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 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.