NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Tickets for Laravel

Configuration

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

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

// 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

KeyEnvDefaultPurpose
primary_key_typeTICKETS_PRIMARY_KEY_TYPEbigintPK 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_typeTICKETS_KEY_TYPEbigintOutbound 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

KeyEnvDefaultPurpose
models.ticket—Models\TicketThe ticket model. A replacement must extend the packaged class.
models.category—Models\TicketCategoryThe category model; a replacement extends the packaged class.
models.field—Models\TicketFieldThe custom field definition model; a replacement extends the packaged class.
models.tag—Models\TicketTagThe tag model; a replacement extends the packaged class.
models.guest—Models\TicketGuestThe guest requester model; a replacement extends the packaged class.

Lifecycle, priority, references, channels

KeyEnvDefaultPurpose
lifecycle—Lifecycle\TicketLifecycleA lifecycle-for-laravel definition implementing Contracts\TicketLifecycle whose states enum implements Contracts\TicketState.
auto_close.afterTICKETS_AUTO_CLOSE_AFTERP7DHow long a Resolved ticket waits before lifecycle:sweep closes it; off = never. Read when a ticket enters Resolved.
priority.enum—Enums\TicketPriorityA string-backed enum implementing Contracts\Priority.
priority.defaultTICKETS_DEFAULT_PRIORITYnormalA value of that enum; applies when neither the caller nor the category sets one.
reference.prefixTICKETS_REFERENCE_PREFIXTCKPrefix of tickets without a project; ^[A-Z][A-Z0-9]{0,9}$.
channels—web, email, api, phone, chatAccepted 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—apiOne of channels (falls back to api, else the first).

Defaults at creation, due dates

KeyEnvDefaultPurpose
assignment.strategy—Assignment\CategoryDefaultStrategyA Contracts\AssignmentStrategy, asked when a ticket is created without an assignee.
due.resolver—Due\CategoryDueDateResolverA Contracts\DueDateResolver, asked when a ticket is created without a due date.
due.soon_windowTICKETS_DUE_SOON_WINDOWPT4Htickets:sweep flags a ticket due within this window once (TicketDueSoon); off = never.

Morph alias allow-lists

KeyEnvDefaultPurpose
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—nullnull = every requester and assignable alias.
projects—[]Project aliases; each class must implement Contracts\TicketProject.

Guests

KeyEnvDefaultPurpose
guests.enabledTICKETS_GUESTSonServe 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, phoneThe guest’s fields, stored through attributes-for-laravel — see Guests and guest links.
guests.require_one_identifying—trueA guest needs at least one identifying value.
guests.update_on_resolve—falseOverwrite differing non-identifying values when a returning guest writes in.
guests.link_ttlTICKETS_GUEST_LINK_TTLP30DLifetime of a guest link.
guests.revoke_on_closeTICKETS_GUESTS_REVOKE_ON_CLOSEoffClosing a ticket revokes every guest link to it.
guests.link_generator—Guests\SignedRouteLinkGeneratorA Contracts\GuestLinkGenerator.

Categories, fields, tags, links, merge

KeyEnvDefaultPurpose
categories.max_depth—5Deepest category tree, 1–50.
fields.allowed_rules—min, max, between, regex, digits, digits_between, alpha, alpha_num, alpha_dash, starts_with, ends_withThe only rules a custom field may carry; exists and unique are always refused.
tags.free_formTICKETS_TAGS_FREE_FORMonAttaching an unknown tag name creates it; off = existing tags only.
links.cross_projectTICKETS_LINKS_CROSS_PROJECTonLink tickets of different projects.
links.max_depth—50How far the blocks / parent_of cycle check walks, 1–1000.
merge.cross_projectTICKETS_MERGE_CROSS_PROJECToffMerge tickets of different projects.
merge.move_attachmentsTICKETS_MERGE_MOVE_ATTACHMENTSonA merge moves the source’s attachments to the target.

Conversation and watchers

