a2zwebltd/laravel-customer-support
Portable Laravel helpdesk engine with ticketing, threaded replies, attachments, internal notes, agent assignment, SLA tracking, mail notifications, a Livewire + Flux UI, and optional Nova admin resources.
Decide whether you actually want the UI before you install anything. This package is two products bolted together: a well-shaped domain layer (models, enums, actions, events, SLA maths, mail) and an opinionated Livewire + Flux front end. The domain layer drops into almost any Laravel 11/12/13 app. The UI only makes sense if you are already on Livewire 3/4 and Flux 2 — the Blade templates lean on Flux components and inherit its --color-accent tokens. If you're not on Flux, budget for restyling everything or plan to skip the bundled views entirely and build your own screens against the actions.
Hard gates, check them first:
livewire/livewire ^3|^4 and livewire/flux ^2 (for the bundled UI)spatie/laravel-medialibrary ^11 — the media table must existlaravel/nova ^5 — optional, resources auto-register when presentcomposer require a2zwebltd/laravel-customer-support
php artisan migrate # support_tickets, support_ticket_messages (+ medialibrary's media)
php artisan vendor:publish --tag=customer-support-config
Four pieces of wiring, then you're live:
// 1. User model
use A2ZWeb\CustomerSupport\Concerns\HasSupportTickets;
class User extends Authenticatable
{
use HasSupportTickets; // supportTickets(), assignedSupportTickets(), supportTicketMessages()
}
Note HasMedia is not required on User — the ticket and message models carry the media.
// 2. AppServiceProvider::boot() — the agent gate
Gate::define('manage-support-tickets', fn (User $user): bool => $user->is_admin);
# 3. Layout + mail + private attachments
CUSTOMER_SUPPORT_LAYOUT=components.layouts.app # must resolve to an existing view
[email protected],[email protected]
/* 4. resources/css/app.css — Tailwind v4, or the dashboard renders unstyled */
@source '../../vendor/a2zwebltd/laravel-customer-support/resources/views/**/*.blade.php';
Then two operational prerequisites that are easy to forget and look like package bugs when missing: run a queue worker (every mail implements ShouldQueue) and register the scheduler:
// routes/console.php
Schedule::command('support:escalate-overdue')->hourly();
Where to look first. Read config/customer-support.php end to end — it's short and nearly every key has a trap (see below). Then the five action classes in A2ZWeb\CustomerSupport\Actions\ and the three enums. Those are the real public API; the Livewire components are just one consumer of them. Skip the views on day one — you will restyle, not customise.
Best first use case (half a day): an internal support inbox. A customer hits /support/new, submits; SupportTicket::creating fills ticket_number (CS-000001), status = Open, priority = Normal, last_activity_at, and due_at = now + slaHours(). An agent finds it on /support/admin under Unassigned, assigns it, replies publicly, customer gets mail. You have a working helpdesk — and, more importantly, a working action layer you can then call from anywhere.
Add a sidebar link yourself, because the host app owns navigation:
<flux:sidebar.item icon="lifebuoy" :href="route('support.index')" :current="request()->routeIs('support.*')" wire:navigate>
{{ __('Customer Support') }}
</flux:sidebar.item>
Never write tickets or messages directly. The actions exist so that number generation, SLA dates, domain events, and notifications stay consistent:
use A2ZWeb\CustomerSupport\Actions\CreateTicket;
use A2ZWeb\CustomerSupport\DataTransferObjects\TicketData;
use A2ZWeb\CustomerSupport\Enums\{TicketCategory, TicketPriority};
$ticket = app(CreateTicket::class)->execute(new TicketData(
userId: $user->id,
subject: $request->subject,
description: $request->description,
category: TicketCategory::Billing,
priority: TicketPriority::High,
attachments: $request->file('attachments', []),
metadata: ['source' => 'in_app', 'plan' => $user->plan],
));
| Action | Signature |
|---|---|
CreateTicket |
execute(TicketData $data): SupportTicket |
ReplyToTicket |
execute(MessageData $data): SupportTicketMessage |
AddInternalNote |
execute(int $ticketId, int $userId, string $body, array $attachments = []): SupportTicketMessage |
ChangeTicketStatus |
execute(SupportTicket $ticket, TicketStatus $status, ?int $changedByUserId = null): SupportTicket |
AssignTicket |
execute(SupportTicket $ticket, ?int $assigneeId): SupportTicket |
This is the single biggest day-to-day win: an artisan command that bulk-closes stale tickets, a queued job off a webhook, a Nova action, a Pest test — all funnel through the same five calls, and none of them reimplement "set due_at" or "fire TicketStatusChanged".
Use the DTOs as your intake adapter. TicketData and MessageData are plain value objects, so an inbound-email command or a signature-verified webhook is roughly ten lines:
$ticket = app(CreateTicket::class)->execute(new TicketData(
userId: User::where('email', $inbound->from)->value('id'),
subject: Str::limit($inbound->subject, 120),
description: $inbound->textBody,
category: TicketCategory::Other,
metadata: ['channel' => 'email', 'message_id' => $inbound->messageId],
));
metadata is a JSON column and a fine place to stash channel ids — but expect to outgrow it and add real columns once you're filtering or reporting on it.
Treat the four events as your integration seam. This is how you extend the package without touching vendor code:
// TicketCreated($ticket)
// TicketReplied($ticket, $message)
// TicketStatusChanged($ticket, TicketStatus $previous, TicketStatus $current, ?int $changedByUserId)
// TicketAssigned($ticket, ?int $assigneeId, ?int $previousAssigneeId)
Event::listen(TicketCreated::class, fn ($e) => DispatchToSlack::dispatch($e->ticket)->afterCommit());
Event::listen(TicketStatusChanged::class, function ($e) {
if ($e->current === TicketStatus::Resolved) {
RecordFirstResponseTime::dispatch($e->ticket);
}
});
They are dispatched inside the action's transaction, so any listener touching an external service belongs on the queue with ->afterCommit() (or a queue connection with after_commit => true). A synchronous HTTP call to Slack inside TicketCreated will hold the insert open and, if it times out, roll back the customer's ticket.
Distinguish internal notes from public replies in the listener. TicketReplied fires for both; only the flag tells you:
Event::listen(TicketReplied::class, function ($e) {
if ($e->message->is_internal_note) return;
NotifyCustomerOfReply::dispatch($e->message);
});
Build the agent console on the scopes, not raw queries. SupportTicket ships open(), closed(), unassigned(), overdue(), needsOverdueNotification(), forUser($id), plus isOverdue(). The bundled All / Unassigned / Mine / Overdue tabs are literally those chained. If — when — you replace the dashboard with your own, keep your queries on the scopes so a package upgrade doesn't silently change what "overdue" means under you.
Use SlaTracking for anything user-facing rather than recomputing deltas from due_at:
use A2ZWeb\CustomerSupport\Support\SlaTracking;
$sla = SlaTracking::forTicket($ticket);
$sla->dueAt; $sla->breached; $sla->minutesRemaining; $sla->minutesOverdue; $sla->humanLabel();
humanLabel() is what you want next to a ticket row; breached is what you want in a filter.
Serve attachments privately. Point attachments.disk at local or a private S3 bucket. Files stream through the authenticated support.attachment route, so no public URL is needed — and none should be generated. Accept UploadedFile or Livewire TemporaryUploadedFile interchangeably; the actions handle both.
Localise mail per recipient. Implement Illuminate\Contracts\Translation\HasLocalePreference on User (preferredLocale()) if you serve multiple languages; translate the package's English keys in lang/<locale>.json.
Testing is unusually pleasant here because factories carry the states and the actions dispatch real events:
$ticket = SupportTicket::factory()->overdue()->urgent()->unassigned()->create();
Event::fake([TicketReplied::class]);
app(ReplyToTicket::class)->execute(new MessageData(
ticketId: $ticket->id, userId: $agent->id, body: 'On it.', isInternalNote: true,
));
Event::assertDispatched(TicketReplied::class, fn ($e) => $e->message->is_internal_note);
An undefined gate means nobody is an agent — silently. Not a 500, not a warning: the admin dashboard just isn't reachable and you'll spend an hour on middleware before remembering admin_gate. Verify with Gate::forUser($admin)->allows('manage-support-tickets') in tinker before debugging routes.
ticket_number is derived from max(id) + 1. Fine for the normal insert path, but it breaks if you seed/import tickets with explicit IDs, and it's a race under genuinely concurrent creation. If you need gapless or high-throughput numbering, this is the one place you should consider overriding creating yourself — and then never use the CreateTicket action with a pre-set number.
due_at is set once, at creation. Changing sla_hours in config does not retroactively move existing deadlines; changing a ticket's priority does not recalculate the deadline either. Both are defensible, both surprise people. If you need "urgent → 1 hour from now", do it explicitly.
Categories are keyed by enum value, and the failure modes differ per surface. The form silently saves unknown keys as other; Nova throws a ValueError. Relabel, re-icon, reorder, or remove — but don't invent keys. Labels pass through __(), so custom labels are a translation concern, not a config concern.
The route name prefix keeps its trailing dot. Views build route names from routes.name_prefix; strip the dot and every route('support.show') call blows up. If you set routes.enabled to false, declare all five routes yourself under the same names — support.attachment before support.show, or /support/{ticket} will swallow /support/{ticket}/attachments/{media}.
Pin the version and read the migrations. Two stars, zero dependents, a single maintainer crew, and a domain layer that owns your users' conversations. That's not a reason to avoid it — a small, MIT, action-oriented package is often a better bet than a heavyweight helpdesk SaaS — but it is a reason to composer require a2zwebltd/laravel-customer-support:^1.2, read every migration before running it in production, and know that your escape hatch (the five actions and four events, which are narrow enough to reimplement or fork) exists.
Don't publish the views unless you must. Publishing makes upgrades a diff-and-merge exercise across dozens of Blade files. You can get most of the way there with CSS (theme.accent, Flux tokens) and by replacing individual Livewire components with your own screens that call the same actions. If you do publish, add the second @source glob for resources/views/vendor/customer-support/**/*.blade.php or your published views render unstyled.
Escalation without duplicates is on you. EscalateOverdueTickets and the needsOverdueNotification() scope exist so the hourly cron doesn't re-email the same ticket forever — but if you write your own escalation job for richer routing (per-team paging, Slack, on-call), reuse that scope rather than overdue() alone, or you will spam agents every hour.
Multi-tenancy is not built in. There's forUser($id) and policies, but no global scope. In a multi-tenant app you must add your own global scope on SupportTicket/SupportTicketMessage and audit the bundled routes for tenant leakage — the /support/admin dashboard in particular queries broadly.
Verify the Tailwind build actually picked up the package. grep -o 'color-accent' public/build/assets/app-*.css is the fastest sanity check. A rendered-but-monochrome dashboard is almost always a missing @source line, not a broken component.
How can I help you explore Laravel packages today?