A Laravel ticketing system needs five moving parts to survive contact with real customers. A tickets table with a proper status enum, a threaded messages table that separates public replies from internal notes, Policies that decide who reads what, queued notifications so nothing blocks a web request, and SLA timestamps you can query. Everything after that is interface.

Most tutorials stop at CRUD. You get a tickets table, a status column holding free text strings, and a Blade list view. That version breaks the first week an agent goes on holiday and nobody can tell which tickets moved.

You already know the shape of this problem, because you have watched a shared support inbox turn into a guessing game. This guide covers the parts that decide whether the build holds up. Schema, lifecycle rules, authorization, SLA timers, the agent interface, and the handful of tests worth writing. Every snippet is standard Laravel 11 and 12 code you can paste into an app you already run.

Key TakeawaysA production Laravel ticketing system needs four core tables, tickets, ticket_messages, attachments, and a teams table for routing.Store status and priority as backed PHP enums with an explicit transition guard so impossible states never reach the database.Authorization belongs in a TicketPolicy, because requesters, assigned agents, and supervisors need three different views of the same record.Track SLAs with two timestamp columns, first_response_due_at and resolution_due_at, checked by a scheduled queued job instead of a live calculation on every page load.Filament generates the agent interface in minutes, which leaves your time for routing rules, escalation, and email ingestion.

What a Laravel ticketing system actually needs

A helpdesk is a queue with rules attached. Strip the branding away and every Laravel ticketing system tracks the same six facts. Who asked, what they asked, who owns it now, what state it sits in, when the clock runs out, and everything said along the way.

Write those down before you touch a migration. Teams that skip the step bolt a status column onto an existing contact form, then discover a quarter later that they cannot answer a simple question about response times without a data cleanup.

A three person agency ran support from a shared mailbox for two years. Work got done, but none of it was countable. When a retainer client asked for response time evidence at renewal, the team had nothing beyond a folder of forwarded threads. They spent a weekend building a support ticket system in Laravel, pointed the mailbox at it, and the next renewal conversation opened with a report instead of an apology.

Concern

Where it lives

Why it matters

Identity

tickets.reference

Customers quote a readable reference, not an auto increment ID

Ownership

tickets.assignee_id

Unassigned work is invisible work

State

tickets.status

Routing and reporting both depend on a fixed vocabulary

Conversation

ticket_messages

Public replies and internal notes share one timeline

Urgency

SLA timestamp columns

You cannot escalate what you never measured

Curious how much of this scaffolding you can skip on the first pass? Try StackMivo Now and describe the helpdesk you want in plain English.

Design the database schema for your Laravel ticketing system

Start with four tables. Tickets, ticket_messages, ticket_attachments, and teams. Anything else can arrive later without a painful migration, as long as these four are right.

php artisan make:model Ticket -mfsc --policy
php artisan make:model TicketMessage -mf
php artisan make:enum TicketStatus
php artisan make:notification FirstResponseBreached

The make:enum command ships with Laravel 11 and later. If you want the full flag list for model generation, the php artisan make:model cheatsheet covers every combination.

Schema::create('tickets', function (Blueprint $table) {
 $table->id();
 $table->string('reference')->unique();
 $table->foreignId('requester_id')->constrained('users');
 $table->foreignId('assignee_id')->nullable()->constrained('users')->nullOnDelete();
 $table->foreignId('team_id')->nullable()->constrained()->nullOnDelete();
 $table->string('subject');
 $table->string('status')->default('open');
 $table->string('priority')->default('normal');
 $table->string('channel')->default('web');
 $table->timestamp('first_response_due_at')->nullable();
 $table->timestamp('first_responded_at')->nullable();
 $table->timestamp('resolution_due_at')->nullable();
 $table->timestamp('resolved_at')->nullable();
 $table->timestamps();

 $table->index(['status', 'assignee_id']);
 $table->index(['team_id', 'status']);
});

Three choices in there earn their keep. The reference column gives customers something to quote that does not leak how many tickets you have handled. A nullable assignee_id models the real world, where tickets land in a queue before a person owns them. The composite indexes match the two queries your agent dashboard runs constantly, my open tickets and my team backlog.

Keep public replies and internal notes in one thread

Agents need somewhere to write things the customer must never read. Splitting that into a second table looks tidy until you try to render one chronological timeline. Use a single ticket_messages table with an is_internal boolean, an author_id, a body, and the channel the message arrived through.

class Ticket extends Model
{
 protected $fillable = [
 'reference', 'requester_id', 'assignee_id', 'team_id',
 'subject', 'status', 'priority', 'channel',
 ];

 protected function casts(): array
 {
 return [
 'status' => TicketStatus::class,
 'priority' => TicketPriority::class,
 'first_response_due_at' => 'datetime',
 'first_responded_at' => 'datetime',
 'resolution_due_at' => 'datetime',
 'resolved_at' => 'datetime',
 ];
 }

 public function requester(): BelongsTo
 {
 return $this->belongsTo(User::class, 'requester_id');
 }

 public function assignee(): BelongsTo
 {
 return $this->belongsTo(User::class, 'assignee_id');
 }

 public function messages(): HasMany
 {
 return $this->hasMany(TicketMessage::class)->oldest();
 }

 public function publicMessages(): HasMany
 {
 return $this->messages()->where('is_internal', false);
 }
}

Two relationships, one filtered. The customer portal reads publicMessages, the agent view reads messages, and the same records back both. The Laravel relationship documentation covers the constrained variants if you later add per team visibility.

Model the ticket lifecycle with statuses and policies

Free text status columns are where helpdesk projects go quietly wrong. One controller writes resolved, a seeder writes Resolved, an import writes closed_by_agent, and every report after that needs a translation layer. A backed enum with a transition guard removes the whole category of bug.

enum TicketStatus: string
{
 case Open = 'open';
 case Pending = 'pending';
 case OnHold = 'on_hold';
 case Resolved = 'resolved';
 case Closed = 'closed';

 public function canMoveTo(self $next): bool
 {
 $allowed = match ($this) {
 self::Open => [self::Pending, self::OnHold, self::Resolved],
 self::Pending => [self::Open, self::OnHold, self::Resolved],
 self::OnHold => [self::Open, self::Pending, self::Resolved],
 self::Resolved => [self::Open, self::Closed],
 self::Closed => [self::Open],
 };

 return in_array($next, $allowed, true);
 }
}

A Laravel team lead inherited a helpdesk where status was a string and any controller could set it. Tickets sat in states nobody had designed, including closed tickets that were still counting toward an SLA report. Replacing the column with an enum and a single guard method took an afternoon. The reporting bugs that had been filed for months disappeared with it.

Authorization is the second half of the lifecycle. A requester sees their own tickets and public replies. An assigned agent sees internal notes. A supervisor reassigns anything. Put those rules in one Policy so no controller has to remember them.

class TicketPolicy
{
 public function view(User $user, Ticket $ticket): bool
 {
 return $user->isAgent() || $user->id === $ticket->requester_id;
 }

 public function reply(User $user, Ticket $ticket): bool
 {
 return $this->view($user, $ticket)
 && $ticket->status!== TicketStatus::Closed;
 }

 public function addInternalNote(User $user, Ticket $ticket): bool
 {
 return $user->isAgent();
 }

 public function reassign(User $user, Ticket $ticket): bool
 {
 return $user->isSupervisor() || $user->id === $ticket->assignee_id;
 }
}

Model roles however you like, a boolean column or a package. What matters is that the Policy stays the only place those answers live.

Add SLA timers, assignment, and queued notifications

SLA tracking is where a Laravel ticketing system stops being a CRUD app. Resist the urge to calculate deadlines on the fly. Write first_response_due_at and resolution_due_at when the ticket is created, based on its priority, then query those columns like any other data.

Route new tickets to the right agent

Assignment has three common shapes. Round robin within a team, load based on open ticket count, or keyword routing that reads the subject and picks a department. Start with team routing plus a manual claim button. It is honest about how small teams really work, and you can layer automation on later without changing the schema.

Breach detection belongs on a schedule, not in a request cycle.

class EscalateBreachedTickets implements ShouldQueue
{
 use Queueable;

 public function handle(): void
 {
 Ticket::query()
 ->whereIn('status', [TicketStatus::Open, TicketStatus::Pending])
 ->whereNull('first_responded_at')
 ->wherePast('first_response_due_at')
 ->with('assignee', 'team')
 ->eachById(function (Ticket $ticket) {
 $ticket->assignee?->notify(new FirstResponseBreached($ticket));
 });
 }
}
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::job(new EscalateBreachedTickets)->everyFiveMinutes();

The wherePast() helper arrived in Laravel 11. On Laravel 9 or 10, swap it for a plain comparison against now(). Either way, run this through a queue worker so a slow mail provider never delays an agent clicking reply. The queue documentation covers the supervisor configuration you need in production.

Email ingestion is the other half of the channel story. Inbound mail arrives through a webhook from your mail provider, gets parsed for the ticket reference in the subject, and appends a message to the existing thread or opens a new one. Treat it as a controller that validates a signature and dispatches a job. Helpdesks are one of the most common internal tools teams build in Laravel, and inbound email is usually what turns a form into a real support channel.

Build the agent interface with Filament

The agent side of a helpdesk is a table, a filter set, a detail view, and a reply box. That is exactly what Filament generates, which makes it the fastest path from schema to working console.

php artisan make:filament-resource Ticket --generate

Customize three things after generation. Add a status filter and an assigned to me filter, because those are the two views agents live in. Render the message thread as a relation manager so replies and internal notes post inline. Add a badge column for the SLA clock, green, amber, and breached, so the queue sorts itself visually. The Filament documentation walks through relation managers and custom columns.

The customer portal is a different animal. Keep it small, a list of their own tickets, one detail page, one reply form, and nothing else. The data backbone is close to what you would build for pipelines and contacts, so if you have already read how to build a CRM with Laravel, the patterns will feel familiar.

Test the rules that matter

You do not need full coverage. You need proof that a stranger cannot read a ticket and that the state machine holds.

it('stops a customer from reading another customer ticket', function () {
 $ticket = Ticket::factory()->create();
 $stranger = User::factory()->create();

 actingAs($stranger)
 ->get(route('tickets.show', $ticket))
 ->assertForbidden();
});

it('refuses an invalid status transition', function () {
 expect(TicketStatus::Closed->canMoveTo(TicketStatus::Resolved))->toBeFalse();
});

Two tests, ten minutes, and the two failures that would actually embarrass you in front of a customer are now caught by CI.

Generate a Laravel ticketing system with AI and ship it

Everything above is roughly a week of focused work for one developer. Most of that week is scaffolding you have written before. Migrations, Policies, FormRequests, resource controllers, notifications, factories, and tests, all shaped by conventions you already know by heart.

That is the part worth handing off. StackMivo is built on Laravel-native intelligence, trained on the ecosystem rather than generic PHP, so the output follows Eloquent, Artisan, and Filament conventions instead of approximating them. You describe the helpdesk you want and get production-ready Laravel apps back, models, migrations, controllers, CRUD, auth, and tests as standard code you own and can edit.

Build a support ticketing module with tickets, threaded messages, internal notes, agent assignment by team, priority based SLA deadlines, a Filament admin resource, and Pest tests for the authorization rules.

That paragraph is the whole brief. Review what comes back, refine it in the same chat, then use one-click deployment to push to Laravel Cloud, Forge, Ploi, or your own SSH server with migrations running automatically. You can also Get started free and point it at an existing repository, where it indexes your models and routes before writing anything new.

Ship a Laravel ticketing system your team will trust

A working helpdesk comes down to five decisions. Four tables instead of one, a status enum with a transition guard instead of free text, a single Policy instead of scattered permission checks, SLA deadlines stored as timestamps instead of calculated on demand, and a queued job doing the escalation work. Get those right and the interface becomes a weekend problem rather than a rewrite.

The helpdesk Laravel teams end up maintaining looks very different from the tutorial version, mostly because it survives an agent going on leave, a customer replying to a three month old thread, and a manager asking for numbers at renewal time. Your next step is small. Open a migration, write the tickets table from the schema above, and add the enum before you write a single controller.

Build it once, own the code, and the support workflow stops being something your team apologizes for.