Customer issues arrive through email, live chat, social DMs, and the occasional phone call. Nobody knows who is handling what, the same complaint gets two different answers, and another one slips through until the customer is furious. That is rarely a people problem. It is the lack of one place to log and track every request.
A ticket system fixes that: every request becomes a record with a number, a status, an owner, and a deadline. This guide covers the features you actually need, the database design, Laravel 11 code for the core pieces, and when an off-the-shelf help desk is the smarter choice.
The Core Features
Do not start from an enterprise help desk feature list. For a team of one to ten people, these six cover most needs:
- A unique, speakable ticket number such as
TKT-2610-0042, so customers and staff refer to the same thing. - Clear statuses: open, in progress, waiting on customer, resolved, closed.
- Priorities: low, normal, high, urgent, each with a written definition.
- Assignment to one agent, not to "the team."
- A threaded conversation, including internal notes customers never see.
- SLA targets: first-response and resolution deadlines by priority.
Build or Buy?
| Criteria | SaaS help desk (Zendesk, Freshdesk, Help Scout) | Open source (osTicket, FreeScout) | Custom Laravel build |
|---|---|---|---|
| Time to launch | Hours | A day or two | Several weeks |
| Cost | Per-agent monthly subscription | Hosting only | Developer time + hosting |
| Access to your app's data | Via API or integrations | Requires modification | Same database, direct |
| Workflow customization | Within settings | Possible, someone else's code | Unlimited |
| Best for | Dedicated support teams | Tight budgets | Tickets tied to your product and accounts |
Building makes sense when tickets must live next to your application data, for example when a customer opens a ticket from an order page and the agent instantly sees payment and license status without switching tools.
Database Design
Two new tables plus your existing users table are enough for version one. If relational modeling is new to you, start with designing a simple ERD.
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->string('number', 20)->unique();
$table->foreignId('user_id')->constrained();
$table->foreignId('assigned_to')->nullable()->constrained('users');
$table->string('subject');
$table->string('category', 50)->nullable();
$table->enum('priority', ['low', 'normal', 'high', 'urgent'])->default('normal');
$table->enum('status', ['open', 'in_progress', 'waiting_customer', 'resolved', 'closed'])->default('open');
$table->timestamp('first_response_due_at')->nullable();
$table->timestamp('resolution_due_at')->nullable();
$table->timestamp('first_responded_at')->nullable();
$table->timestamp('resolved_at')->nullable();
$table->timestamps();
$table->index(['status', 'priority']);
});
Schema::create('ticket_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained();
$table->text('body');
$table->boolean('is_internal')->default(false);
$table->timestamps();
});
Keep first_responded_at and resolved_at separate from updated_at. Without them you cannot measure SLA compliance, because updated_at changes on every small edit.
Ticket Numbers and SLA Deadlines
Compute the number and deadlines in one place, such as the model's creating event:
class Ticket extends Model
{
protected $guarded = [];
// hours: [first response, resolution]
public const SLA_HOURS = [
'urgent' => [1, 8],
'high' => [4, 24],
'normal' => [8, 72],
'low' => [24, 120],
];
protected static function booted(): void
{
static::creating(function (Ticket $ticket) {
$ticket->number ??= 'TKT-' . now()->format('ym') . '-' . strtoupper(Str::random(5));
[$respond, $resolve] = self::SLA_HOURS[$ticket->priority ?? 'normal'];
$ticket->first_response_due_at = now()->addHours($respond);
$ticket->resolution_due_at = now()->addHours($resolve);
});
}
}
These SLA numbers are examples only. Set targets your team can actually hit; an SLA everyone routinely misses is worse than none, because breaking it becomes normal. If you support customers across time zones, consider whether deadlines count calendar hours or business hours, and document the choice.
When an agent sends a public reply, set first_responded_at if it is still empty and move the status to waiting_customer. Internal notes should never count as a response.
Notifications and SLA Reminders
- To the customer: ticket received (with its number), new reply, ticket resolved.
- To the agent: ticket assigned, customer replied.
- To a supervisor: ticket approaching or past its SLA deadline.
Send mail through a queue so pages stay fast; see Laravel queues and jobs. For SLA warnings, schedule a check in routes/console.php:
Schedule::call(function () {
Ticket::whereIn('status', ['open', 'in_progress'])
->whereNull('first_responded_at')
->where('first_response_due_at', '<=', now()->addMinutes(30))
->each(fn ($t) => Notification::route('mail', config('support.supervisor_email'))
->notify(new SlaWarning($t)));
})->everyFifteenMinutes();
In production, add a flag column such as sla_warned_at so the same warning is not resent every 15 minutes.
Common Mistakes
- Too many statuses. Ten options confuse agents; five is enough.
- Letting customers set priority. Nearly everyone picks "urgent." Let customers choose a category, and let agents or rules decide priority.
- Tickets without an owner. Assign every ticket to one named person.
- Missing authorization. Customer A must not open customer B's ticket by changing an ID in the URL. Use a Policy that checks
user_id; see web application security best practices. - Auto-closing too aggressively. Closing "waiting on customer" tickets after a few quiet days is fine, but warn the customer first.
A Knowledge Base to Reduce Volume
After a month or two, group tickets by category. Questions that keep repeating, such as password resets or installation steps, deserve help articles. Suggest matching articles on the ticket form based on the subject line, and some customers will solve the issue before they hit submit.
Metrics Worth Watching
Once tickets are logged properly, you gain data you never had before. Four numbers are enough for a weekly review:
- First response time: the gap between
created_atandfirst_responded_at. Report the median, not the average, so one forgotten ticket does not distort the picture. - SLA hit rate: tickets answered before
first_response_due_atdivided by all tickets. - Open tickets per agent: shows who is overloaded before quality drops.
- Top categories: a signal of which part of your product confuses customers most.
For example, if "installation" tops the list every week, better docs or a short walkthrough video will likely cut volume more than hiring another agent.
Launch Checklist
- Priority definitions and SLA targets are agreed and written down.
- Every ticket has a unique number and a single owner.
- Internal notes never reach customers (test it manually).
- A Policy blocks access to other customers' tickets.
- Mail goes through a queue and a worker is running.
- The scheduler is active for SLA reminders.
- Old channels send an auto-reply pointing to the ticket form.
A simple ticket system used consistently beats a full-featured help desk nobody opens. Ship the foundation, measure response times for a month, then add features based on data rather than guesses. GudangCode uses the same pattern for its member tickets.