Laravel Help Desk
#Laravel Help Desk
A comprehensive help desk and ticket management system for Laravel applications with email integration.
#Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
#Installation
composer require jeffersongoncalves/laravel-help-desk
The package uses Laravel's auto-discovery, so the service provider and facade are registered automatically.
#Publish Configuration
php artisan vendor:publish --tag=help-desk-config
#Publish Migrations
php artisan vendor:publish --tag=help-desk-migrations
#Run Migrations
php artisan migrate
#Publish Translations (optional)
php artisan vendor:publish --tag=help-desk-translations
#Configuration
The configuration file is located at config/help-desk.php. Key options:
return [ // Database connection used by the help desk tables and migrations // (null = the application's default connection) 'connection' => env('HELPDESK_DB_CONNECTION'), // Identifies this application on the tickets it creates 'app' => [ 'key' => env('HELPDESK_APP_KEY'), // null = single application 'name' => env('HELPDESK_APP_NAME'), ], // Read only this application's own tickets 'scope_to_app' => env('HELPDESK_SCOPE_TO_APP', false), // Models used by the help desk 'models' => [ 'user' => \App\Models\User::class, // Model that creates tickets 'operator' => \App\Models\User::class, // Model that manages tickets ], // Ticket settings 'ticket' => [ 'reference_prefix' => 'HD', // Ticket reference format: HD-00001 'default_status' => 'open', 'default_priority' => 'medium', 'attachment_disk' => 'local', // Storage disk for attachments 'auto_close_days' => null, // Auto-close resolved tickets (null = disabled) 'allow_reopen' => true, ], // Email integration 'email' => [ 'enabled' => true, 'inbound' => [ 'driver' => null, // 'imap', 'mailgun', 'sendgrid', 'resend', or 'postmark' ], ], // Notification settings 'notifications' => [ 'channels' => ['mail'], 'queue' => 'default', ], ];
#Transports
A satellite application reaches the help desk one of two ways, and the choice comes first because everything else follows from it.
database |
api |
|
|---|---|---|
| How | shares the central database connection | signed HTTP to the central application |
| Needs | credentials for the support database | a shared secret |
| Suits | applications you run, on one network | a satellite that should hold no database credentials |
| Can do | everything | the end-user side only |
HELPDESK_DRIVER=database # the default
The sections below cover the shared connection. Talking Over the Signed API covers the other.
#Dedicated Database Connection
Set help-desk.connection to route every help desk table, model and migration to a
connection other than the application default:
HELPDESK_DB_CONNECTION=help_desk
// config/database.php 'connections' => [ 'help_desk' => [ 'driver' => 'mysql', 'host' => env('HELPDESK_DB_HOST'), 'database' => env('HELPDESK_DB_DATABASE'), 'username' => env('HELPDESK_DB_USERNAME'), 'password' => env('HELPDESK_DB_PASSWORD'), // ... ], ],
Leaving it unset keeps the current behaviour, so existing installations need no change.
#Sharing One Help Desk Database Across Applications
Several applications can point at the same help desk database — for example a central
support application running the admin side, and satellite applications exposing only the
end-user side. No migration declares a foreign key to users, so the tickets can live in
a database that knows nothing about any application's user table.
Each application sets the same connection credentials:
HELPDESK_DB_CONNECTION=help_desk
The central application owns the schema. Satellite applications install the package and point at the connection, but must not publish or run the help desk migrations — Laravel tracks applied migrations in each application's own default connection, so a satellite would try to create tables that already exist.
Three things need attention in this topology:
-
Attachments.
help-desk.ticket.attachment_diskdefaults tolocal, which keeps uploads on the disk of whichever application received them. Point every application at a shared disk (S3 or equivalent) so the admin side can serve files uploaded elsewhere. -
Requester identity. Tickets reference their requester through a polymorphic
user_type/user_idpair. When applications keep separateuserstables, register a distinct morph alias per application so the keys cannot collide:use Illuminate\Database\Eloquent\Relations\Relation; // AppServiceProvider::boot() of each application Relation::enforceMorphMap([ 'app-a-user' => \App\Models\User::class, ]);
Without this, user
#5of two different applications produce the same requester key.
#Telling Applications Apart
Give each application a key. Tickets it creates carry it, so the central application can group, filter and route them:
HELPDESK_APP_KEY=app-a HELPDESK_APP_NAME="Application A"
Ticket::forApp('app-a')->open()->count(); $ticket->app_key; // 'app-a' $ticket->app_name; // 'Application A', falling back to the key
The label is stored on the ticket rather than looked up, because the central application has no configuration describing the applications it serves.
A satellite application can also refuse to read anything but its own tickets:
HELPDESK_SCOPE_TO_APP=true
This applies to every query on Ticket, not only the ones that filter by requester, and
it complements the per-application morph alias above rather than replacing it. Leave it off
in the central application, which needs to see them all.
Both are optional: with no key configured a ticket stores none and nothing is scoped, which is the single-application behaviour.
#Naming People From Another Application
An application can only load the requester of a ticket it created itself. Everywhere else the stored morph type points at a model that is not installed, or that lives in a database this application cannot reach.
So the requester's name and email are copied onto the ticket when it is created, and the same is done for every comment author, attachment uploader, history entry and watcher. Read them through the accessors, which use the live model when it resolves and the copy when it does not:
$ticket->requester_name; // 'Ada Lovelace' $ticket->requester_email; // 'ada@example.com' $comment->author_name; $comment->author_email; $attachment->uploader_name; $attachment->uploader_email; $historyEntry->performer_name; $watcherRow->watcher_name;
$ticket->requester(); // the model, or null when not installed here $comment->resolvedAuthor(); // same, and null for system comments $attachment->resolvedUploadedBy(); // same $historyEntry->resolvedPerformer(); // same, and null for a system action $watcherRow->resolvedWatcher(); // same
Reading the raw relation — $ticket->user, $comment->author, $attachment->uploadedBy,
$entry->performer, $row->watcher — still throws for a model this application does not
have, because Eloquent instantiates the stored class name. Use the methods above instead.
Notifications follow the same rule. Ticket::notifyRequester() goes through the model when
it resolves and falls back to an on-demand mail notification to the copied address
otherwise, so the central application can reply to a requester it cannot load.
The copy is a snapshot, not a join: it records who opened the ticket at the time, and a later rename or deletion does not rewrite history. If your user model exposes its display fields under other names, override them:
// App\Models\User public function toHelpDeskSnapshot(): array { return ['name' => $this->full_name, 'email' => $this->contact_email]; }
#Talking Over the Signed API
When a satellite should hold no credentials for the support database — it runs on another host, or on someone else's infrastructure — it reaches the central application over HTTP instead, authenticated with HMAC.
Calling code does not change. The facade is the same, and what comes back is still a
Ticket:
$ticket = HelpDesk::createTicket([ 'department_id' => $department->id, 'title' => 'Printer offline', 'description' => 'It stopped printing.', ], $user); $ticket->reference_number; // 'HD-00042' $ticket->isOpen(); // true $ticket->requester_name; // 'Ada Lovelace'
#On the satellite
HELPDESK_DRIVER=api HELPDESK_API_URL=https://support.example.com HELPDESK_APP_KEY=app-a HELPDESK_API_SECRET=a-long-random-string
#On the central application
// config/help-desk.php 'api' => [ 'clients' => [ 'app-a' => [ 'secrets' => [ env('HELPDESK_SECRET_APP_A'), env('HELPDESK_SECRET_APP_A_PREVIOUS'), ], 'actor_types' => ['app-a-user'], ], ], ],
Two secrets, current first, so one can be rotated without a flag day: deploy the new
secret, roll the satellites, then drop the old entry. actor_types lists the morph aliases
that application's users are stored under; claiming any other is rejected.
No clients configured means no API routes are registered at all, so a single application installation exposes nothing.
#What the signature proves, and what it does not
It proves which application is calling. The acting user is asserted by that application in the payload.
So a leaked secret can impersonate any user of that application, and none of another — the app key comes from the signed header, never the body, and every read is scoped by it. HMAC gives authenticity and integrity, not confidentiality: HTTPS is still required.
#Reading a list, on either transport
The four methods a user-facing panel needs are on the contracts, so the same call works under both drivers and nothing has to branch on which one is configured:
// The tickets this user opened, newest first. Paginated, because the API // endpoint behind it always was. $tickets = HelpDesk::tickets()->forActor($user); $tickets = HelpDesk::tickets()->forActor($user, perPage: 15, page: 2); // Filtered, searched and sorted by the database — on both drivers. $tickets = HelpDesk::tickets()->forActor($user, status: [TicketStatus::Open, 'in_progress'], // strings or enums priority: TicketPriority::Urgent, search: 'scanner', // title and reference number sort: 'priority', direction: 'desc', // one of Ticket::SORTABLE ); // The options a create form offers: active only, in sort order. HelpDesk::departments()->all(); HelpDesk::departments()->categoriesFor($department->id); // The bytes of an attachment, for handing a file back to its uploader. HelpDesk::attachments()->contents($attachment, $ticket->uuid);
forActor() takes the user rather than falling back to whoever is authenticated. Which
tickets someone may see is not a decision to make by omission.
The filters are on the contract rather than left to the caller because the API driver cannot do them in memory: narrowing the page it happens to hold and presenting that as "your open tickets" looks filtered and is wrong. They are applied after the actor scope, so they only ever narrow what is asked for.
A few details worth knowing:
statusandprioritytake a value, an enum case, or a list of either. An unknown one throws rather than matching nothing — an empty list from a typo looks exactly like an empty list from having no tickets.searchcoverstitleandreference_number, case-insensitively. Not the description: it is rich text, and matching the markup produces hits the user cannot see in the row.%and_in the term are the characters the user typed, not wildcards.sortis an allow-list —Ticket::SORTABLE— checked on the client and again on the central application, because a caller-supplied column reaches the query builder. Sorting bypriorityorstatusorders by the enum's own sequence (severity, lifecycle), not by the stored string, which would puthighabovelowand call it sorted.
Over the API these are query parameters on GET tickets: status[], priority[], q,
sort and direction. Anything the allow-lists do not name is a 422.
The ticket uuid on contents() is not redundant: it is what the API path needs, and both
transports check it against the attachment, so a mismatched pair fails the same way
instead of serving a file from another ticket.
#Closing and reopening
The two status changes that belong to the person who opened the ticket work over the API:
HelpDesk::closeTicket($ticket, $user); HelpDesk::reopenTicket($ticket, $user);
Every other status is an operator decision and has no endpoint, so the driver refuses it locally rather than sending a request the central application would reject:
HelpDesk::changeStatus($ticket, TicketStatus::Resolved, $user); // HelpDeskApiException: Changing a ticket to resolved is an operator action and // the API driver cannot perform it.
The allow-list is enforced on the central application as well, not only in the client — a
satellite is not a trust boundary. The transition table still applies to both moves,
including help-desk.ticket.allow_reopen, so a refused move raises
InvalidStatusTransitionException on either driver.
#What the API driver cannot do
The rest of the operator surface throws immediately, naming what to use instead, rather than making a request that would be refused:
HelpDesk::assignTicket($ticket, $operator); // HelpDeskApiException: assignTicket() is an operator action and the API driver // cannot perform it. It belongs to the central application, on the database driver.
That covers updating, the four operator statuses, assignment, deletion, internal notes, watchers, and managing departments.
#Attachments
A file travels base64 encoded inside the JSON body, so the signature covers it like any other payload and no multipart handling is involved.
$attachment = HelpDesk::attachments()->store($ticket, $request->file('file'), $user); $attachment->file_name; // 'invoice.pdf' $attachment->uploader_name; // 'Ada Lovelace'
The cost of sending it inline is a cap, because the file grows by a third in transit and is held in memory on both ends:
HELPDESK_API_MAX_INLINE_ATTACHMENT=2048 # KB, on both ends
Deliberately smaller than help-desk.ticket.max_file_size — it is the ceiling of the
inline approach, not a policy about files. Something larger wants a signed upload URL,
which this does not implement.
The satellite has no access to the disk the file sits on, so there is no URL to hand out:
$attachment->getUrl(); // HelpDeskApiException: getUrl() is not available on the API driver… Use // HelpDesk::attachments()->contents($attachment) to fetch the bytes instead. $bytes = HelpDesk::attachments()->contents($attachment, $ticket->uuid);
Extension and size limits are enforced on both ends. The satellite checks first to avoid spending a round trip, and the central application checks again because a satellite is not a trust boundary.
Relations are the other limit. A model that came back over the wire has no database to join against, so reading a relation the response did not carry throws rather than producing a missing-table SQL error:
$ticket->comments; // HelpDeskApiException: Relation [comments] on ApiTicket needs the database driver. // The show endpoint returns them: use HelpDesk::tickets()->findByUuid($uuid).
A relation the response did carry — the comments and attachments on a ticket fetched by
uuid — is returned as normal. The show endpoint sends attachments as one flat list, so
$ticket->attachments holds all of them and each comment carries its own subset.
Hydrated tickets and attachments are keyed by uuid, not id: the resources publish the
uuid and never the central application's primary key, so $ticket->getKey() returns the
uuid. It matters for anything that keys a collection by the model's key.
#The signature scheme
Enough to write a client in another language.
canonical = METHOD \n REQUEST_URI \n TIMESTAMP \n NONCE \n sha256(RAW_BODY) signature = "sha256=" + hex(hmac_sha256(canonical, secret))
REQUEST_URI is the path plus query string, exactly as the server sees it. The method and
URI are in the string because signing the body alone would let a captured request be
replayed against a different endpoint.
| Header | Content |
|---|---|
X-HelpDesk-App |
the calling application's app key |
X-HelpDesk-Timestamp |
Unix seconds |
X-HelpDesk-Nonce |
128 bits of randomness, 32 hex characters |
X-HelpDesk-Signature |
sha256=<hex> |
The server rejects a timestamp more than help-desk.api.tolerance seconds away in either
direction, and rejects a nonce it has already seen. Both are required: a window alone
leaves everything inside it replayable, and a nonce alone lets a capture be replayed
forever.
Every rejection is the same 401 with the same body, whatever the reason.
#Endpoints
| Method | Path |
|---|---|
POST |
/help-desk/api/tickets |
GET |
/help-desk/api/tickets |
GET |
/help-desk/api/tickets/{uuid} |
POST |
/help-desk/api/tickets/{uuid}/status |
POST |
/help-desk/api/tickets/{uuid}/comments |
GET |
/help-desk/api/departments |
GET |
/help-desk/api/departments/{id}/categories |
Every request acting on behalf of a person carries an actor:
{ "actor": { "type": "app-a-user", "id": 5, "name": "Ada Lovelace", "email": "ada@example.com" }, "title": "Printer offline", "description": "It stopped printing." }
The name and email become the identity snapshot, which is how the central application names a requester whose model it does not have.
#Setup
#1. Add Traits to Your User Model
For regular users (ticket creators):
use JeffersonGoncalves\HelpDesk\Concerns\HasTickets; class User extends Authenticatable { use HasTickets; }
For operators/agents (ticket managers):
use JeffersonGoncalves\HelpDesk\Concerns\IsOperator; class User extends Authenticatable { use IsOperator; // Includes HasTickets }
#2. Create Departments
use JeffersonGoncalves\HelpDesk\Facades\HelpDesk; $department = HelpDesk::createDepartment([ 'name' => 'Technical Support', 'slug' => 'technical-support', 'email' => 'support@example.com', 'is_active' => true, ]);
#3. Assign Operators to Departments
HelpDesk::addOperator($department, $user, 'operator'); // 'operator', 'manager', or 'admin' HelpDesk::removeOperator($department, $user); HelpDesk::updateDepartment($department, ['name' => 'Support', 'is_active' => false]);
#Usage
#Creating Tickets
use JeffersonGoncalves\HelpDesk\Facades\HelpDesk; $ticket = HelpDesk::createTicket([ 'title' => 'Cannot access my account', 'description' => 'I get an error when trying to log in...', 'department_id' => $department->id, 'priority' => 'high', ], $user); // $ticket->reference_number => "HD-00001" // $ticket->uuid => "550e8400-e29b-41d4-a716-446655440000"
#Managing Tickets
// Find tickets $ticket = HelpDesk::findTicketByReference('HD-00001'); $ticket = HelpDesk::findTicketByUuid('550e8400-...'); // Assign to operator HelpDesk::assignTicket($ticket, $operator); HelpDesk::unassignTicket($ticket); // Change status use JeffersonGoncalves\HelpDesk\Enums\TicketStatus; HelpDesk::changeStatus($ticket, TicketStatus::InProgress); HelpDesk::closeTicket($ticket); HelpDesk::reopenTicket($ticket); // Update ticket HelpDesk::updateTicket($ticket, [ 'priority' => 'urgent', 'category_id' => $category->id, ]); // Delete ticket (soft delete) HelpDesk::deleteTicket($ticket);
State is readable straight off the model:
$ticket->isOpen(); // anything that is not closed or resolved $ticket->isClosed(); $ticket->isResolved(); $ticket->isAssigned(); $ticket->isOverdue(); // past due_at and still open
#Comments
// Add a public reply $comment = HelpDesk::addComment($ticket, $user, 'Thank you for contacting us.'); // Add an internal note (not visible to end user) $note = HelpDesk::addNote($ticket, $operator, 'Escalating to senior engineer.'); // Add comment with attachments $comment = HelpDesk::addComment($ticket, $user, 'See attached screenshot.', [ 'attachments' => [$uploadedFile], ]);
Comments come in three kinds — a public reply, an internal note, and a system
entry written by the package itself. Scope by kind, or by whether the end user may see
them:
$ticket->comments()->public()->get(); // everything the requester may read $ticket->comments()->internal()->get(); // internal notes only $ticket->comments()->replies()->get(); // type = reply $ticket->comments()->notes()->get(); // type = note $comment->isReply(); $comment->isNote(); $comment->isSystem(); $comment->isInternal();
A system comment has no author, which is why $comment->author is nullable.
#Attachments
Attachments belong to a ticket, and optionally to one comment on it. Passing
attachments to addComment() covers the common case; reach for the service when you
need the attachment on its own.
use JeffersonGoncalves\HelpDesk\Facades\HelpDesk; // From an uploaded file $attachment = HelpDesk::attachments()->store($ticket, $request->file('file'), $user); // Attached to a specific comment $attachment = HelpDesk::attachments()->store($ticket, $file, $user, $comment); // From a file already on disk — the inbound email path uses this $attachment = HelpDesk::attachments()->storeFromPath( $ticket, '/tmp/scan.pdf', 'scan.pdf', 'application/pdf', filesize('/tmp/scan.pdf'), $user, ); // Deletes the row and the stored file, and fires AttachmentRemoved HelpDesk::attachments()->delete($attachment, $operator);
Validate before storing. Neither method enforces the limits for you — they exist so you can reject a file with your own message:
$service = HelpDesk::attachments(); $service->isAllowedExtension($file->getClientOriginalExtension()); // help-desk.ticket.allowed_extensions $service->isWithinSizeLimit($file->getSize() / 1024); // help-desk.ticket.max_file_size, in KB
Reading one back:
$attachment->getUrl(); // public URL on the attachment's disk $attachment->getTemporaryUrl(5); // signed URL, valid for 5 minutes, for private disks $attachment->getFileSizeForHumans(); // '1.44 MB' $attachment->uploader_name; // see "Naming People From Another Application"
getTemporaryUrl() needs a disk that supports signed URLs, such as S3. The local disk
does not.
#Watchers
HelpDesk::addWatcher($ticket, $anotherUser); HelpDesk::removeWatcher($ticket, $anotherUser); foreach ($ticket->watchers as $row) { $row->watcher_name; // snapshot, so it survives a model this app lacks $row->watcher_email; $row->resolvedWatcher(); // the model, or null when not installed here }
Adding the same watcher twice is a no-op, so you can call it without checking first.
#Querying Tickets
use JeffersonGoncalves\HelpDesk\Models\Ticket; use JeffersonGoncalves\HelpDesk\Enums\TicketStatus; use JeffersonGoncalves\HelpDesk\Enums\TicketPriority; // Open tickets $open = Ticket::open()->get(); // Closed tickets $closed = Ticket::closed()->get(); // By status $inProgress = Ticket::byStatus(TicketStatus::InProgress)->get(); // By priority $urgent = Ticket::byPriority(TicketPriority::Urgent)->get(); // Overdue tickets $overdue = Ticket::overdue()->get(); // Unassigned tickets $unassigned = Ticket::unassigned()->get(); // User's tickets (via trait) $user->helpDeskTickets; // Operator's assigned tickets (via trait) $operator->helpDeskAssignedTickets;
The traits add more than those two. HasTickets gives a user:
$user->helpDeskTickets; // tickets they opened $user->helpDeskComments; // comments they wrote, across every ticket $user->helpDeskWatching; // TicketWatcher rows for tickets they follow
and IsOperator adds, on top of those:
$operator->helpDeskAssignedTickets; // tickets assigned to them $operator->helpDeskDepartments; // departments they operate, with a `role` pivot $operator->helpDeskHistory; // every action they performed
#Ticket History
Every status change, assignment, comment and attachment is recorded, by the
LogTicketHistory subscriber, as long as the default listeners are registered.
use JeffersonGoncalves\HelpDesk\Enums\HistoryAction; foreach ($ticket->history()->latest()->get() as $entry) { $entry->action; // HistoryAction enum $entry->field; // 'status', 'priority', 'assigned_to', or null $entry->old_value; $entry->new_value; $entry->description; $entry->performer; // the model that acted, null for a system action } $ticket->history()->where('action', HistoryAction::StatusChanged)->get();
Like tickets, comments and attachments, a history row carries an identity snapshot, so reading it from an application that does not have the performer's model installed gives a name rather than a crash:
$entry->performer_name; $entry->performer_email; $entry->resolvedPerformer(); // the model, or null for a system action
Reading $entry->performer directly still throws for a model this application does not
have. Use resolvedPerformer().
#Canned Responses
use JeffersonGoncalves\HelpDesk\Models\CannedResponse; CannedResponse::create([ 'title' => 'Greeting', 'body' => 'Thank you for contacting our support team...', 'department_id' => $department->id, 'is_active' => true, ]); // Get canned responses for a department $responses = CannedResponse::active() ->forDepartment($department->id) ->ordered() ->get();
A canned response's body can carry placeholders, rendered against a ticket (and
optionally the acting operator) through CannedResponseService:
use JeffersonGoncalves\HelpDesk\Services\CannedResponseService; $service = app(CannedResponseService::class); // "Thank you for contacting our support team, John. Your reference is HD-00042." $service->render($cannedResponse, $ticket, $operator);
Four placeholders are built in: {ticket_code}, {user_name}, {agent_name} (empty
when no operator is passed), and {department}. An unrecognized placeholder is left
untouched in the output rather than stripped or throwing.
An application can register its own placeholders — typically from a service provider's
boot() — for business-specific tags the core package has no reason to know about:
use JeffersonGoncalves\HelpDesk\Models\Ticket; use JeffersonGoncalves\HelpDesk\Services\CannedResponseService; CannedResponseService::resolveVariable('order_number', function (Ticket $ticket, ?Model $agent) { return $ticket->metadata['order_number'] ?? null; });
A resolver returning null renders as an empty string, the same as a built-in
placeholder with nothing to show. Registering a resolver under a built-in name
(ticket_code, user_name, agent_name, department) has no effect — the built-in
substitution always wins, so a canned response reads the same regardless of what an
application registers.
#Categories
use JeffersonGoncalves\HelpDesk\Models\Category; $category = Category::create([ 'department_id' => $department->id, 'name' => 'Billing', 'slug' => 'billing', 'is_active' => true, ]); // Subcategories $sub = Category::create([ 'department_id' => $department->id, 'parent_id' => $category->id, 'name' => 'Refunds', 'slug' => 'refunds', ]);
#Exceptions
Lookups and status changes throw rather than returning null, so a controller can let them bubble to a handler instead of branching on every call.
| Exception | Thrown by | When |
|---|---|---|
TicketNotFoundException |
findTicketByUuid(), findTicketByReference() |
No ticket matches, or the UUID is malformed |
InvalidStatusTransitionException |
changeStatus(), closeTicket(), reopenTicket() |
The current status cannot transition to the requested one — see the table below |
UnauthorizedOperatorException |
Yours to throw | Provided for authorization checks; the package does not throw it for you |
EmailProcessingException |
The inbound email drivers | An IMAP connection fails, webklex/php-imap is missing, a payload will not parse, or no active department exists to route a message to |
All four extend RuntimeException.
use JeffersonGoncalves\HelpDesk\Enums\TicketStatus; use JeffersonGoncalves\HelpDesk\Exceptions\InvalidStatusTransitionException; use JeffersonGoncalves\HelpDesk\Exceptions\TicketNotFoundException; use JeffersonGoncalves\HelpDesk\Facades\HelpDesk; try { $ticket = HelpDesk::findTicketByReference($reference); HelpDesk::changeStatus($ticket, TicketStatus::Closed, $operator); } catch (TicketNotFoundException) { abort(404); } catch (InvalidStatusTransitionException $e) { return back()->withErrors($e->getMessage()); }
Check first when you would rather not catch:
$ticket->status->canTransitionTo(TicketStatus::Closed);
Inbound email failures are not thrown at you — ProcessInboundEmail catches them and marks
the row failed, so the message is visible on the InboundEmail record rather than in a log.
#Ticket Statuses
| Status | Description |
|---|---|
open |
New ticket, awaiting response |
pending |
Awaiting user response |
in_progress |
Being worked on by an operator |
on_hold |
Temporarily on hold |
resolved |
Issue has been resolved |
closed |
Ticket is closed |
Status transitions are validated automatically. For example, a closed ticket can only transition to open (reopen).
#Ticket Priorities
| Priority | Numeric Value |
|---|---|
low |
1 |
medium |
2 |
high |
3 |
urgent |
4 |
#Events
The package dispatches events that you can listen to in your application:
| Event | Description |
|---|---|
TicketCreated |
A new ticket was created |
TicketUpdated |
A ticket was updated |
TicketStatusChanged |
Ticket status changed |
TicketPriorityChanged |
Ticket priority changed |
TicketAssigned |
Ticket was assigned to an operator |
TicketClosed |
Ticket was closed |
TicketReopened |
Ticket was reopened |
TicketDeleted |
Ticket was deleted |
CommentAdded |
A comment was added to a ticket |
AttachmentAdded |
An attachment was added |
AttachmentRemoved |
An attachment was removed |
InboundEmailReceived |
An inbound email was received |
InboundEmailProcessed |
An inbound email was processed |
#Disabling Default Listeners
If you want to handle events yourself:
// config/help-desk.php 'register_default_listeners' => false,
#Email Integration
#Outbound Notifications
Notifications are sent automatically when events occur (configurable via notifications.notify_on). Email threading is supported via Message-ID, In-Reply-To, and References headers.
#Inbound Email
The package supports receiving emails via 5 drivers:
Security: webhook secrets are mandatory. The HTTP webhook drivers (Mailgun, SendGrid, Resend, and Postmark) verify every request against a configured secret/credential and fail closed: if the corresponding secret is not set, the request is rejected with
403 Forbiddenand a warning is logged. You must configure the secrets below for each driver you enable, otherwise the endpoint will reject all traffic. The webhook routes are also rate limited by default (throttle:60,1, configurable viahelp-desk.webhooks.middleware).
Driver Required configuration Mailgun HELPDESK_MAILGUN_SIGNING_KEYSendGrid HELPDESK_SENDGRID_WEBHOOK_USERNAME+HELPDESK_SENDGRID_WEBHOOK_PASSWORDResend HELPDESK_RESEND_WEBHOOK_SECRET(andHELPDESK_RESEND_API_KEYto fetch bodies)Postmark HELPDESK_POSTMARK_WEBHOOK_USERNAME+HELPDESK_POSTMARK_WEBHOOK_PASSWORD
#IMAP
HELPDESK_INBOUND_DRIVER=imap HELPDESK_IMAP_HOST=imap.example.com HELPDESK_IMAP_PORT=993 HELPDESK_IMAP_ENCRYPTION=ssl HELPDESK_IMAP_USERNAME=support@example.com HELPDESK_IMAP_PASSWORD=your-password HELPDESK_IMAP_FOLDER=INBOX
Requires the webklex/php-imap package:
composer require webklex/php-imap
Schedule the polling command in your app/Console/Kernel.php or routes/console.php:
$schedule->command('help-desk:poll-imap')->everyFiveMinutes();
#Mailgun
HELPDESK_INBOUND_DRIVER=mailgun HELPDESK_MAILGUN_SIGNING_KEY=your-signing-key
Configure your Mailgun route to forward to:
POST https://your-app.com/help-desk/webhooks/mailgun
#SendGrid
HELPDESK_INBOUND_DRIVER=sendgrid HELPDESK_SENDGRID_WEBHOOK_USERNAME=your-username HELPDESK_SENDGRID_WEBHOOK_PASSWORD=your-password
Configure your SendGrid Inbound Parse to forward to:
POST https://your-app.com/help-desk/webhooks/sendgrid
#Resend
HELPDESK_INBOUND_DRIVER=resend HELPDESK_RESEND_API_KEY=re_your-api-key HELPDESK_RESEND_WEBHOOK_SECRET=whsec_your-webhook-secret
Configure your Resend receiving domain webhook to forward to:
POST https://your-app.com/help-desk/webhooks/resend
Select the email.received event type in your Resend webhook configuration.
#Postmark
HELPDESK_INBOUND_DRIVER=postmark HELPDESK_POSTMARK_WEBHOOK_USERNAME=your-username HELPDESK_POSTMARK_WEBHOOK_PASSWORD=your-password
In your Postmark server, go to the Inbound Message Stream settings and set the webhook URL to:
POST https://your-username:your-password@your-app.com/help-desk/webhooks/postmark
Postmark sends the full email content (body, headers, attachments) directly in the webhook payload. The package also uses Postmark's StrippedTextReply field for cleaner reply parsing.
#Email Channels
You can configure multiple email channels, each mapped to a department:
use JeffersonGoncalves\HelpDesk\Models\EmailChannel; EmailChannel::create([ 'department_id' => $department->id, 'name' => 'Support Inbox', 'driver' => 'mailgun', 'email_address' => 'support@example.com', 'settings' => [], // Driver-specific settings (encrypted) 'is_active' => true, ]);
#Email Threading
When an inbound email is received, the package resolves it to an existing ticket using:
In-Reply-ToheaderReferencesheader- Subject line reference number (e.g.,
HD-00001)
If no match is found, a new ticket is created.
#Artisan Commands
# Poll IMAP mailboxes for new emails php artisan help-desk:poll-imap # Clean old processed inbound emails php artisan help-desk:clean-emails --days=30 # Auto-close stale tickets php artisan help-desk:close-stale --days=14 --status=resolved # Dry run (see what would be closed) php artisan help-desk:close-stale --days=14 --dry-run
#Knowledge Base
KnowledgeBaseService searches published articles by title and body, and records views:
use JeffersonGoncalves\HelpDesk\Services\KnowledgeBaseService; $articles = $knowledgeBase->search('reset password', departmentId: 3); $knowledgeBase->recordView($articles->first());
search() only returns is_published articles, is scoped by app_key the same way tickets are when help-desk.scope_to_app is enabled, and treats %/_/! in the term as literal characters rather than SQL wildcards.
#Knowledge base deflection
There is no dedicated table for tracking which articles were shown to a requester before they decided to open a ticket anyway. TicketService::create() already merges any metadata key passed in $data, so record the suggestion by writing to that same key — no new core method needed:
$ticketService->create([ 'title' => $request->title, 'description' => $request->description, 'department_id' => $request->department_id, 'metadata' => [ 'suggested_articles' => $suggestedArticleIds, // shown before the ticket was opened ], ], $request->user());
Read it back the same way: $ticket->metadata['suggested_articles'] ?? [].
#Using the Services Directly
For more control, you can inject the service classes directly:
use JeffersonGoncalves\HelpDesk\Services\TicketService; use JeffersonGoncalves\HelpDesk\Services\CommentService; use JeffersonGoncalves\HelpDesk\Services\DepartmentService; use JeffersonGoncalves\HelpDesk\Services\AttachmentService; class MyController { public function __construct( private TicketService $tickets, private CommentService $comments, ) {} public function store(Request $request) { $ticket = $this->tickets->create([ 'title' => $request->title, 'description' => $request->description, 'department_id' => $request->department_id, ], $request->user()); return $ticket; } }
#Translation
The package ships with English and Brazilian Portuguese translations. To customize:
php artisan vendor:publish --tag=help-desk-translations
This publishes translation files to lang/vendor/help-desk/. You can modify them or add new locales.
// Using translations in your code __('help-desk::tickets.messages.created') // "Ticket created successfully." __('help-desk::statuses.open') // "Open" __('help-desk::priorities.urgent') // "Urgent"
#Testing
composer test
#Static Analysis
composer analyse
#Code Formatting
composer format
#Changelog
Please see CHANGELOG for more information on what has changed recently.
#Contributing
Please see CONTRIBUTING for details.
#Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
#Credits
#License
The MIT License (MIT). Please see License File for more information.