Filing and editing tickets
Tickets::make() returns a TicketBuilder; create() files the ticket and returns it. Only the subject and a requester (a host model or guest fields) are required:
$ticket = Tickets::make()
->subject('Printer on floor 3 is jammed') // required
->description('Paper jam error E-102.')
->requester($user) // a host model…
->guest(['name' => 'Jo', 'email' => '[email protected]']) // …or guest fields (GuestData|array)
->category($hardware) // model, id or null
->priority('high') // value or enum case
->project($helpdesk) // a TicketProject
->assignee($agent)
->due(now()->addDay())
->channel('web')
->fields(['asset_tag' => 'PRN-0042'])
->tags(['printer']) // names or TicketTag models
->watchers($colleague)
->attach($request->file('photo')) // UploadedFile or a local path — never user input as a path
->meta(['crm_id' => 'C-1']) // host-owned metadata; top-level keys merge
->by($agent) // the actor (default: the auth user, with actor.from_auth)
->create();asSystem() files it with no actor; data() returns the CreateTicketData instead of creating. An inactive category is refused for a new ticket, and the channel must be one of tickets.channels. From PHP, attachments take uploads or local paths — never pass user input as a path.
Defaults at creation
- Values the caller leaves out are filled once, at creation: the nearest ancestor category that sets one, then config — priority (tickets.priority.default), assignee (Contracts\AssignmentStrategy), due date (Contracts\DueDateResolver) and channel (tickets.default_channel). Custom field defaults are filled too.
- Every contract sees the stored ticket: the assignee and the due date are picked right after the row is inserted, in the same transaction — the ticket has its key, reference, number and initial status, its requester, project, category, priority and channel, but not yet its custom field values, tags, watchers or attachments.
- Changing a ticket’s category later re-applies nothing.
The DTO form
use RoundlyConsulting\Tickets\DataTransferObjects\CreateTicketData;
$ticket = Tickets::create(new CreateTicketData(
subject: 'Printer on floor 3 is jammed',
requester: $customer,
category: $hardware,
priority: 'high',
channel: 'web',
fields: ['asset_tag' => 'PRN-0042'],
));From the requester or the project
// Concerns\RequestsTickets on your user model — the requester is set
$ticket = $user->openTicket()->subject('VPN drops every hour')->create();
// Concerns\HasTickets on your project model — numbered in the project's sequence
$ticket = $project->openTicket()->subject('…')->requester($user)->create(); // SUP-1Finding tickets
Tickets::find('SUP-42'); // ?Ticket — live tickets only
Tickets::find('SUP-42', withTrashed: true); // a soft-deleted one too
Tickets::findOrFail('SUP-42'); // TicketNotFoundException otherwise
Tickets::for('SUP-42'); // a handle; an unknown or soft-deleted reference throwsEditing a ticket
use RoundlyConsulting\Tickets\DataTransferObjects\UpdateTicketData;
// Only what is given changes; null clears a nullable value
Tickets::for($ticket)->by($agent)->update(new UpdateTicketData(subject: 'Printer on floor 3 jams on A3', description: null));
Tickets::for($ticket)->by($agent)->prioritize('urgent'); // TicketPriorityChanged
Tickets::for($ticket)->by($agent)->categorize($printers); // TicketCategoryChanged; defaults are not re-applied
Tickets::for($ticket)->by($agent)->changeRequester($colleague); // a model or GuestData- UpdateTicketData fields default to the Support\Optional sentinel (not given), so only what you pass changes and null can clear a nullable value. TicketUpdated fires with the changed keys (subject, description, meta).
- categorize() does not re-apply category defaults, but a newly required field of the target category needs a value.
- changeRequester() takes the ticket away from the previous requester: their automatic watch is removed silently (a watch an agent added on purpose stays), and when the previous requester was a guest every guest link to the ticket is revoked (GuestAccessRevoked).
- Setting the same priority again fires nothing.
Deleting and restoring
Tickets::for($ticket)->by($manager)->delete(); // soft delete
Tickets::for(Tickets::findOrFail('SUP-42', withTrashed: true))->restore(); // bring it backA soft-deleted ticket keeps its number, and every mutation of it throws TicketTrashedException until it is restored. In the API, delete and restore need the manage ability held by an agent for the ticket (see Policy and agent scope).
The actor
by($actor) attributes the following calls; by(null) falls back to the authenticated user (tickets.actor.from_auth on tickets.actor.guard); asSystem() means nobody. The actor is what activity entries, events and notifications credit, and what the domain rules judge — an agent role, a requester, a participant.
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.