Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Help Desk Laravel Package

jeffersongoncalves/laravel-help-desk

Laravel Help Desk is a complete ticket management system for Laravel 11–13 with email integration. Create departments, assign operators, and manage ticket statuses, priorities, attachments, and notifications through a simple, configurable package.

View on GitHub
Deep Wiki
Context7

Getting Started

Install in the central/support application first. This package is a service, not a starter kit — there is no UI, no routes you visit, no scaffolding. You build the controllers and views.

composer require jeffersongoncalves/laravel-help-desk
php artisan vendor:publish --tag=help-desk-config
php artisan vendor:publish --tag=help-desk-migrations
php artisan migrate

Where to look first, in this order:

  1. config/help-desk.php — this single file decides almost everything: driver (database vs api), connection, app_key/app_name, scope_to_app, attachment_disk, api.clients. Read it before writing a line of code; half the "why doesn't this work" questions are answered there.
  2. JeffersonGoncalves\HelpDesk\Facades\HelpDesk — the service facade is the API surface. Day-to-day you will call the facade, not the models.
  3. Enums\TicketStatus, Enums\TicketPriority, Enums\HistoryAction — the state machine is an enum, so the legal transitions are enumerable rather than tribal knowledge.
  4. Models\Ticket — only for querying. Mutations go through the facade.

Wire up your User model. Two traits, mutually exclusive in intent:

// Requesters (anyone who can open a ticket)
use JeffersonGoncalves\HelpDesk\Concerns\HasTickets;

// Operators/managers (includes HasTickets)
use JeffersonGoncalves\HelpDesk\Concerns\IsOperator;

Seed one department, then create one ticket. That is the shortest path to a working vertical slice:

use JeffersonGoncalves\HelpDesk\Facades\HelpDesk;

$dept = HelpDesk::createDepartment([
    'name' => 'Technical Support',
    'slug' => 'technical-support',
    'email' => '[email protected]',
    'is_active' => true,
]);

HelpDesk::addOperator($dept, $operator, 'operator'); // operator | manager | admin

$ticket = HelpDesk::createTicket([
    'title' => 'Cannot access my account',
    'description' => 'I get an error when trying to log in...',
    'department_id' => $dept->id,
    'priority' => 'high',
    'company_id' => $company->id,        // optional, since v1.13
    'company_name' => $company->name,    // optional, snapshotted into metadata.company.name
], $user);

You get uuid, reference_number (HD-00001) and status = Open for free. reference_number is the human-facing identifier — route your public URLs on uuid and show reference_number in copy, not vice versa.

First realistic use case: requester submits a form → ticket is created → operator sees it in "unassigned, by priority" → operator replies with addComment, requester replies by email → operator closes. Build that before touching email inbound, attachments, or the API driver.

One caveat worth stating plainly: at 12 stars and zero dependents, treat the first afternoon as a spike. Pin the version in composer.json, and confirm php artisan migrate lands the tables you expect before designing screens around the schema.

Implementation Patterns

Facade-first, model-second. The facade is a service layer that enforces the state machine, writes history, and fires notifications. Eloquent writes skip all of it. The correct split:

HelpDesk::changeStatus($ticket, TicketStatus::InProgress);  // facade — validated, logged
HelpDesk::updateTicket($ticket, ['priority' => 'urgent']);   // facade — logged
$ticket->isOverdue();                                        // model — read only
Ticket::overdue()->get();                                    // model — query only

Let the status enum drive your UI. Because invalid transitions throw InvalidStatusTransitionException, render the action buttons from the allowed transitions rather than showing a dropdown of every status. Closed tickets go only to Open (reopen); Resolved goes only to Open or Closed. Catching the exception as a user-facing validation error is the fallback, not the design.

Comments are one table with three kinds. Do not build separate "replies" and "notes" tables — pick the right writer and the right scope:

HelpDesk::addComment($ticket, $user, 'Thanks for reaching out.');        // reply
HelpDesk::addNote($ticket, $operator, 'Escalating to senior engineer.'); // internal
HelpDesk::addComment($ticket, $user, 'See screenshot.', [
    'attachments' => [$uploadedFile],
]);

Then filter by audience on the read side: ->public() for the requester view, ->internal() for the agent view, ->notes() / ->replies() when you need a specific kind. This is the single most useful design decision in the package for day-to-day work — the visibility rule is expressed as a query scope instead of an if in Blade.

