Configuration
The package works with zero host configuration. Publish config/tickets.php only to override:
php artisan vendor:publish --tag=tickets-configEvery key is read through a validating reader (Support\TicketsConfig, on the toolkit’s Config): a junk value throws InvalidTicketsConfigurationException (or the toolkit’s InvalidConfigurationException) naming the key — never a silent fallback.
- Booleans read on/off (also true/false, 1/0, yes/no); anything else throws. A blank env value means the shipped default.
- Durations are ISO-8601 (P7D, PT4H, P1DT12H); only the keys that say so accept off.
- A config contract test pins both directions: every key the code reads is shipped, and every shipped key is read.
The config file
// config/tickets.php (imports omitted)
return [
'primary_key_type' => env('TICKETS_PRIMARY_KEY_TYPE', 'bigint'),
'key_type' => env('TICKETS_KEY_TYPE', 'bigint'),
'models' => [
'ticket' => Ticket::class,
'category' => TicketCategory::class,
'field' => TicketField::class,
'tag' => TicketTag::class,
'guest' => TicketGuest::class,
],
'lifecycle' => TicketLifecycle::class,
'auto_close' => [
'after' => env('TICKETS_AUTO_CLOSE_AFTER', 'P7D'),
],
'priority' => [
'enum' => TicketPriority::class,
'default' => env('TICKETS_DEFAULT_PRIORITY', 'normal'),
],
'reference' => [
'prefix' => env('TICKETS_REFERENCE_PREFIX', 'TCK'),
],
'channels' => ['web', 'email', 'api', 'phone', 'chat'],
'default_channel' => 'api',
'assignment' => [
'strategy' => CategoryDefaultStrategy::class,
],
'due' => [
'resolver' => CategoryDueDateResolver::class,
'soon_window' => env('TICKETS_DUE_SOON_WINDOW', 'PT4H'),
],
'requesters' => ['user' => 'App\\Models\\User'],
'assignables' => ['user' => 'App\\Models\\User'],
'watchables' => null,
'projects' => [],
'guests' => [
'enabled' => env('TICKETS_GUESTS', 'on'),
'fields' => [
'name' => ['type' => 'string', 'rules' => ['required', 'string', 'max:120'], 'display' => true],
'email' => ['type' => 'string', 'rules' => ['nullable', 'email:rfc', 'max:191'], 'identifying' => true, 'route' => 'mail', 'normalize' => 'email'],
'phone' => ['type' => 'string', 'rules' => ['nullable', 'string', 'max:32'], 'identifying' => true, 'route' => 'vonage', 'normalize' => 'phone', 'encrypted' => false],
],
'require_one_identifying' => true,
'update_on_resolve' => false,
'link_ttl' => env('TICKETS_GUEST_LINK_TTL', 'P30D'),
'revoke_on_close' => env('TICKETS_GUESTS_REVOKE_ON_CLOSE', 'off'),
'link_generator' => SignedRouteLinkGenerator::class,
],
'categories' => [
'max_depth' => 5,
],
'fields' => [
'allowed_rules' => ['min', 'max', 'between', 'regex', 'digits', 'digits_between', 'alpha', 'alpha_num', 'alpha_dash', 'starts_with', 'ends_with'],
],
'comments' => [
'auto_approve' => env('TICKETS_COMMENTS_AUTO_APPROVE', 'on'),
'reply_reopens' => env('TICKETS_REPLY_REOPENS', 'on'),
],
'watchers' => [
'can_view' => env('TICKETS_WATCHERS_CAN_VIEW', 'on'),
'auto' => [
'requester' => env('TICKETS_AUTO_WATCH_REQUESTER', 'on'),
'assignee' => env('TICKETS_AUTO_WATCH_ASSIGNEE', 'on'),
'agent_repliers' => env('TICKETS_AUTO_WATCH_AGENT_REPLIERS', 'on'),
'mentioned' => env('TICKETS_AUTO_WATCH_MENTIONED', 'off'),
],
],
'authorization' => [
'register_policy' => env('TICKETS_REGISTER_POLICY', 'on'),
'policy' => TicketPolicy::class,
'abilities' => [
'agent' => 'tickets.agent',
'assign' => 'tickets.assign',
'merge' => 'tickets.agent',
'manage' => 'tickets.manage',
],
'agent_scope' => null,
'requester_transitions' => ['close', 'reopen'],
'participants_can_transition' => env('TICKETS_PARTICIPANTS_CAN_TRANSITION', 'off'),
'self_service' => env('TICKETS_SELF_SERVICE', 'on'),
'requester_can_edit' => env('TICKETS_REQUESTER_CAN_EDIT', 'on'),
],
'tags' => [
'free_form' => env('TICKETS_TAGS_FREE_FORM', 'on'),
],
'links' => [
'cross_project' => env('TICKETS_LINKS_CROSS_PROJECT', 'on'),
'max_depth' => 50,
],
'merge' => [
'cross_project' => env('TICKETS_MERGE_CROSS_PROJECT', 'off'),
'move_attachments' => env('TICKETS_MERGE_MOVE_ATTACHMENTS', 'on'),
],
'attachments' => [
'bucket' => 'attachments',
'disk' => env('TICKETS_ATTACHMENTS_DISK', 'local'),
'mimes' => [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'text/csv',
'application/zip',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
'max_kb' => 10240,
],
'activity' => [
'enabled' => env('TICKETS_ACTIVITY', 'on'),
],
'actor' => [
'from_auth' => env('TICKETS_ACTOR_FROM_AUTH', 'on'),
'guard' => env('TICKETS_ACTOR_GUARD'),
],
'routes' => [
// Blank means off: the route switch reads a blank value as on.
'enabled' => env('TICKETS_ROUTES') ?: 'off',
'prefix' => env('TICKETS_ROUTES_PREFIX', 'api'),
'middleware' => ['api', 'auth', 'throttle:tickets-api'],
'max_per_page' => 100,
'rate_limit' => 120,
'guest' => [
'middleware' => ['api', 'throttle:tickets-guest'],
'rate_limit' => 30,
],
'bulk' => [
'max' => 100,
],
],
'notifications' => [
'enabled' => env('TICKETS_NOTIFICATIONS', 'on'),
'channels' => ['mail'],
'queue' => env('TICKETS_NOTIFICATIONS_QUEUE'),
'notify_actor' => env('TICKETS_NOTIFY_ACTOR', 'off'),
'guests' => env('TICKETS_NOTIFY_GUESTS', 'on'),
'url' => env('TICKETS_NOTIFICATIONS_URL'),
'types' => [
'created' => TicketCreatedNotification::class,
'assigned' => TicketAssignedNotification::class,
'replied' => TicketRepliedNotification::class,
'requester_replied' => TicketRequesterRepliedNotification::class,
'note_added' => TicketNoteAddedNotification::class,
'mentioned' => TicketMentionedNotification::class,
'status_changed' => TicketStatusChangedNotification::class,
'overdue' => TicketOverdueNotification::class,
'due_soon' => TicketDueSoonNotification::class,
'merged' => TicketMergedNotification::class,
],
],
];Key types
| Key | Env | Default | Purpose |
|---|---|---|---|
primary_key_type | TICKETS_PRIMARY_KEY_TYPE | bigint | PK of Ticket and TicketGuest and of every column pointing at a ticket — bigint, uuid or ulid. Must equal lifecycle.key_type, attributes.key_type and comments.key_type. Set it before migrating. |
key_type | TICKETS_KEY_TYPE | bigint | Outbound morph ids: requester, assignee, project, watcher, actor, link creator, a category’s default assignee. Must equal your user keys, lifecycle.actor_key_type and comments.key_type. Set it before migrating. |
Models
| Key | Env | Default | Purpose |
|---|---|---|---|
models.ticket | — | Models\Ticket | The ticket model. A replacement must extend the packaged class. |
models.category | — | Models\TicketCategory | The category model; a replacement extends the packaged class. |
models.field | — | Models\TicketField | The custom field definition model; a replacement extends the packaged class. |
models.tag | — | Models\TicketTag | The tag model; a replacement extends the packaged class. |
models.guest | — | Models\TicketGuest | The guest requester model; a replacement extends the packaged class. |
Lifecycle, priority, references, channels
| Key | Env | Default | Purpose |
|---|---|---|---|
lifecycle | — | Lifecycle\TicketLifecycle | A lifecycle-for-laravel definition implementing Contracts\TicketLifecycle whose states enum implements Contracts\TicketState. |
auto_close.after | TICKETS_AUTO_CLOSE_AFTER | P7D | How long a Resolved ticket waits before lifecycle:sweep closes it; off = never. Read when a ticket enters Resolved. |
priority.enum | — | Enums\TicketPriority | A string-backed enum implementing Contracts\Priority. |
priority.default | TICKETS_DEFAULT_PRIORITY | normal | A value of that enum; applies when neither the caller nor the category sets one. |
reference.prefix | TICKETS_REFERENCE_PREFIX | TCK | Prefix of tickets without a project; ^[A-Z][A-Z0-9]{0,9}$. |
channels | — | web, email, api, phone, chat | Accepted intake channels, each ^[a-z][a-z0-9_]{0,31}$. An empty or unset list reads as the shipped one; a channel you add takes its label from tickets::enums.ticket_channel.<value>. |
default_channel | — | api | One of channels (falls back to api, else the first). |
Defaults at creation, due dates
| Key | Env | Default | Purpose |
|---|---|---|---|
assignment.strategy | — | Assignment\CategoryDefaultStrategy | A Contracts\AssignmentStrategy, asked when a ticket is created without an assignee. |
due.resolver | — | Due\CategoryDueDateResolver | A Contracts\DueDateResolver, asked when a ticket is created without a due date. |
due.soon_window | TICKETS_DUE_SOON_WINDOW | PT4H | tickets:sweep flags a ticket due within this window once (TicketDueSoon); off = never. |
Morph alias allow-lists
| Key | Env | Default | Purpose |
|---|---|---|---|
requesters | — | ['user' => App\Models\User] | Who may be a requester in API input ({type: alias, id}) — never a class name. The guest model is always the alias guest. |
assignables | — | ['user' => App\Models\User] | Who may be assigned; each class must implement Contracts\Assignee. |
watchables | — | null | null = every requester and assignable alias. |
projects | — | [] | Project aliases; each class must implement Contracts\TicketProject. |
Guests
| Key | Env | Default | Purpose |
|---|---|---|---|
guests.enabled | TICKETS_GUESTS | on | Serve the guest endpoints (only with the routes on). A published config that drops the key reads as on; tickets:doctor warns. |
guests.fields | — | name, email, phone | The guest’s fields, stored through attributes-for-laravel — see Guests and guest links. |
guests.require_one_identifying | — | true | A guest needs at least one identifying value. |
guests.update_on_resolve | — | false | Overwrite differing non-identifying values when a returning guest writes in. |
guests.link_ttl | TICKETS_GUEST_LINK_TTL | P30D | Lifetime of a guest link. |
guests.revoke_on_close | TICKETS_GUESTS_REVOKE_ON_CLOSE | off | Closing a ticket revokes every guest link to it. |
guests.link_generator | — | Guests\SignedRouteLinkGenerator | A Contracts\GuestLinkGenerator. |
Categories, fields, tags, links, merge
| Key | Env | Default | Purpose |
|---|---|---|---|
categories.max_depth | — | 5 | Deepest category tree, 1–50. |
fields.allowed_rules | — | min, max, between, regex, digits, digits_between, alpha, alpha_num, alpha_dash, starts_with, ends_with | The only rules a custom field may carry; exists and unique are always refused. |
tags.free_form | TICKETS_TAGS_FREE_FORM | on | Attaching an unknown tag name creates it; off = existing tags only. |
links.cross_project | TICKETS_LINKS_CROSS_PROJECT | on | Link tickets of different projects. |
links.max_depth | — | 50 | How far the blocks / parent_of cycle check walks, 1–1000. |
merge.cross_project | TICKETS_MERGE_CROSS_PROJECT | off | Merge tickets of different projects. |
merge.move_attachments | TICKETS_MERGE_MOVE_ATTACHMENTS | on | A merge moves the source’s attachments to the target. |
Conversation and watchers
| Key | Env | Default | Purpose |
|---|---|---|---|
comments.auto_approve | TICKETS_COMMENTS_AUTO_APPROVE | on | Approve a ticket reply the host’s comments.require_approval left pending — never one the blocklist held. Off, with require_approval on, every reply waits for a moderator and stays inert for the ticket even once approved (see Replies and internal notes). |
comments.reply_reopens | TICKETS_REPLY_REOPENS | on | A requester’s reply reopens a Resolved ticket. |
watchers.can_view | TICKETS_WATCHERS_CAN_VIEW | on | A non-agent watcher sees the ticket and its public thread and may reply as a participant. Off: a signed-in watcher gets 404, and a guest watcher gets no link (their links answer 403, mails carry no button). |
watchers.auto.requester | TICKETS_AUTO_WATCH_REQUESTER | on | The requester watches on creation. |
watchers.auto.assignee | TICKETS_AUTO_WATCH_ASSIGNEE | on | The assignee watches on assignment. |
watchers.auto.agent_repliers | TICKETS_AUTO_WATCH_AGENT_REPLIERS | on | An agent who writes on the ticket watches it. |
watchers.auto.mentioned | TICKETS_AUTO_WATCH_MENTIONED | off | Someone mentioned (who may read the comment) watches it. |
Authorization
| Key | Env | Default | Purpose |
|---|---|---|---|
authorization.register_policy | TICKETS_REGISTER_POLICY | on | Register the policy for the ticket model. |
authorization.policy | — | Policies\TicketPolicy | Must extend TicketPolicy; an invalid class registers nothing (the doctor reports it). |
authorization.abilities.agent | — | tickets.agent | Gate ability that makes a user an agent. |
authorization.abilities.assign | — | tickets.assign | Assign a ticket to someone else. |
authorization.abilities.merge | — | tickets.agent | Merge tickets. |
authorization.abilities.manage | — | tickets.manage | Delete and restore tickets (the holder must also be an agent for the ticket — tickets.agent plus the AgentScope); run categories, fields and tags. |
authorization.agent_scope | — | null | A Contracts\AgentScope; null binds Authorization\UnrestrictedAgentScope (agents see every ticket). |
authorization.requester_transitions | — | ['close', 'reopen'] | Transitions a requester may run on their own ticket; [] (or a blank string) = none, null = the default; a comma-separated string works too. Each ^[a-z][a-z0-9_]{0,63}$. |
authorization.participants_can_transition | TICKETS_PARTICIPANTS_CAN_TRANSITION | off | A watcher may run them too. |
authorization.self_service | TICKETS_SELF_SERVICE | on | Anyone signed in may file a ticket; off = agents only. |
authorization.requester_can_edit | TICKETS_REQUESTER_CAN_EDIT | on | The requester may edit the subject and description while unresolved. |
Attachments
| Key | Env | Default | Purpose |
|---|---|---|---|
attachments.bucket | — | attachments | The media-library bucket of ticket attachments (private); ^[a-z][a-z0-9_-]{0,63}$. |
attachments.disk | TICKETS_ATTACHMENTS_DISK | local | Must not be web-served — media-library’s own default (public) is reachable under /storage. |
attachments.mimes | — | images, PDF, text, CSV, ZIP, Word, Excel | Accepted mime types (type/subtype); [] = any. A published config whose attachments array lacks the key gets this list, never any type (the doctor warns). |
attachments.max_kb | — | 10240 | Size cap in kilobytes, at least 1. Reply and note attachments follow comments’ comments.media.* instead. |
Activity and actor
| Key | Env | Default | Purpose |
|---|---|---|---|
activity.enabled | TICKETS_ACTIVITY | on | Write the activity timeline; off = nothing written and the endpoint returns []. |
actor.from_auth | TICKETS_ACTOR_FROM_AUTH | on | A call without an explicit actor is attributed to the authenticated user. |
actor.guard | TICKETS_ACTOR_GUARD | blank | The guard for that; blank = the default guard. |
HTTP API
| Key | Env | Default | Purpose |
|---|---|---|---|
routes.enabled | TICKETS_ROUTES | off | Serve the JSON API. Written env('TICKETS_ROUTES') ?: 'off', so a blank value stays off. A published config that drops the key reads as on (the toolkit’s route switch); tickets:doctor warns. |
routes.prefix | TICKETS_ROUTES_PREFIX | api | URI prefix (/ for none); letters, digits, -, _, . and /. |
routes.middleware | — | ['api', 'auth', 'throttle:tickets-api'] | Agent and requester endpoints; use auth:sanctum with Sanctum. |
routes.max_per_page | — | 100 | Largest ticket page, 1–1000 (default page size 20). |
routes.rate_limit | — | 120 | Requests a minute per user (per IP for an anonymous caller) on tickets-api, at least 1. |
routes.guest.middleware | — | ['api', 'throttle:tickets-guest'] | Guest endpoints; EnsureGuestTicketAccess is always appended. |
routes.guest.rate_limit | — | 30 | Requests a minute per IP and ticket on tickets-guest, at least 1. |
routes.bulk.max | — | 100 | Most tickets one bulk call takes, 1–1000. |
Notifications
| Key | Env | Default | Purpose |
|---|---|---|---|
notifications.enabled | TICKETS_NOTIFICATIONS | on | Send notifications at all. |
notifications.channels | — | ['mail'] | database needs your notifications table; any other channel needs a subclass (see Notifications). |
notifications.queue | TICKETS_NOTIFICATIONS_QUEUE | blank | Queue name; blank = the connection’s default. |
notifications.notify_actor | TICKETS_NOTIFY_ACTOR | off | Tell the one who acted too (the created receipt always goes out). |
notifications.guests | TICKETS_NOTIFY_GUESTS | on | Mail guest requesters and guest watchers, with a fresh signed link. |
notifications.url | TICKETS_NOTIFICATIONS_URL | blank | The mail button for signed-in recipients, {reference} filled in (https://help.example.com/tickets/{reference}); blank = no button; must be http(s). |
notifications.types.<type> | — | the packaged class | One key per type — created, assigned, replied, requester_replied, note_added, mentioned, status_changed, overdue, due_soon, merged: a class extending the packaged one, or false (also the strings false, off, 0, no) to switch it off; blank or unset = the packaged class. |
With authorization.register_policy off, Laravel’s own policy discovery still maps Models\Ticket to Policies\TicketPolicy — off is not deny-all; register your own policy with Gate::policy(). The rate limiters are defined only with the routes on, and apply only while their throttle: middleware is in the list.
Settings read from other packages
| Key | Why |
|---|---|
lifecycle.key_type, lifecycle.actor_key_type, comments.key_type, attributes.key_type | Key-type alignment (doctor, install). |
comments.authorization | A doctor warning (see Replies and internal notes). |
comments.media.private_disk, media.stream.enabled | The attachment URL check of the doctor. |
query-builder.mode.unknown_filter | reject (default) answers an unknown filter[...] with 400; ignore drops it. |
Environment
The common knobs are env-driven, so you rarely publish the config at all:
TICKETS_REFERENCE_PREFIX=TCK
TICKETS_DEFAULT_PRIORITY=normal
TICKETS_AUTO_CLOSE_AFTER=P7D
TICKETS_DUE_SOON_WINDOW=PT4H
TICKETS_ROUTES=on
TICKETS_ATTACHMENTS_DISK=local
TICKETS_NOTIFICATIONS_URL=https://help.example.com/tickets/{reference}
TICKETS_NOTIFY_GUESTS=onShow 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.