DEV Community

Cover image for Building a Dual-Interface Support Ticket System for a Multi-Tenant Laravel SaaS
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Dual-Interface Support Ticket System for a Multi-Tenant Laravel SaaS

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

  1. Architecture Overview
  2. Database Schema
  3. Status Workflow
  4. Admin CRUD & Features
  5. Smart Sorting & Filtering
  6. User Side & Security
  7. Category & Label Management
  8. Frontend: Two Interfaces
  9. Events & Audit Trail
  10. Testing
  11. 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();
});
Enter fullscreen mode Exit fullscreen mode

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', ...);
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

Five sorting levels ensure the most urgent ticket is always at the top:

  1. Unresolved tickets float up
  2. Brand new tickets (created_at = updated_at, never responded to) surface next
  3. High priority before standard
  4. Waiting for moderator before waiting for customer
  5. 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; }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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)