Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Escalated Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin