roundly-consulting/tickets-for-laravel
A Laravel package that adds ticketing support to your application, providing models, migrations, and helpers for creating, tracking, and managing support tickets out of the box. Integrates with Eloquent and Blade views for quick setup and customization.
composer require the package, then run php artisan tickets:install. The installer publishes config/migrations and prints a checklist — read the printed lines rather than skimming, the current version calls out the two things people miss. Requires package-toolkit ^1.4.0; if you are pinning that separately, resolve it before chasing install errors.
Migrate, then make your user model an assignee:
class User extends Authenticatable implements \Vendor\Tickets\Contracts\Assignee
{
use \Vendor\Tickets\Concerns\IsTicketAssignee;
}
This is the step every installation needs before an agent can take a ticket. Skip it and claim() / assign() throw InvalidAssigneeException at runtime, not at install time.
The Gate line printed by the installer (and shown in the README) uses is_support as a placeholder — that is your agent flag. Swap it for whatever your app actually uses (is_agent, a role check, a can_handle_tickets column).
Decide about the HTTP API up front. It is opt-in and off by default: if your published config/tickets.php has no routes.enabled key (or it is null), the routes do not load. Turn it on with TICKETS_ROUTES=on in the environment, or set the key explicitly. Confirm with php artisan about — it should read Routes OFF on a default install.
Run php artisan tickets:doctor. On an untouched install it should be green (the shipped lifecycle's close/dismiss ambiguity is reported as ok). A warning there means your own configuration, not the package.
First use case: an agent opens the queue and calls $ticket->claim() on an unassigned ticket; a dispatcher calls $ticket->assign($agent). From the requester side, a ticket created through your form lands in a queue and is picked up the same way.
Assignment. Treat Assignee as the contract for "can hold a ticket" — implement it on User and on any team/queue model you assign to. claim() for self-service pickup, assign($assignee) for dispatch. Gate your UI on the same predicate the exception enforces.
Authorization. TicketPolicy is the single place to reason about who may touch a ticket. Note the attribute-less form: @can('update', $ticket) now answers "may the requester edit anything at all" — with requester_can_edit off it returns true while the ticket still has an active, applicable requester_editable field. Use @can('update', $ticket) to decide whether to render an edit affordance at all, and @can('update', [$ticket, $field])-style attribute checks to decide which fields to render.
Requester editing. Model this as config + custom fields, not policy code: requester_can_edit is the master switch, and per-field requester_editable flags define the surface. Deactivating the last editable field is what actually closes the door, and the policy follows it.
Querying and filtering. Use the filter array for index screens (filter[created_to], filter[due_to], filter[fields.<key>.to]) and whereFieldBetween($key, $from, $to) for reports. Date-only bounds are now inclusive of the whole day; datetime bounds keep their exact moment. Pass the precision you mean and let the package interpret it.
Guest links. Tickets::guests()->linkFor($ticket, $guest) (or guestLink()) generates the signed link for a non-user requester. In tests, Tickets::fake()->assertGuestLinkIssued($ticket, $guest) matches a link issued without an explicit guest when $guest is the ticket's guest requester — assert against the requester, not null.
Guest conversion. Tickets::guests()->convert() promotes a guest to a real user and re-parents their tickets and watches. It is transactional; conflicts resolve in favour of the authenticated user.
Status transitions. Drive status changes through the lifecycle, and prefer naming the transition over naming the target status. Ambiguous targets (two transitions landing on the same status) require the transition name.
Links and activity. Directional links (duplicates is mutual, but the rows are directional). Subscribe to TicketUnlinked and read the activity entries when you need an audit trail of who unlinked what.
Enabling the API. Treat routes.enabled / TICKETS_ROUTES as a deliberate deployment decision, not something that is on by default. Read it in one place (env or config) and let about be your smoke test; the flag is now evaluated the same way by the route loader, tickets:doctor, and about.
InvalidAssigneeException on claim()/assign() is almost always the missing Assignee implementation + IsTicketAssignee trait, not a bug in your assignee lookup. Check the model before the query.config/tickets.php with no routes.enabled key (or null) keeps the API off — that is the intended behaviour. Set TICKETS_ROUTES=on or add the key; don't add a duplicate route file to work around it. Note the old behaviour: before 1.0.3 the switch was ignored when absent and the routes loaded anyway — if you had routes "for free" on 1.0.0–1.0.2, a deploy to 1.0.3 can make them 404.about and tickets:doctor now tell the same story. On a config without the key, about reports Routes OFF and the doctor's missing-key warning says the HTTP API is off (and names TICKETS_ROUTES=on as the fix). If you previously suppressed or misread that doctor warning, re-read it — it is describing an intentionally disabled feature, not an error.transitionTo(TicketStatus::Closed) throws on a resolved ticket. The shipped lifecycle has close and dismiss both leading resolved → closed, so the target alone is ambiguous. Transition by name instead. tickets:doctor reports the shipped ambiguity as ok (with a note) and keeps warning for your own lifecycles — so a doctor warning in your app means your lifecycle has an ambiguous target, and the same transitionTo() call will throw there too.23:59:59. A date-only upper bound already includes the day. If you need an instant, pass a datetime and it is preserved exactly; mixing the two is how off-by-one-day reports happen.duplicates pair is directional. DELETE /tickets/{ticket}/links/{link} removes the link it names. Before 1.0.1, deleting B → A from A's end removed A → B and the TicketUnlinked event and activity entry named the wrong direction — if you wrote assertions or listeners around those payloads on 1.0.0, re-check them.assertGuestLinkIssued with a guest requester. Asserting with a different guest, or expecting the "no guest" call to fail, is the wrong shape — the matcher keys off the ticket's guest requester.package-toolkit: the package now requires ^1.4.0. Bump it rather than aliasing around it — about, the doctor, and the route switch share behaviour that landed with that constraint.tickets:doctor first (config/lifecycle/route switch), then php artisan about to cross-check the API flag, then the activity log for the ticket, then the TicketUnlinked / link events for direction. The doctor command is cheap and catches the majority of misconfiguration.requester_editable drives both UI and the attribute-less update gate), and the Assignee contract for non-User assignees.How can I help you explore Laravel packages today?