Attachments: two entry points. Use the addComment(['attachments' => ...]) array when the file belongs to the comment you are creating. Use the service when it does not:

$service = HelpDesk::attachments();

if (! $service->isAllowedExtension($file->getClientOriginalExtension())
    || ! $service->isWithinSizeLimit($file->getSize() / 1024)) {
    // reject with your own message
}

$attachment = $service->store($ticket, $file, $user, $comment);       // upload
$attachment = $service->storeFromPath($ticket, $path, $name, $mime, $size, $user); // disk / inbound mail
$service->delete($attachment, $operator);                              // row + file

Neither guard method enforces anything — they are predicates for your own validation layer. That is deliberate and, for a multi-tenant app with per-plan limits, the right call.

Read tickets through scopes, not hand-written wheres. open(), closed(), byStatus(), byPriority(), overdue(), unassigned() compose normally, so an operator queue is one line:

Ticket::open()->unassigned()->byPriority(TicketPriority::Urgent)->latest()->paginate(25);

Company is a first-class filter, as of v1.13. company_id is stored on the ticket and is queryable; company_name is a snapshot kept alongside it in metadata.company.name, so a renamed or deleted company does not rewrite ticket history:

Ticket::where('company_id', $company->id)->open()->paginate(25);

$ticket->company_id;    // stable key — filter and group on this
$ticket->company_name;  // display — snapshot string, falls back to company_id

Treat company_id as the join key and company_name as presentation, exactly like user_id vs requester_name. If your tenancy column is named differently (empresa_id), map it once with an accessor on the model rather than sprinkling where clauses — the README's Multi-company Installations section covers this, the null company caveat, and the filament-help-desk integration.

Use the user relationships for the two dashboards rather than joining: $user->helpDeskTickets for the requester portal, $operator->helpDeskAssignedTickets and $operator->helpDeskDepartments for the agent console.

Always read people (and companies) through the snapshot accessors. This is the pattern the package wants and the one most likely to bite you. Store the accessor, never the morph:

$ticket->requester_name;      $ticket->requester();
$ticket->company_name;        // no resolver — pure snapshot with company_id fallback
$comment->author_name;        $comment->resolvedAuthor();
$attachment->uploader_name;   $attachment->resolvedUploadedBy();
$entry->performer_name;       $entry->resolvedPerformer();
$row->watcher_name;           $row->resolvedWatcher();

Each has an _email counterpart. The snapshot is written at action time, so renames and deletions do not rewrite history — which is exactly what an audit log needs. If your user model's display fields are named differently, override toHelpDeskSnapshot() on the model once and every accessor follows.

History is a subscriber, not a manual call. LogTicketHistory records status changes, assignments, comments and attachments — provided the default listeners are registered. Verify that after install; if history rows are missing, this is the first thing to check. Then render the timeline with the field/old_value/new_value triple and the HistoryAction enum, which makes a generic "changed {field} from {old} to {new}" renderer trivial.

Watchers are idempotent. addWatcher twice is a no-op, so you can call it defensively from a controller without a existence check. Auto-watch the requester and the assignee at creation and the operator's "my tickets" filter falls out for free.

Choose the transport deliberately, and early. database means the help desk shares a central database connection — everything works. api means a satellite holds no DB credentials and talks signed HTTP to the central app — end-user side only. The facade signature does not change, so satellite code reads identically; but operator actions and Eloquent queries will not exist there. If you are building a satellite and an admin console in the same codebase, split them now rather than discovering assignTicket throws in production.

For shared-database satellites, configure all five things at once — they are a set, and getting four of five produces confusing partial failures: connection set, migrations not published, distinct morph map per app, accessors for people, shared attachment_disk, and scope_to_app=false only on the central app. Relation::enforceMorphMap(['app-a-user' => \App\Models\User::class]) in AppServiceProvider::boot() is the one that silently corrupts data if skipped — user #5 in two applications becomes the same requester key.

API clients: two secrets, current first. 'secrets' => [env('HELPDESK_SECRET_APP_A'), env('HELPDESK_SECRET_APP_A_PREVIOUS')] lets you rotate without a flag day. And note the trust model: the signature proves which application is calling; the acting user is asserted by that application. A leaked secret impersonates any user of that app — so keep the secrets per-app, and keep HTTPS mandatory (HMAC gives integrity, not confidentiality).

