Models, keys and references
Two settings, both read by the migrations — set them before migrating:
| Setting | Keys | Must equal |
|---|---|---|
tickets.primary_key_type | The PK of tickets and ticket_guests — the models other packages point at (lifecycle subject, commentable, attribute and media owner) — and every column pointing at a ticket (ticket_tag.ticket_id, ticket_watchers.ticket_id, ticket_links.ticket_id / related_ticket_id, ticket_activities.ticket_id, tickets.merged_into_id). | lifecycle.key_type, attributes.key_type, comments.key_type |
tickets.key_type | Outbound morph ids: requester, assignee, project, watcher, activity actor, link creator, a category’s default assignee. | your user model’s key type, lifecycle.actor_key_type, comments.key_type |
With uuid or ulid, Ticket and TicketGuest generate time-ordered keys (UUIDv7 / ULID) through Concerns\HasConfigurableKey. Every other package table keeps a bigint id(), and morph columns go through the toolkit’s morphKey / ownerKey seam. media.model_id is a string column, so media-library needs no key setting. tickets:doctor reports every pair that disagrees and warns when the default guard’s user model is keyed differently from tickets.key_type.
References and sequences
- A ticket’s reference is {prefix}-{number} — TCK-42, or SUP-42 in a project. The prefix is tickets.reference.prefix, or TicketProject::ticketReferencePrefix() for a ticket in a project; both must match ^[A-Z][A-Z0-9]{0,9}$.
- One sequence for tickets without a project (scope global) and one per project (scope {morphAlias}:{key}), stored on the ticket as sequence_scope + number, unique together. The reference is stored, unique, and is the route key.
- Numbers are gap-free under concurrency: the sequence row is read with a lock inside the create transaction. A rolled-back create gives its number back; a soft-deleted ticket keeps its number; numbers are never reused.
- A project that renames its prefix keeps its old references; new tickets continue the same sequence under the new prefix.
- Two projects with the same prefix collide on the reference — DuplicateTicketReferenceException. Prefixes must be unique.
Models
| Model | Table | Swappable | Soft deletes |
|---|---|---|---|
Ticket | tickets | tickets.models.ticket | yes |
TicketCategory | ticket_categories | tickets.models.category | yes |
TicketField | ticket_fields | tickets.models.field | yes |
TicketTag | ticket_tags | tickets.models.tag | yes |
TicketGuest | ticket_guests | tickets.models.guest | yes |
TicketWatcher | ticket_watchers | no (final) | no — unique rows |
TicketLink | ticket_links | no (final) | no — unique rows |
TicketActivity | ticket_activities | no (final) | no — append-only |
TicketSequence | ticket_sequences | no (final) | no |
The Ticket model
Ticket implements lifecycle’s LifecycleSubject (HasLifecycle, attribute status), comments’ HasComments, attributes’ HasAttributes (imported as HasCustomFields) and media-library’s HasMedia (InteractsWithMedia). None of their methods collide.
| Columns | Meaning |
|---|---|
id, reference, number, sequence_scope | Identity. |
subject, description | Content. |
status | Owned by lifecycle — tickets never writes it (lifecycle.strict_writes stays on). |
priority | A value of the priority enum (string 32). |
category_id | FK, null on delete. |
requester_type/_id, assignee_type/_id, assigned_at, project_type/_id, channel | Parties. |
due_at, due_soon_notified_at, overdue_notified_at, first_responded_at, resolved_at, closed_at, last_activity_at | Timing (resolved_at / closed_at are lifecycle stamps). |
merged_into_id, guest_access_version | Merge target; guest-link version. |
meta | JSON, yours — the package never writes it and never shows it in the API. |
Relations: category(), requester(), assignee(), project(), mergedInto(), tags(), watchers() (TicketWatcher rows), links(), activities() — and from the traits comments(), media(), the lifecycle relations and the attribute relations. Conveniences that go through the manager, so the fake sees them: reply($body, ?$author), note($body, ?$author) and assignTo($assignee, ?$actor).
Status helpers
Each helper agrees with its query scope, so a badge and a list filter never disagree:
| Helper | True when | Scope |
|---|---|---|
isUnresolved() | Open, Active or Waiting | unresolved() |
isResolved() | in a Resolved state | withStatusCategory(StatusCategory::Resolved) |
isClosed() | in a Closed state | withStatusCategory(StatusCategory::Closed) |
isOverdue() | past its due date (strictly) and unresolved | overdue() |
isMerged() | merged into another ticket | merged() |
isAssigned() | has an assignee | the opposite of unassigned() |
$ticket->priority(); // TicketPriority::High — null for a value the enum no longer has
$ticket->statusCategory(); // StatusCategory::Waiting
$ticket->isUnresolved(); // Open, Active or Waiting
$ticket->isOverdue(); // past its due date (strictly) and unresolved
$ticket->isMerged(); // merged into another ticketHost metadata
meta(?string $path = null, mixed $default = null) returns the whole map or a dot path, and hasMeta(string $path) tells whether a key exists. Use it for your integration ids — a CRM id, a source URL:
use RoundlyConsulting\Tickets\DataTransferObjects\UpdateTicketData;
$ticket = Tickets::make()->subject('…')->requester($user)->meta(['crm_id' => 'C-1'])->create();
$ticket->meta(); // ['crm_id' => 'C-1']
$ticket->meta('crm_id'); // 'C-1'
$ticket->hasMeta('crm_id'); // true — a key holding null counts as present
// Replaces the whole map; [] clears it
Tickets::for($ticket)->update(new UpdateTicketData(meta: ['crm_id' => 'C-2']));TicketBuilder::meta() merges top-level keys across calls; UpdateTicketData(meta: …) replaces the whole map. The metadata never reaches an API resource, a change is recorded as an agent-only updated entry naming the key meta (never a value), and a metadata-only change leaves the public updated_at alone.
The other models
- TicketCategory — a tree (parent_id, null on delete) with an optional project morph; translatable name and description; a slug made once from the fallback-locale name, unique among siblings in the same project; position, is_active and the defaults default_priority, default_assignee (morph) and default_due_in (ISO-8601).
- TicketField — a custom field definition: key, translatable label and help, type (FieldType), options, rules, required, encrypted, default, requester_visible, requester_editable, position, is_active, a project morph and category_id.
- TicketTag — name (1–64 characters, permanent per scope), slug (unique within the project, follows renames), color (#rrggbb, stored lower-case) and a project morph; pivot ticket_tag.
- TicketGuest — no fixed PII columns: every field in tickets.guests.fields is stored through attributes-for-laravel. displayName() reads the display field, routeNotificationFor($driver) the field whose route is that driver; relations requestedTickets() and watchedTickets().
- TicketActivity — append-only (an update or delete throws ActivityIsAppendOnlyException): ticket_id, type, actor morph, is_system, visibility, properties (the diff — never a note body or an encrypted value) and occurred_at.
- TicketWatcher, TicketLink, TicketSequence — final, read through the handles; TicketSequence also holds the package’s advisory lock rows (lock:categories, lock:tags, lock:fields, lock:links). Never write it yourself.
Swapping a model
// config/tickets.php
'models' => [
'ticket' => App\Models\SupportTicket::class, // extends RoundlyConsulting\Tickets\Models\Ticket
// …
],Set it in config, not at runtime: the provider wires listeners for the configured class at boot. Every query, relation, listener and the packaged factories resolve the class through Support\TicketModels, so the host subclass is what comes back. Override the hooks the underlying packages call — getRouteKeyName() (reference), lifecycleDefinitions(), attributeDefinitions() and registerMediaBuckets() — only by calling the parent.
Tables
Ten publish-only, forward-only migrations in foreign-key order: ticket_categories → ticket_sequences → ticket_guests → tickets → ticket_fields → ticket_tags → ticket_tag → ticket_watchers → ticket_links → ticket_activities. Tickets soft-delete, so their cascades fire only on a force delete. Translatable columns use translatable-for-laravel’s macro (jsonb on PostgreSQL, json on MySQL); meta, options, rules, default and properties are plain jsonb. The package also writes to the tables of comments (the conversation), lifecycle (the status history), attributes (custom field and guest values) and media-library (attachments).
Enums
| Enum | Cases | Helpers |
|---|---|---|
TicketStatus | new, open, in_progress, pending, on_hold, resolved, closed | category(): StatusCategory |
StatusCategory | open, active, waiting, resolved, closed | isUnresolved(), static unresolved() |
Automation | assigned, requester_replied, requester_reopened, auto_close, merged | sources(), targets() |
TicketPriority | low (10), normal (20), high (30), urgent (40) | weight(): int |
FieldType | text, textarea, email, url, number, decimal, boolean, date, datetime, select, multiselect | attributeType(), hasOptions() |
LinkType | relates_to, duplicates, blocks, parent_of | inverse(), inverseLabel(), isSymmetric(), isAcyclic() |
ActivityType | see Querying and the activity timeline | defaultVisibility() |
ActivityVisibility | public, internal | — |
AuthorRole | agent, requester, participant, system | — |
TicketChannel | web, email, api, phone, chat | the label and colour of a shipped channel |
NotificationType | created, assigned, replied, requester_replied, note_added, mentioned, status_changed, overdue, due_soon, merged | defaultClass(), reachesRequesters() |
TicketOperation | every operation the fake records — one per manager path | — |
Every enum is string-backed, implements enums-for-laravel’s HasColor (color(): a palette name such as blue, amber or red) and uses its Helpers trait: label() (translated through tickets::enums.<enum>.<value>, English and Slovak), labels(), values(), options(), toOptions(), validationRule(), tryFromName(), is() / isIn() and the rest of that trait.
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.