Categories and priorities
A tree of translatable categories, global or per project, each with defaults for new tickets. The builder and create(CategoryData) run the same action:
use RoundlyConsulting\Tickets\DataTransferObjects\UpdateCategoryData;
$hardware = Tickets::categories()->make(['en' => 'Hardware', 'sk' => 'Hardvér']) // a string = the current locale
->description(['en' => 'Laptops, printers, peripherals'])
->project($helpdesk) // or none: a global category
->under($parent) // or none: a root
->position(10)
->defaults(priority: 'high', assignee: $itTeam, dueIn: 'P1D') // or dueIn: CarbonInterval::day()
->create();
Tickets::categories()->update($hardware, new UpdateCategoryData(position: 20, defaultDueIn: null));
Tickets::categories()->move($hardware, $newParent, position: 0); // null parent = to the root
Tickets::categories()->delete($hardware, reassignTo: $general); // soft delete
Tickets::categories()->restore($hardware);
Tickets::categories()->tree($helpdesk); // roots + children: the global tree plus the project's ownRules
Each refusal throws InvalidCategoryException:
- No move under itself or a descendant (cycle); depth is capped by tickets.categories.max_depth.
- A child’s project equals its parent’s; a global parent may have project children. A ticket’s category is global or of the ticket’s project.
- An inactive category is refused for new tickets; existing tickets keep it.
- Deleting a category that has live tickets needs reassignTo — an active category, not itself, global or of the same project. Each ticket moves exactly as categorize() moves it (a category_changed entry, TicketCategoryChanged, the target’s required fields enforced — a ticket missing one stops the whole delete). A category with subcategories cannot be deleted; a restore brings it back as it was.
- Names must not be empty; default_priority must be a value of the priority enum; default_due_in a positive ISO-8601 duration — given as one ('PT4H') or as a CarbonInterval, stored as the duration either way; default_assignee an Assignee (an ineligible default assignee leaves the ticket unassigned).
Slugs come from the name in the app’s fallback locale, unique among siblings in a project, and are made once — a rename keeps the slug. Category writes serialise on the lock:categories advisory row, which keeps the cycle check and slug uniqueness race-free.
Defaults
Defaults apply once, at creation, only for what the caller did not give: the caller → the nearest ancestor category that sets one → config. Changing a ticket’s category later re-applies nothing.
Priorities
Enums\TicketPriority — low (weight 10), normal (20), high (30), urgent (40) — is stored as a string; tickets.priority.default (normal) applies when neither the caller nor the category sets one:
Tickets::for($ticket)->prioritize('urgent'); // TicketPriorityChanged
Ticket::query()->withPriority('high', 'urgent')->orderByPriority()->get();
$ticket->priority(); // TicketPriority::Urgent, or nullA stored value the enum no longer has reads as null (never a 500), sorts last, presents color: null in the API, and tickets:doctor counts it.
Your own priority scale
Point tickets.priority.enum at any string-backed enum implementing Contracts\Priority (weight(): int) and set tickets.priority.default to one of its values. orderByPriority() and the API’s priority sort follow the weights; tickets:doctor checks that the enum and the default are usable.
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.