Gotchas and Tips

$ticket->user can be fatal, not null. If the stored morph type names a class absent from this application, Eloquent instantiates it and PHP raises Class "..." not found. This is the number-one crash in multi-app setups and it will not show up in your local single-app tests. Rule: in views and serializers, use requester_name / author_name / uploader_name / performer_name / watcher_name. Use the resolved*() methods when you need the model, and handle null — system comments and system history entries legitimately have no person.

company_name is not always a name. Tickets created before v1.13 fall back to company_id from the accessor, so a mixed-age table will render "Acme Ltd" for new rows and "42" for old ones. If that matters, format defensively (or backfill metadata.company.name yourself — no migration was needed for the feature, and none is provided for the backfill). Also expect null/absent company on tickets that belong to no company at all; do not assume company_id is non-null when scoping dashboards.

API company_id arrives as a string. The api driver casts an integer company_id to string on the way out (and TicketResource exposes company_name). Use loose comparison or cast explicitly in satellite code — a strict === against your local integer id will silently fail. Conversely, POST tickets accepts company_id and company_name; reads stay scoped to the requester, so sending a company does not widen what the client can see.

Notifications survive a missing user. Ticket::notifyRequester() goes through the model when it resolves and falls back to an on-demand mail to the snapshot address otherwise. That is what lets a central app reply to a requester from a satellite it cannot load — lean on it instead of guarding with if ($ticket->user).

getTemporaryUrl() needs S3 or similar. On the default local disk it throws. If your plan was "signed URLs on local storage," change the plan or change attachment_disk. Related: on the api driver, getUrl() throws too (no disk to serve from) — fetch bytes with HelpDesk::attachments()->contents($attachment, $ticket->uuid) and stream them yourself.

On the api driver, $ticket->comments throws unless the response carried the relation. HelpDesk::tickets()->findByUuid($uuid) returns the show-endpoint payload with comments attached, so fetch-by-uuid is the supported path for a detail screen. Any other relation access raises HelpDeskApiException telling you it needs the database driver — read that message literally, it is accurate.

Satellites must not run the help desk migrations. Laravel tracks applied migrations in each application's own default connection, so a satellite will happily try to CREATE TABLE against a schema the central app already owns. Publish the config, set the connection, skip the migrations. Put this in your deploy docs, not just a PR comment.

scope_to_app is central-only-off. The central application needs to see every ticket, so leave scope_to_app false there and true on satellites. Combined with per-app morph aliases, this is the whole tenancy story — there is no separate tenant column to reason about. Company filtering through company_id is your concern layered on top, not something scope_to_app does for you.

No configured API clients means no routes at all. If your API endpoints 404 rather than 401, the api.clients array is empty. That is intentional (fail closed), but it reads as a routing bug at 2am.

driver decides what is possible — check it before writing code. Writing a comment form and then discovering addNote throws on api is a wasted afternoon. Cheapest guard: a single config('help-desk.driver') === 'database' check gating the operator UI.

Status changes are the only mutations that reject you. updateTicket on priority, category_id or due_at is permissive; changeStatus is not. Route every status mutation through changeStatus/closeTicket/reopenTicket and catch InvalidStatusTransitionException at the controller boundary to produce a 422 rather than a 500.

findTicketByReference / findTicketByUuid throw TicketNotFoundException rather than returning null. That is convenient for route model binding (map it to a 404), less convenient in a search box — wrap accordingly.

Missing history is a listener problem, not a data problem. If the timeline is empty, check that the default subscribers are registered before writing a backfill.

Extension points, in order of usefulness: toHelpDeskSnapshot() on your User model (display fields and email); a company_id / empresa_id accessor on the Ticket model for multi-company naming; the help-desk.connection config for schema placement; the attachment_disk config for storage; api.clients[].secrets for rotation; and the HistoryAction / TicketStatus enums for mapping to your own UI labels. There is no documented interface for swapping the attachment service or the status machine, so plan to work with those defaults rather than replace them.

Build the email channel last. Inbound email is the part most likely to need your own glue (mailbox polling, MIME parsing, storeFromPath for the parts). The package gives you the storage primitive — storeFromPath($ticket, $path, $name, $mime, $size, $user) — and the snapshot-based reply path; the transport is yours. Ship the web form first.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings