Every SaaS needs a way for users to reach support. A form, a list, some statuses. Then the requirements pile up: admins need a different view, priorities need to affect sort order, categories need to be togglable on the fly, resolved tickets need to auto-disappear, and the user should never see a raw database ID in the URL.
I built Kohana.io - a production CRM/ERP for small businesses. The ticket module evolved into a dual-interface system where users and admins interact with the same data through completely different controllers, policies, and views.
Now I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel. This post covers the full implementation.
Table of Contents
- Architecture Overview
- Database Schema
- Status Workflow
- Admin CRUD & Features
- Smart Sorting & Filtering
- User Side & Security
- Category & Label Management
- Frontend: Two Interfaces
- Events & Audit Trail
- Testing
- Design Decisions
Architecture Overview
LaraFoundry's ticket system is built on coderflex/laravel-ticket with significant customization. The core idea: one data model, two interfaces.
User side - limited, secure:
- Create tickets with title, message, priority, categories
- View own tickets only (via UUID)
- Reply to tickets (auto-updates status)
- Old resolved tickets hidden after 7 days
Admin side - full control:
- View all tickets with statistics and filters
- Create tickets on behalf of users
- Reply, close, reopen, change priority
- Toggle categories and labels without editing
- Grid/list display modes
The separation happens at every level: controllers, form requests, API resources, policies, Vue pages.
Database Schema
Tickets Table
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->text('message');
$table->string('priority'); // LOW, STANDARD, HIGH
$table->string('status'); // wait-customer, wait-moderator, resolved
$table->boolean('is_resolved')->default(false);
$table->boolean('is_locked')->default(false);
$table->foreignId('assigned_to')->nullable()->constrained('users');
$table->timestamps();
});
The uuid field exists alongside id. Users access tickets via UUID - /tickets/a8f3e2b1-.... The internal integer id is used for admin routes and relationships.
Categories & Labels
// Categories: general, billing, feature request, bug report
Schema::create('ticket_categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->boolean('is_visible')->default(true);
$table->timestamps();
});
// Labels: quick, complex
Schema::create('ticket_labels', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->boolean('is_visible')->default(true);
$table->timestamps();
});
// Pivot tables
Schema::create('ticket_category_assignments', ...);
Schema::create('ticket_label_assignments', ...);
Both use many-to-many relationships. Both are toggleable from the admin detail view.
Conversation Messages
Schema::create('ticket_message_assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('ticket_id')->constrained();
$table->text('message');
$table->timestamps();
});
Each message tracks who sent it. The API resource adds an is_agent flag so the frontend knows whether the message came from a user or admin.
Model
class Ticket extends BaseTicket
{
use Filterable;
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function getUnderReviewTicketsCont(): int
{
return static::query()
->whereColumn('created_at', 'updated_at')
->where('status', '!=', 'resolved')
->orWhere('status', 'wait-moderator')
->count();
}
public function scopeExcludeOldResolved($query, $days = 7): void
{
$query->where(function ($q) use ($days) {
$q->where('status', '!=', 'resolved')
->orWhere('updated_at', '>=', now()->subDays($days));
});
}
public function getExcludeOldResolvedAttribute(): bool
{
return $this->status === 'resolved'
&& $this->updated_at->lt(now()->subDays(7));
}
}
The Filterable trait enables the admin filter system. excludeOldResolved scope keeps the user list clean. The getUnderReviewTicketsCont method counts tickets that haven't been touched yet.
Status Workflow
The status changes automatically based on who acts:
User creates ticket → status = 'wait-moderator'
Admin replies → status = 'wait-customer'
User replies → status = 'wait-moderator'
Admin closes → status = 'resolved'
User replies to resolved → status = 'wait-moderator' (auto-reopen)
In the user controller:
public function store(TicketStoreRequest $request): RedirectResponse
{
$ticket = Ticket::create([
'uuid' => Str::uuid(),
'user_id' => auth()->id(),
'title' => $request->title,
'message' => $request->message,
'priority' => $request->priority,
'status' => 'wait-moderator',
]);
$ticket->attachCategories($request->categories);
event(new TicketCreate(auth()->user(), $ticket));
return redirect()->route('tickets.index')
->with('success', __('Ticket created'));
}
And for replies:
public function storeMessage(StoreTicketMessageRequest $request, string $uuid): RedirectResponse
{
$ticket = Ticket::where('uuid', $uuid)->firstOrFail();
$ticket->messages()->create([
'user_id' => auth()->id(),
'message' => $request->message,
]);
// Auto-reopen if resolved
if ($ticket->status === 'resolved') {
$ticket->update(['status' => 'wait-moderator']);
}
event(new TicketAnswerCreate(auth()->user(), $ticket, $request->message));
return back();
}
The user never touches the status field. The system infers intent from the action.
Admin CRUD and Features
Routes (12 endpoints)
GET /admin/tickets → index
GET /admin/tickets/create/{user} → create (for specific user)
POST /admin/tickets → store
GET /admin/tickets/{ticket} → show
GET /admin/tickets/{ticket}/edit → edit
PUT /admin/tickets/{ticket} → update
POST /admin/tickets/{ticket}/reply → reply
PATCH /admin/tickets/{ticket}/close → close
PATCH /admin/tickets/{ticket}/priority → updatePriority
POST /admin/tickets/{ticket}/category/toggle → toggleCategory
POST /admin/tickets/{ticket}/label/toggle → toggleLabel
Statistics Action
class GetTicketsStat
{
public function __invoke(): object
{
return (object) [
'total' => Ticket::count(),
'open' => Ticket::where('status', '!=', 'resolved')->count(),
'high_priority' => Ticket::where('priority', 'high')
->where('status', '!=', 'resolved')->count(),
'standard_priority' => Ticket::where('priority', 'standard')
->where('status', '!=', 'resolved')->count(),
];
}
}
The index page shows four stat cards at the top. Each card is clickable and links to a filtered view.
Admin Creates Tickets for Users
Admin can create a ticket on behalf of a user. The route takes {user} as a parameter. Status is set to wait-customer (admin-initiated).
public function store(TicketStoreRequest $request): RedirectResponse
{
$ticket = Ticket::create([
'uuid' => Str::uuid(),
'user_id' => $request->userId,
'title' => $request->title,
'message' => $request->message,
'priority' => $request->priority ?? 'standard',
'status' => 'wait-customer',
]);
if ($request->categories) {
$ticket->attachCategories($request->categories);
}
if ($request->labels) {
$ticket->attachLabels($request->labels);
}
return redirect()->route('admin.tickets.show', $ticket);
}
Smart Sorting and Filtering
AdminTicketsFilter
class AdminTicketsFilter extends QueryFilter
{
public function show_tickets($value = null): void
{
match ($value) {
'standard_priority' => $this->builder
->where('priority', 'standard')
->where('status', '!=', 'resolved'),
'high_priority' => $this->builder
->where('priority', 'high')
->where('status', '!=', 'resolved'),
default => null,
};
}
public function search($value = null): void
{
if ($value) {
$this->builder->where(function ($q) use ($value) {
$q->where('title', 'like', "%{$value}%")
->orWhere('message', 'like', "%{$value}%");
});
}
}
public function status($value = null): void
{
if ($value) {
$this->builder->where('status', $value);
}
}
public function apply($builder): Builder
{
parent::apply($builder);
return $builder
->orderByRaw("CASE WHEN status = 'resolved' THEN 1 ELSE 0 END")
->orderByRaw("CASE WHEN created_at = updated_at THEN 0 ELSE 1 END")
->orderByRaw("CASE WHEN priority = 'high' THEN 0 ELSE 1 END")
->orderByRaw("CASE WHEN status = 'wait-moderator' THEN 0 ELSE 1 END")
->orderBy('updated_at', 'desc');
}
}
Five sorting levels ensure the most urgent ticket is always at the top:
- Unresolved tickets float up
- Brand new tickets (created_at = updated_at, never responded to) surface next
- High priority before standard
- Waiting for moderator before waiting for customer
- Most recently updated first
User Side and Security
TicketPolicy
class TicketPolicy
{
public function viewAny(User $user): bool
{
return true; // All authenticated users
}
public function view(User $user, Ticket $ticket): bool
{
return $user->id === $ticket->user_id
&& !$ticket->is_resolved
&& !$ticket->exclude_old_resolved;
}
public function update(User $user, Ticket $ticket): bool
{
return $user->id === $ticket->user_id
&& !$ticket->is_resolved;
}
public function reply(User $user, Ticket $ticket): bool
{
return $user->id === $ticket->user_id;
}
public function delete(): bool { return false; }
public function close(): bool { return false; }
}
Key decisions:
- Users can only view their own tickets
- Resolved tickets are not viewable (admin handles resolution)
- Old resolved tickets (>7 days) are also blocked
- Delete and close always return false for users
- Reply is allowed even to resolved tickets (triggers auto-reopen)
UUID URLs
User-facing routes use {ticket:uuid} binding:
GET /tickets/{ticket:uuid} → show
POST /tickets/{ticket:uuid}/messages → storeMessage
Admin routes use {ticket} (integer ID). This means:
- Users never see sequential IDs
- Admins work with simple numeric references
- Both resolve to the same Ticket model
Auto-Hide Old Resolved
// In user TicketController@index
$tickets = Ticket::query()
->where('user_id', auth()->id())
->excludeOldResolved()
->orderByRaw("FIELD(status, 'wait-moderator', 'wait-customer', 'resolved')")
->orderBy('updated_at', 'desc')
->get();
Resolved tickets silently disappear from the user's list after 7 days. No delete button. No archive feature. Just a scope.
Category and Label Management
Toggle Pattern
On the admin ticket detail page, all categories and labels are displayed as buttons. Active ones are highlighted. Click to toggle:
public function toggleCategory(Request $request, Ticket $ticket): RedirectResponse
{
$category = Category::where('slug', $request->slug)->firstOrFail();
if ($ticket->categories->contains($category->id)) {
$ticket->categories()->detach($category->id);
} else {
$ticket->categories()->attach($category->id);
}
return back();
}
Same pattern for labels. One POST request per toggle. No form submission. No page reload via Inertia's preserveScroll.
Available Categories & Labels
Configured in config/laravel_ticket.php:
| Categories | Labels |
|---|---|
| general | quick |
| billing | complex |
| feature request | |
| bug report |
All translated via Laravel's localization system.
Frontend: Two Interfaces
Admin Pages
IndexTickets.vue:
- 4 stat cards (open, high priority, standard priority, total)
- Each card links to filtered view
- Filter form: search, status, priority
- Grid/list view toggle
- TicketCard (grid) and TicketListItem (list) components
TicketCard component:
- Title (linked to show), user name, creation date
- Status and priority badges with color coding
- Message preview (150 chars truncated)
- Category and label tags
- Message count with icon
- Close button (if not resolved)
- Search text highlighting
TicketListItem component:
- More detailed metadata: user icon, creation date, message count, assigned user
- Full categories/labels with calculated contrast colors
- Close/reopen buttons based on status
- Priority indicator dot
ShowTicket.vue:
- Dynamic category/label toggle buttons (all available, active highlighted)
- Priority selector buttons
- Full conversation via MessageList component
- Admin reply form
EditTicket.vue:
- Pre-filled form: title, message, priority, status, categories, labels
- Status selector with buttons for each state
- Category/label checkboxes
User Pages
IndexTickets.vue:
- Own tickets list: priority badge, categories, title, status, dates
- Empty state when no tickets
CreateTicket.vue:
- Title, message (textarea), priority (select), categories (checkboxes)
- Form validation with error messages
ShowTicket.vue:
- Status/priority badges, creation date, title, message (HTML rendered)
- Categories as tags
- MessageList component (shared with admin)
- Reply textarea
Shared Component
MessageList is the only component shared between user and admin show pages. It renders the conversation thread with sender info, message content, timestamps, and an is_agent flag for visual distinction.
Events and Audit Trail
Two events fire during ticket interactions:
TicketCreate
class TicketCreate
{
public function __construct(
public User $user,
public Ticket $ticket
) {}
}
Logged data: ticket_id, title, message, priority, categories. Fires when a user creates a new ticket.
TicketAnswerCreate
class TicketAnswerCreate
{
public function __construct(
public User $user,
public Ticket $ticket,
public string $message
) {}
}
Logged data: ticket_id, title, answer_message, priority, categories. Fires when a message is added to a ticket.
Both events integrate with LaraFoundry's logging system (covered in module 4) for a complete audit trail.
Testing
Feature Tests (User)
test('user can view own tickets list', function () {
$user = User::factory()->create();
Ticket::factory()->create(['user_id' => $user->id]);
actingAs($user)
->get(route('tickets.index'))
->assertOk();
});
test('user cannot view other users tickets', function () {
$user = User::factory()->create();
$other = User::factory()->create();
$ticket = Ticket::factory()->create(['user_id' => $other->id]);
actingAs($user)
->get(route('tickets.show', $ticket->uuid))
->assertForbidden();
});
test('user can create ticket with valid data', function () {
$user = User::factory()->create();
actingAs($user)->post(route('tickets.store'), [
'title' => 'Need help',
'message' => 'I have an issue with billing',
'priority' => 'standard',
'categories' => ['billing'],
])->assertRedirect();
expect(Ticket::first())
->status->toBe('wait-moderator')
->user_id->toBe($user->id);
});
test('message reply reopens resolved ticket', function () {
$ticket = Ticket::factory()->create([
'status' => 'resolved',
'user_id' => $user->id,
]);
actingAs($user)->post(
route('tickets.messages.store', $ticket->uuid),
['message' => 'I still need help with this']
);
expect($ticket->fresh()->status)->toBe('wait-moderator');
});
Feature Tests (Admin)
test('admin can close ticket', function () {
$ticket = Ticket::factory()->create(['status' => 'wait-moderator']);
actingAs($admin)
->patch(route('admin.tickets.close', $ticket))
->assertRedirect();
expect($ticket->fresh()->status)->toBe('resolved');
});
test('admin can toggle category', function () {
$ticket = Ticket::factory()->create();
$category = TicketCategory::first();
actingAs($admin)->post(
route('admin.tickets.category.toggle', $ticket),
['slug' => $category->slug]
);
expect($ticket->categories)->toHaveCount(1);
});
test('admin can filter tickets by priority', function () {
Ticket::factory()->create(['priority' => 'high']);
Ticket::factory()->create(['priority' => 'standard']);
actingAs($admin)
->get(route('admin.tickets.index', ['show_tickets' => 'high_priority']))
->assertOk();
});
Unit Tests
test('ticket belongs to user', function () {
$ticket = Ticket::factory()->create();
expect($ticket->user)->toBeInstanceOf(User::class);
});
test('exclude old resolved scope works', function () {
Ticket::factory()->create([
'status' => 'resolved',
'updated_at' => now()->subDays(10),
]);
Ticket::factory()->create(['status' => 'wait-moderator']);
expect(Ticket::excludeOldResolved()->count())->toBe(1);
});
Policy Tests
test('user can view own non-resolved ticket', function () {
expect($user->can('view', $ownTicket))->toBeTrue();
});
test('user cannot view old resolved ticket', function () {
expect($user->can('view', $oldResolvedTicket))->toBeFalse();
});
test('user cannot delete tickets', function () {
expect($user->can('delete', $ticket))->toBeFalse();
});
Real HTTP requests, real database, no mocking the ticket system.
Design Decisions
Why coderflex/laravel-ticket as a Base?
Starting from a package gives you the basic table structure and relationships. Then you extend: add uuid, customize the model, add filters, add events. It's faster than building from scratch and the package handles the boilerplate migrations and pivot tables.
Why Separate Controllers for Users and Admins?
Mixing user and admin logic in one controller leads to messy authorization checks everywhere. Separate controllers mean clean, focused methods. The user controller has 4 methods. The admin controller has 11. Different validation, different resources, different concerns.
Why UUID for Users but ID for Admins?
Users shouldn't enumerate tickets. UUID prevents /tickets/1, /tickets/2, /tickets/3 guessing. Admins are trusted - integer IDs are simpler for internal use and URL readability in the admin panel.
Why Auto-Hide Instead of Archive?
An archive feature adds UI complexity: archive button, archived tickets list, unarchive action. The 7-day auto-hide achieves the same result with zero UI. Resolved tickets disappear naturally. The data stays in the database. If needed, it's queryable.
Why Toggle Instead of Edit for Categories/Labels?
Editing a ticket to change its category requires: load edit form → modify → submit → redirect. Toggling requires: one click → one POST → done. Admins manage dozens of tickets daily. Every saved click matters.
Why Five-Level Sorting?
A single "sort by date" puts old high-priority tickets below new low-priority ones. A single "sort by priority" puts old tickets at the top forever. Multi-level sorting ensures the admin always sees the most actionable ticket first, without manual sorting.
This is part of the "LaraFoundry series" - documenting the extraction of a production CRM/ERP into an open-source Laravel SaaS framework.
Previous: Notifications Module
LaraFoundry: larafoundry.com
Top comments (0)