I added a support desk to LaraFoundry this week. The first commit in the slice removed a package instead of adding one.
LaraFoundry is a reusable SaaS core for Laravel that I'm extracting in public from an older app of mine. Auth, multi-tenancy, roles, activity log, notifications, billing seam, and now support tickets. The rule for every module is the same: lift the proven idea out of the old code, modernise it, harden it, and make it something you can composer require into a fresh Laravel app without inheriting a pile of assumptions.
Tickets is where that rule got interesting, because the old code didn't own its ticket model. It leaned on a third-party ticket library.
Why a ticket package is wrong for a reusable core
A third-party ticket package is a perfectly reasonable choice when you're building one app. You get tables, a model, a status enum and a UI scaffold for free.
It's the wrong choice for a core that other apps install. Pull it into the core and every host app inherits that package's migrations, its table names, its status vocabulary and its idea of what a ticket is. The dependency becomes load-bearing in projects that never asked for it, and the day it lags a Laravel release, every downstream app waits.
So I cut it (decision D-4.2-1 in my notes) and wrote the model by hand. The model is about 180 lines. There is no magic. Two tables, a uuid, a status, a couple of scopes. The diff against "depend on the package" was less code in the core, not more, because I only kept the behaviour I actually use.
Here's the top of the model, with the extraction notes I leave for future me:
/**
* A support ticket: the channel between a host user and the platform operator.
*
* Extracted from the donor App\Models\Ticket, which sat on a third-party
* ticket package. That dependency is cut: this is a self-contained model.
* Categories and labels are JSON slug arrays driven by config, not pivot
* tables. The dead assigned_to column and the donor's invalid-operator
* query are dropped.
*/
class Ticket extends Model
{
use Filterable;
public const STATUS_WAIT_MODERATOR = 'wait-moderator';
public const STATUS_WAIT_CUSTOMER = 'wait-customer';
public const STATUS_RESOLVED = 'resolved';
protected $table = 'larafoundry_tickets';
}
The part worth keeping: a status nobody sets
The one genuinely good idea in the donor was its status flow, and it's the thing I kept.
Most ticket systems give the operator a status dropdown. Open, pending, on hold, waiting, closed. The list grows, two of the values mean the same thing, and half of them are wrong at any given moment because somebody forgot to change the dropdown after they replied.
LaraFoundry tickets have three statuses and you never pick one. The status is derived from who acted.
user opens a ticket
|
v
wait-moderator <-----------------------+
| |
| operator replies | user replies
v |
wait-customer --------------------------+
|
| operator resolves
v
resolved ----> user replies ----> reopens (wait-moderator)
In code it's just this, in the two reply paths:
// User replies. It needs the operator's attention again, whatever it was before.
$ticket->update(['status' => Ticket::STATUS_WAIT_MODERATOR]);
// Operator replies. The ball is back with the customer.
$ticket->update(['status' => Ticket::STATUS_WAIT_CUSTOMER]);
Replying to a resolved ticket reopens it, because a reply to a closed thing means it wasn't actually closed. No dropdown, no stale state, nothing to forget.
That same derived status drives the operator queue ordering. Open before resolved, brand-new before answered, high priority first, most recently updated last. I do it in one scope so the list always reads top to bottom as "deal with me first":
public function scopeWorkflowOrder(Builder $query): Builder
{
return $query
->orderByRaw("CASE WHEN status = 'resolved' THEN 1 ELSE 0 END asc")
->orderByRaw("CASE
WHEN created_at = updated_at THEN 0
WHEN priority = 'high' THEN 1
WHEN priority = 'standard' THEN 2
ELSE 3 END asc")
->orderByRaw("CASE
WHEN status = 'wait-moderator' THEN 1
WHEN status = 'wait-customer' THEN 2
ELSE 3 END asc")
->orderBy('updated_at', 'desc');
}
created_at = updated_at is a cheap trick for "nobody has touched this since it was opened", so a brand-new ticket floats to the very top without an extra column.
Extraction is a free code review
The other thing extraction gives you, and nobody advertises this, is a forced reading of code you wrote a long time ago and then stopped looking at.
To lift the ticket logic out cleanly I had to read every line of the donor. I found bugs I'd been running without noticing:
- A query method built a filter with
'!=='as its SQL operator.'!=='is JavaScript. In SQL it's a syntax error, which means that code path had never actually run. I dropped the method. - A queue filter was inverted. The flag that was supposed to show all tickets was being read the wrong way round, so the "all" view and the "open" view were quietly swapped in one branch.
- There was an
assigned_tocolumn that nothing wrote to and nothing read. A leftover from the package's model that my app never used. Gone.
None of these were dramatic. That's the point. They were the kind of quiet wrongness that survives for years because the feature mostly works and nobody re-reads it. Extracting a module into a place where it has to stand on its own is the cheapest code review I know.
Config, not pivot tables
Categories and labels are the kind of thing you reach for a pivot table for by reflex. A categories table, a ticket_category pivot, a model, a seeder.
For a core that other people configure, that's heavy. So categories and labels are JSON slug arrays on the ticket, driven by config:
// config/larafoundry-tickets.php
'categories' => ['general', 'billing', 'feature', 'bug'],
'labels' => ['quick', 'complex'],
A host app adds a category by editing an array. No migration, no new model, no seeder. The slugs you allow are validated against that same config on the way in, so a request can't invent a category that isn't on the list. When a host eventually wants database-managed categories, the column is already JSON and the swap is local. Until then, this is the lightest thing that works.
The one screen a suspended customer must still reach
Here's the product decision I spent the most time on, and it's a one-line decision in the routes file.
The ticket routes sit behind auth and nothing else. No verified gate, and no account-active gate.
Route::middleware(['web', 'auth'])
->prefix('tickets')
->name('tickets.')
->group(function () {
// the customer's own tickets
});
The reason is support is the last channel. When a company gets suspended (say their billing lapsed and the tenant is blocked from the rest of the app) the support page is the one screen they must still be able to open, because it's their only line back to me. Gate it like everything else and you've locked a paying-again customer out of the room where they'd tell you they want to pay again.
There's a sharp edge here that only showed up when I integrated the module into the host app, and I like it as an example of why integration tests earn their keep.
The host applies a global middleware that hard-logs-out blocked or deleted accounts on every web route. So I had two ideas of "blocked" colliding:
- A company suspended for billing. The user's own account is fine. They should reach support.
- An account an operator personally banned. That one should be gone everywhere, support included.
The middleware that enforces the second one looks only at identity (is this account banned or deleted), and it deliberately knows nothing about companies. A company suspension never reaches it, so a billing-blocked user sails through to the ticket page. An operator-banned account is stopped at the door on every route. Two kinds of blocked, one deliberate line between them, and the only way I trusted that line was a test that drives the real host middleware stack and checks both.
Reusing last month's seam
Phase 4.1 was an in-app notification centre with a small service seam: hand it some users, a code and a couple of translation keys, and it delivers an in-app notification.
The ticket module didn't grow its own notification logic. When an operator replies, it calls that seam:
app(NotificationService::class)->system(
users: [$ticket->user],
code: 'info',
titleKey: 'larafoundry::tickets.notify.reply.title',
);
The author gets a bell notification that an operator answered, localised to their language, and the ticket module stays out of the notification business entirely. Seams from one phase paying off in the next is the whole reason I freeze them.
The security pass
Self-written means I own the security, so the module got the same review gate every module gets: a few independent agents reading the diff adversarially before it merges. The things that mattered:
- The route key is the uuid, never the sequential id, so a customer's URL never leaks how many tickets exist. On top of that an ownership policy authorises every action, so asking for someone else's ticket is a 403, not a peek.
- Message bodies render as text, never
v-html. A ticket is user-submitted content and the operator reads it in their console, so an unescaped body is a stored XSS straight at the admin. The message list component renders the body with text interpolation and whitespace preserved, and a Vitest test pins that escaping so a future refactor can't quietly turn it into HTML. - Opening tickets and replying are throttled, with the limits in config, so a script can't flood the support queue.
Testing
Tickets added 30 Pest tests to the core (it now sits at 556) and 8 Vitest tests for the Vue side (132). The model, the policy, the user flow, the operator console, the filter, and the two reply transitions all have coverage.
Then there's one more layer that I've come to rely on: a host integration test. The package has its own test suite against a bare test app, but the host app is where the real middleware stack, the real user model and the real tenancy live. The integration test opens a ticket as a real user, has an operator reply, and asserts the author got the notification, that a second user gets a 403 on someone else's ticket, that an unverified user can still reach support, and that the support menu item shows up for the operator. That's the test that caught the two-kinds-of-blocked edge above.
It's free
Tickets is FREE core, like auth and tenancy and notifications. The paid part of LaraFoundry is the billing add-on, not the things every SaaS needs. So all of the code in this post is exactly what ships, and you can read the rest in the repo.
The next step is writing the reference doc for the module while the details are still fresh, which is its own small discipline I'll write about separately.
Follow along
- GitHub: github.com/dmitryisaenko/larafoundry (star or follow the build)
- Project: larafoundry.com
Top comments (0)