---
title: "Configuration — Tickets for Laravel | Roundly"
description: "Every config key with its env variable and default — keys, lifecycle, priorities, guests, watchers, authorization, attachments, routes and notifications."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/configuration
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tickets for Laravel](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)

# Configuration

The package works with zero host configuration. Publish config/tickets.php only to override:

```bash
php artisan vendor:publish --tag=tickets-config
```

Every 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

```php
// 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:

```bash
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=on
```

## 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)
