escalated-dev/escalated-laravel
A full-featured, embeddable support ticket system for Laravel with SLA tracking, escalation rules, agent workflows, and a customer portal. Self-host or sync to cloud with one config change.
Install in four steps:
composer require escalated-dev/escalated-laravel
npm install @escalated-dev/escalated
php artisan escalated:install
php artisan migrate
Then make your User model ticketing-aware (the installer offers to do this automatically; do it manually if you have a custom model):
use Escalated\Laravel\Contracts\HasTickets;
use Escalated\Laravel\Contracts\Ticketable;
class User extends Authenticatable implements Ticketable
{
use HasTickets;
}
Define the two authorization gates so Escalated knows who can do what. In Laravel 12+ this goes in AppServiceProvider::boot(), in Laravel 11 and earlier in AuthServiceProvider::boot():
Gate::define('escalated-admin', fn ($user) => $user->is_admin);
Gate::define('escalated-agent', fn ($user) => $user->is_agent);
Finally, wire the frontend. Add the package to your Tailwind content (v3) or @source (v4), then extend your existing Inertia page resolver:
const escalatedPages = import.meta.glob(
'../../node_modules/@escalated-dev/escalated/src/pages/**/*.vue',
);
createInertiaApp({
resolve: (name) => {
if (name.startsWith('Escalated/')) {
const path = name.replace('Escalated/', '');
return resolvePageComponent(
`../../node_modules/@escalated-dev/escalated/src/pages/${path}.vue`,
escalatedPages,
);
}
return resolvePageComponent(`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue'));
},
});
Visit /support (prefix configurable in config/escalated.php) — that's the customer portal, agent dashboard, and admin panel in one. First realistic use case: bolt it onto an existing SaaS app as a support desk with no external helpdesk vendor. Publish the config early with php artisan vendor:publish --tag=escalated-config so you can see the full surface you'll be tuning.
As of v1.9.4 there's a second, independent on-ramp: embedding the widget/live chat on sites you don't control. That's opt-in and changes nothing until you list an origin — see docs/embedding-the-widget.md and the Implementation Patterns section below.
Programmatic ticket creation from your app. The richest pattern in Escalated is programmatically attaching domain objects to a new ticket, so agents get a clickable breadcrumb back into your app instead of copy-pasting IDs. Implement the TicketSubject contract on any model, and use the trait to avoid boilerplate:
use Escalated\Laravel\Concerns\PresentsAsTicketSubject;
use Escalated\Laravel\Contracts\TicketSubject;
class Project extends Model implements TicketSubject
{
use PresentsAsTicketSubject;
public function ticketSubjectSubtitle(): ?string
{
return 'Project · '.$this->customer->name;
}
public function ticketSubjectUrl(): ?string
{
return route('projects.show', $this);
}
public function ticketSubjectColor(): ?string { return '#2563eb'; }
public function ticketSubjectIcon(): ?string { return 'folder'; }
}
Then attach one or more subjects to the ticket — a ticket can reference several:
$ticket->attachSubject($project, role: 'project');
$ticket->attachSubject($customer, role: 'account');
// Or bulk-sync in one call (array of model-or-[model, role] pairs)
$ticket->syncSubjects([$project, [$customer, 'account']]);
$ticket->detachSubject($project);
This is the day-to-day integration tip. Once your subjects implement TicketSubject, agents stop escaping back to your app to look things up. For the agent-facing API to accept subjects, add an allowlist to config:
// config/escalated.php
'ticket_subjects' => [
'types' => [
\App\Models\Project::class,
\App\Models\Customer::class,
],
],
Embedding the widget on other sites (v1.9.4). The highest-value new pattern: your marketing site, docs site, or a customer's own storefront can host the ticket form and live chat without touching that site's stack. Two levers — config or the admin UI:
// config/escalated.php
'widget' => [
'allowed_origins' => [
'https://example.com',
'https://*.example.com', // subdomain wildcard
],
'serve_script' => true, // set false to self-host the bundle
],
or ESCALATED_WIDGET_ALLOWED_ORIGINS, or the Sites that can embed the widget admin setting (widget_allowed_origins). Then the remote site needs one tag:
<script src="https://your-app.test/support/widget/embed.js"></script>
Because the embed route is served from your app, the remote site doesn't need npm, a build step, or your Tailwind config. Treat the allowed-origins list as the whole security boundary: listed origins get CORS headers (including on 422/429, so form validation errors and rate-limit messages are readable in the browser), skip CSRF, and always run as a guest. Anything not listed behaves exactly as before release.
Passing host context into widget tickets. The best day-to-day use of the new metadata support is pre-filling what agents would otherwise ask for — order number, plan, page URL:
// on the embedding site
Escalated.widget({
metadata: { order: '10428', plan: 'pro', page: location.pathname },
});
That lands on the ticket (and on live chats) as a sanitised flat map of short scalars, plus a server-recorded embedded_on origin. /config advertises accepts_metadata: true, so you can feature-detect instead of version-pinning your embed snippet.
Theming integration via EscalatedPlugin. Rather than duplicating pages, mount Escalated inside your existing authenticated layout:
import { EscalatedPlugin } from '@escalated-dev/escalated';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
app.use(plugin).use(EscalatedPlugin, {
layout: AuthenticatedLayout,
theme: { primary: '#3b82f6', radius: '0.75rem' },
});
Conditional nav and UI gating. Escalated shares page.props.escalated on every Inertia page:
page.props.escalated = {
prefix: 'support',
is_agent: true,
is_admin: false,
}
Use these props to show/hide nav links and menu items in your own layout — don't duplicate the gate logic in JS; just read these.
Hosting mode as a deployment flag, not a refactor. The same controllers, UI, and business logic work across self-hosted, synced, and cloud. Treat it as a config/escalated.php switch and an env var:
'mode' => env('ESCALATED_MODE', 'self-hosted'),
'hosted' => [
'api_url' => 'https://cloud.escalated.dev/api/v1',
'api_key' => env('ESCALATED_API_KEY'),
],
Practically: start self-hosted, flip to synced if you later want multi-app visibility with queued retries. cloud mode is the "no local storage" option.
Inbound email and notifications. Point Mailgun/Postmark/SES/IMAP at the inbound webhook, and Escalated creates tickets and appends replies. Outbound email is already threading-correct (proper In-Reply-To and References), so keep the notification pipeline intact rather than reimplementing it. Branded templates come from php artisan vendor:publish --tag=escalated-views.
Saved views / custom queues. Treat saved views as your team's day-to-day operating surface: filter to a set of tickets once, save it, share it. This is the pattern that actually reduces "where do I look next" time.
Real-time broadcasting. Opt-in with Pusher/Reverb/Soketi. There's automatic polling fallback, so if a broadcaster is misbehaving, disable broadcasting in config to fall back rather than rolling back the feature.
Scheduling the snooze wake-up. Add the wake command to your scheduler:
// routes/console.php (Laravel 11+)
Schedule::command('escalated:wake-snoozed-tickets')->everyMinute();
Without this, snoozed tickets never come back — a silent, "bug-looking" failure.
user_key_type is baked in at migrate time. The package reflects your user_model and picks unsignedBigInteger vs. string-compatible columns — nice — but once a migration runs, changing your user key strategy requires a manual migration. If you plan to move to UUID/ULID users, set it now:
// config/escalated.php
'user_key_type' => 'uuid', // 'auto' | 'bigint' | 'uuid' | 'ulid' | 'string'
Same caution for any long-term app with a schema-shifting roadmap. This is the biggest "install once, regret later" lever.
User IDs are never coerced to int. Every user-id param is int|string and incoming IDs aren't cast, so UUIDs survive round-trips. Don't "helpfully" cast to int in your own code — you'll silently corrupt the value on UUID installs.
Escalated/ prefix collision. The page resolver must claim Escalated/ before your default ./Pages/ glob, otherwise a same-named page in your app silently wins. If you see your own page rendering instead of Escalated's, that's the bug.
Tailwind classes get purged without the content/source entry. v3 needs './node_modules/@escalated-dev/escalated/src/**/*.vue' in content; v4 needs @source '../../node_modules/@escalated-dev/escalated/src/**/*.vue' in your CSS. Forget it and everything renders unstyled — a common "is it broken?" red herring when the real cause is config. This now bites twice: the served widget/embed.js bundle has its own styling, so a broken local setup and a broken embed can look like unrelated bugs.
The EscalatedPlugin layout contract. Your layout component must accept both a #header slot and a default slot. Escalated renders its sub-nav in the header and page content in the default. A layout that only has a default slot will silently drop the sub-nav, and agents will think routes are missing.
Empty ticket_subjects.types is a security feature. Programmatic attachSubject() works for any model when the allowlist is empty, but the agent API only accepts allowlisted types — that's how Escalated stops arbitrary class resolution from request input. Populate the allowlist if you want agents to attach subjects from the UI, and keep it minimal.
Widget requests are guests, always — by design. v1.9.4 widget traffic from a listed origin skips CSRF and never inherits your session, even if the visitor is logged into your app in another tab. Don't attempt to authenticate embed users, don't read auth()->user() in widget-facing callbacks, and don't put anything user-private into the widget response. Identity travels via the guest token, not the session.
allowed_origins empty means "nothing changed", not "broken". If the embed loads locally but silently fails to call your API from https://example.com, the origin isn't listed — check that (a) the exact scheme+host is present, (b) wildcards are written https://*.example.com (scheme included; a bare *.example.com won't match), and (c) you're not testing from a different port/host than you whitelisted. Also reconcile the two sources of truth: config/ESCALATED_WIDGET_ALLOWED_ORIGINS and the admin widget_allowed_origins setting.
CORS is handled for 422/429 too — use that. Because validation and rate-limit responses carry CORS headers, browser-side form errors are readable. If your remote embed shows a generic failure on invalid input, suspect your own error handling, not the CORS layer.
Widget metadata is sanitised, and embedded_on is server-owned. Only a flat map of short scalar values survives; nested arrays/objects get dropped, and anything named embedded_on you send is overwritten by the recorded origin. Don't design an integration around shipping JSON blobs through widget metadata — attach a TicketSubject on your side if you need rich references. Live-chat metadata is now sanitised the same way as ticket metadata.
The widget settings UI only started persisting in v1.9.4. The admin page previously displayed widget settings (widget_enabled, colour, position, greeting, departments) that the controller never saved. If you upgraded from an earlier version and "already configured" the widget in the UI, re-open the settings and re-save — the old values were never stored. Symptom to recognise: widget enabled in the admin form, still off at runtime.
serve_script is your CSP escape hatch. If your Content-Security-Policy or an air-gapped network blocks the served widget/embed.js route, set escalated.widget.serve_script => false and self-host the bundle from @escalated-dev/escalated instead of widening script-src.
Synced mode queues on cloud failure — "if the cloud is unreachable, your app keeps working; events queue and retry." So a cloud outage shows up as a delay in the central inbox, not an error in your app. Don't add retry logic; do watch the queue tables/worker health if the inbox seems stale.
Snooze needs a scheduler entry. escalated:wake-snoozed-tickets is the only thing that un-snoozes tickets. Missing schedule = perpetually snoozed tickets with no error.
Guest tickets use magic-link tokens. Anonymous submission works with a guest token rather than auth, so the link is the credential — don't log or embed it in analytics/URL captures. Widget embeds make this more likely to leak: third-party analytics on the embedding site will see the guest URLs and referrers, so audit that site's scripts, not just yours.
Broadcast fallback is your first debugging move. If real-time updates stall, disable broadcasting in config to confirm whether the issue is the broadcaster or the ticket model. If polling works, the fault is in Pusher/Reverb/Soketi wiring, not Escalated. Same triage works for live chat in the widget: if the embedded chat doesn't tick, test the same conversation in-app first.
Extension points worth knowing: vendor:publish --tag=escalated-migrations (if you need column tweaks), --tag=escalated-views (email templates), --tag=escalated-config, and --tag=escalated-translations. Also, EscalatedPlugin accepts a theme object mapping to CSS custom properties (--esc-primary, --esc-primary-hover, --esc-radius, --esc-radius-lg, --esc-font-family) — reach for these before overriding component styles. Theming applies to the in-app pages; the standalone widget bundle has its own colour/position settings, so you'll be theming in two places if you use both.
escalated-admin vs escalated-agent gates drive everything. is_agent/is_admin in shared props are derived from these gates, so a user who "should be an agent" but sees no dashboard almost always has a gate that returns false — check the gate closure before suspecting the package. Note that widget-originated tickets are unaffected by gates at creation time (guests), so missing dashboards and missing widget tickets are always two different investigations.
How can I help you explore Laravel packages today?