KeyEnvDefaultPurpose
comments.auto_approveTICKETS_COMMENTS_AUTO_APPROVEonApprove 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_reopensTICKETS_REPLY_REOPENSonA requester’s reply reopens a Resolved ticket.
watchers.can_viewTICKETS_WATCHERS_CAN_VIEWonA 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.requesterTICKETS_AUTO_WATCH_REQUESTERonThe requester watches on creation.
watchers.auto.assigneeTICKETS_AUTO_WATCH_ASSIGNEEonThe assignee watches on assignment.
watchers.auto.agent_repliersTICKETS_AUTO_WATCH_AGENT_REPLIERSonAn agent who writes on the ticket watches it.
watchers.auto.mentionedTICKETS_AUTO_WATCH_MENTIONEDoffSomeone mentioned (who may read the comment) watches it.

Authorization

KeyEnvDefaultPurpose
authorization.register_policyTICKETS_REGISTER_POLICYonRegister the policy for the ticket model.
authorization.policy—Policies\TicketPolicyMust extend TicketPolicy; an invalid class registers nothing (the doctor reports it).
authorization.abilities.agent—tickets.agentGate ability that makes a user an agent.
authorization.abilities.assign—tickets.assignAssign a ticket to someone else.
authorization.abilities.merge—tickets.agentMerge tickets.
authorization.abilities.manage—tickets.manageDelete 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—nullA 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_transitionTICKETS_PARTICIPANTS_CAN_TRANSITIONoffA watcher may run them too.
authorization.self_serviceTICKETS_SELF_SERVICEonAnyone signed in may file a ticket; off = agents only.
authorization.requester_can_editTICKETS_REQUESTER_CAN_EDITonThe requester may edit the subject and description while unresolved.

Attachments

KeyEnvDefaultPurpose
attachments.bucket—attachmentsThe media-library bucket of ticket attachments (private); ^[a-z][a-z0-9_-]{0,63}$.
attachments.diskTICKETS_ATTACHMENTS_DISKlocalMust not be web-served — media-library’s own default (public) is reachable under /storage.
attachments.mimes—images, PDF, text, CSV, ZIP, Word, ExcelAccepted 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—10240Size cap in kilobytes, at least 1. Reply and note attachments follow comments’ comments.media.* instead.

Activity and actor

KeyEnvDefaultPurpose
activity.enabledTICKETS_ACTIVITYonWrite the activity timeline; off = nothing written and the endpoint returns [].
actor.from_authTICKETS_ACTOR_FROM_AUTHonA call without an explicit actor is attributed to the authenticated user.
actor.guardTICKETS_ACTOR_GUARDblankThe guard for that; blank = the default guard.

HTTP API

KeyEnvDefaultPurpose
routes.enabledTICKETS_ROUTESoffServe 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.prefixTICKETS_ROUTES_PREFIXapiURI 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—100Largest ticket page, 1–1000 (default page size 20).
routes.rate_limit—120Requests 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—30Requests a minute per IP and ticket on tickets-guest, at least 1.
routes.bulk.max—100Most tickets one bulk call takes, 1–1000.

Notifications

KeyEnvDefaultPurpose
notifications.enabledTICKETS_NOTIFICATIONSonSend notifications at all.
notifications.channels—['mail']database needs your notifications table; any other channel needs a subclass (see Notifications).
notifications.queueTICKETS_NOTIFICATIONS_QUEUEblankQueue name; blank = the connection’s default.
notifications.notify_actorTICKETS_NOTIFY_ACTORoffTell the one who acted too (the created receipt always goes out).
notifications.guestsTICKETS_NOTIFY_GUESTSonMail guest requesters and guest watchers, with a fresh signed link.
notifications.urlTICKETS_NOTIFICATIONS_URLblankThe 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 classOne 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

KeyWhy
lifecycle.key_type, lifecycle.actor_key_type, comments.key_type, attributes.key_typeKey-type alignment (doctor, install).
comments.authorizationA doctor warning (see Replies and internal notes).
comments.media.private_disk, media.stream.enabledThe attachment URL check of the doctor.
query-builder.mode.unknown_filterreject (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=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

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