escalated-dev / escalated-laravel
An embeddable support ticket system for Laravel applications
Package info
github.com/escalated-dev/escalated-laravel
pkg:composer/escalated-dev/escalated-laravel
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/events: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/notifications: ^11.0|^12.0
- illuminate/queue: ^11.0|^12.0
- illuminate/routing: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- inertiajs/inertia-laravel: ^2.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- dev-main
- 0.6.0
- 0.5.0
- 0.4.0
- v0.1.9
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-feature/platform-parity
- dev-feature/plugins-page-path-fix
- dev-fix/install-command-auto-configure-user-model
- dev-feature/api
- dev-refactor/reorganize-controllers-by-feature
- dev-feature/api-security-improvements
- dev-feature/i18n
- dev-feature/plugins
- dev-feature/fixes-and-inbound-email
This package is auto-updated.
Last update: 2026-03-24 00:18:02 UTC
README
A full-featured, embeddable support ticket system for Laravel. Drop it into any app — get a complete helpdesk with SLA tracking, escalation rules, agent workflows, and a customer portal. No external services required.
Three hosting modes. Run entirely self-hosted, sync to a central cloud for multi-app visibility, or proxy everything to the cloud. Switch modes with a single config change.
Features
- Ticket lifecycle — Create, assign, reply, resolve, close, reopen with configurable status transitions
- SLA engine — Per-priority response and resolution targets, business hours calculation, automatic breach detection
- Escalation rules — Condition-based rules that auto-escalate, reprioritize, reassign, or notify
- Agent dashboard — Ticket queue with filters, bulk actions, internal notes, canned responses
- Customer portal — Self-service ticket creation, replies, and status tracking
- Admin panel — Manage departments, SLA policies, escalation rules, tags, and view reports
- File attachments — Drag-and-drop uploads with configurable storage and size limits
- Activity timeline — Full audit log of every action on every ticket
- Email notifications — Configurable per-event notifications with webhook support
- Department routing — Organize agents into departments with auto-assignment (round-robin)
- Tagging system — Categorize tickets with colored tags
- Guest tickets — Anonymous ticket submission with magic-link access via guest token
- Inbound email — Create and reply to tickets via email (Mailgun, Postmark, AWS SES, IMAP)
- Inertia.js + Vue 3 UI — Shared frontend via
@escalated-dev/escalated
v0.4.0 — Advanced Features
- Bulk actions — Assign, change status/priority, add tags, close, or delete multiple tickets at once
- Macros — Reusable multi-step automations (set status + assign + add note in one click)
- Ticket followers — Agents follow tickets and receive the same notifications as the assignee
- Satisfaction ratings — 1-5 star CSAT ratings with optional comments after resolution
- Pinned notes — Pin important internal notes to the top of the ticket thread
- Keyboard shortcuts — Full keyboard navigation for power users
- Quick filters — One-click filter chips (My Tickets, Unassigned, Urgent, SLA Breaching)
- Presence indicators — See who else is viewing a ticket in real-time
- Enhanced dashboard — CSAT metrics, resolution times, SLA breach tracking
Requirements
- PHP 8.2+
- Laravel 11.x or 12.x
- Node.js 18+ (for frontend assets)
Quick Start
composer require escalated-dev/escalated-laravel npm install @escalated-dev/escalated php artisan escalated:install php artisan migrate
The install command will offer to automatically configure your User model with the Ticketable interface and HasTickets trait. If you prefer to do this manually, or if you use a custom user model, add the following:
use Escalated\Laravel\Contracts\HasTickets; use Escalated\Laravel\Contracts\Ticketable; class User extends Authenticatable implements Ticketable { use HasTickets; }
Define authorization gates in a service provider:
use Illuminate\Support\Facades\Gate; Gate::define('escalated-admin', fn ($user) => $user->is_admin); Gate::define('escalated-agent', fn ($user) => $user->is_agent || $user->is_admin);
Visit /support — you're live.
Frontend Integration
Escalated ships a Vue component library and default pages via the @escalated-dev/escalated npm package.
1. Tailwind Content
Add the Escalated package to your Tailwind content config so its classes aren't purged:
// tailwind.config.js content: [ // ... your existing paths './node_modules/@escalated-dev/escalated/src/**/*.vue', ],
2. Page Resolver
Add the Escalated page resolver to your app.ts:
import { createInertiaApp } from '@inertiajs/vue3'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 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')); }, // ... });
3. Theming (Optional)
Register the EscalatedPlugin to render Escalated pages inside your app's layout — no page duplication needed:
import { EscalatedPlugin } from '@escalated-dev/escalated'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; createInertiaApp({ setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }) .use(plugin) .use(EscalatedPlugin, { layout: AuthenticatedLayout, }) .mount(el); }, });
Your layout component must accept a #header slot and a default slot. Escalated will render its sub-navigation in the header and page content in the default slot.
Without the plugin, Escalated uses its own standalone layout with a simple nav bar.
CSS Custom Properties
Pass a theme option to customize colors and radii:
app.use(EscalatedPlugin, { layout: AuthenticatedLayout, theme: { primary: '#3b82f6', radius: '0.75rem', } })
| Property | Default | Description |
|---|---|---|
--esc-primary |
#4f46e5 |
Primary action color |
--esc-primary-hover |
auto-darkened | Primary hover color |
--esc-radius |
0.5rem |
Border radius for inputs and buttons |
--esc-radius-lg |
auto-scaled | Border radius for cards and panels |
--esc-font-family |
inherit | Font family override |
Available Components
| Component | Description |
|---|---|
ActivityTimeline |
Full audit log of ticket events |
AssigneeSelect |
Agent assignment dropdown |
AttachmentList |
File attachment display |
FileDropzone |
Drag-and-drop file upload |
PriorityBadge |
Priority level indicator |
ReplyComposer |
Rich text reply editor |
ReplyThread |
Chronological message thread |
SlaTimer |
SLA countdown display |
StatsCard |
Metric card for dashboards |
StatusBadge |
Ticket status indicator |
TagSelect |
Tag picker with colors |
TicketFilters |
Search and filter controls |
TicketList |
Paginated ticket table |
TicketSidebar |
Ticket metadata sidebar |
Shared Inertia Props
Escalated automatically shares data to all Inertia pages via page.props.escalated:
page.props.escalated = { prefix: 'support', // Route prefix from config is_agent: true, // Current user can access agent views is_admin: false, // Current user can access admin views }
Use these to conditionally show nav links or restrict UI elements.
Hosting Modes
Self-Hosted (default)
Everything stays in your database. No external calls. Full autonomy.
// config/escalated.php 'mode' => 'self-hosted',
Synced
Local database + automatic sync to cloud.escalated.dev for unified inbox across multiple apps. If the cloud is unreachable, your app keeps working — events queue and retry.
'mode' => 'synced', 'hosted' => [ 'api_url' => 'https://cloud.escalated.dev/api/v1', 'api_key' => env('ESCALATED_API_KEY'), ],
Cloud
All ticket data proxied to the cloud API. Your app handles auth and renders UI, but storage lives in the cloud. Supports multiple domains per API key.
'mode' => 'cloud',
All three modes share the same controllers, UI, and business logic. The driver pattern handles the rest.
Publishing Assets
# Email templates php artisan vendor:publish --tag=escalated-views # Config file php artisan vendor:publish --tag=escalated-config # Database migrations php artisan vendor:publish --tag=escalated-migrations
Scheduling
Add these to your scheduler for SLA and escalation automation:
// app/Console/Kernel.php or routes/console.php Schedule::command('escalated:check-sla')->everyMinute(); Schedule::command('escalated:evaluate-escalations')->everyFiveMinutes(); Schedule::command('escalated:close-resolved')->daily(); Schedule::command('escalated:purge-activities')->weekly(); Schedule::command('escalated:poll-imap')->everyMinute(); // Only if using IMAP adapter
Configuration
All config lives in config/escalated.php. Key options:
'mode' => 'self-hosted', // self-hosted | synced | cloud 'user_model' => App\Models\User::class, 'table_prefix' => 'escalated_', 'default_priority' => 'medium', 'routes' => [ 'prefix' => 'support', 'middleware' => ['web', 'auth'], ], 'tickets' => [ 'allow_customer_close' => true, 'auto_close_resolved_after_days' => 7, ], 'sla' => [ 'enabled' => true, 'business_hours_only' => false, 'business_hours' => [ 'start' => '09:00', 'end' => '17:00', 'timezone' => 'UTC', 'days' => [1, 2, 3, 4, 5], ], ],
See the full configuration reference.
Events
Every ticket action dispatches an event you can listen to:
| Event | When |
|---|---|
TicketCreated |
New ticket |
TicketStatusChanged |
Status transition |
TicketAssigned |
Agent assigned |
ReplyCreated |
Public reply added |
InternalNoteAdded |
Agent note added |
SlaBreached |
SLA deadline missed |
TicketEscalated |
Ticket escalated |
TicketResolved |
Ticket resolved |
TicketClosed |
Ticket closed |
use Escalated\Laravel\Events\TicketCreated; Event::listen(TicketCreated::class, function ($event) { // $event->ticket });
Inbound Email
Escalated can create and reply to tickets from incoming emails. Supports Mailgun, Postmark, AWS SES webhooks, and IMAP polling as a fallback.
How It Works
- An external email service receives an email at your support address (e.g.,
support@yourapp.com) - The service forwards the email to your application via webhook (or IMAP polling fetches it)
- Escalated normalizes the payload into an
InboundMessageDTO via the adapter - The
InboundEmailServiceprocesses the message:- Thread matching: checks the subject for a ticket reference (e.g.,
[ESC-00001]), then checksIn-Reply-To/Referencesheaders against stored message IDs - Match found: adds a reply to the existing ticket; reopens the ticket if it was resolved or closed
- No match: creates a new ticket — if the sender is a registered user they become the requester, otherwise a guest ticket is created
- Thread matching: checks the subject for a ticket reference (e.g.,
- Every inbound email is logged to
escalated_inbound_emailsfor audit
Enable Inbound Email
ESCALATED_INBOUND_EMAIL=true ESCALATED_INBOUND_ADDRESS=support@yourapp.com
Adapter Setup
Mailgun
ESCALATED_INBOUND_ADAPTER=mailgun ESCALATED_MAILGUN_SIGNING_KEY=your-mailgun-signing-key
Configure a Mailgun Route to forward inbound emails to:
POST https://yourapp.com/support/inbound/mailgun
The signing key is in your Mailgun dashboard under Settings > API Keys > HTTP Webhook Signing Key. Requests are verified via HMAC-SHA256 signature.
Postmark
ESCALATED_INBOUND_ADAPTER=postmark ESCALATED_POSTMARK_INBOUND_TOKEN=your-postmark-inbound-token
Configure an Inbound Webhook in your Postmark server settings pointing to:
POST https://yourapp.com/support/inbound/postmark
The token is sent in the X-Postmark-Token header and verified on each request.
AWS SES
ESCALATED_INBOUND_ADAPTER=ses ESCALATED_SES_REGION=us-east-1 ESCALATED_SES_TOPIC_ARN=arn:aws:sns:us-east-1:123456789:your-topic
- Configure SES to receive emails and publish to an SNS topic
- Create an HTTPS subscription on the SNS topic pointing to:
POST https://yourapp.com/support/inbound/ses - Escalated auto-confirms the SNS subscription and verifies message signatures using Amazon's certificate
IMAP (Fallback)
For providers without webhook support, poll via IMAP:
ESCALATED_INBOUND_ADAPTER=imap ESCALATED_IMAP_HOST=imap.gmail.com ESCALATED_IMAP_PORT=993 ESCALATED_IMAP_ENCRYPTION=ssl ESCALATED_IMAP_USERNAME=support@yourapp.com ESCALATED_IMAP_PASSWORD=your-app-password ESCALATED_IMAP_MAILBOX=INBOX
Schedule the poll command:
Schedule::command('escalated:poll-imap')->everyMinute();
Webhook URL
POST /{prefix}/inbound/{adapter}
Where {prefix} is your configured route prefix (default: support) and {adapter} is mailgun, postmark, or ses. These routes use the api middleware (no CSRF, no auth).
Processing Features
- Thread detection via subject reference pattern (
[ESC-00001]) andIn-Reply-To/Referencesheaders - Guest tickets for unknown senders — display name derived from email (e.g.,
john.doe@example.com→John Doe) - Subject sanitization — strips
RE:,FW:,FWD:prefixes (including stacked) - HTML fallback — uses stripped HTML body when plain text is empty
- Duplicate detection — skips messages with duplicate
Message-IDheaders - Attachment handling — stores attachments respecting
max_attachment_size_kbandmax_attachments_per_reply - Auto-reopen — reopens resolved/closed tickets when a reply arrives via email
- Audit logging — every inbound email recorded in
escalated_inbound_emailswith status tracking
Custom Adapter
Implement the InboundAdapter interface:
use Escalated\Laravel\Mail\Adapters\InboundAdapter; use Escalated\Laravel\Mail\InboundMessage; use Illuminate\Http\Request; class MyAdapter implements InboundAdapter { public function parseRequest(Request $request): InboundMessage { return new InboundMessage( fromEmail: $request->input('from'), fromName: $request->input('name'), toEmail: $request->input('to'), subject: $request->input('subject'), bodyText: $request->input('text'), bodyHtml: $request->input('html'), messageId: $request->input('message_id'), inReplyTo: $request->input('in_reply_to'), ); } public function verifyRequest(Request $request): bool { return $request->header('X-Secret') === config('services.my_adapter.secret'); } }
Inbound Email Environment Variables
| Variable | Default | Description |
|---|---|---|
ESCALATED_INBOUND_EMAIL |
false |
Enable inbound email |
ESCALATED_INBOUND_ADAPTER |
mailgun |
Default adapter |
ESCALATED_INBOUND_ADDRESS |
support@example.com |
Support email address |
ESCALATED_MAILGUN_SIGNING_KEY |
— | Mailgun webhook signing key |
ESCALATED_POSTMARK_INBOUND_TOKEN |
— | Postmark inbound token |
ESCALATED_SES_REGION |
us-east-1 |
AWS SES region |
ESCALATED_SES_TOPIC_ARN |
— | AWS SNS topic ARN |
ESCALATED_IMAP_HOST |
— | IMAP server hostname |
ESCALATED_IMAP_PORT |
993 |
IMAP server port |
ESCALATED_IMAP_ENCRYPTION |
ssl |
IMAP encryption |
ESCALATED_IMAP_USERNAME |
— | IMAP username |
ESCALATED_IMAP_PASSWORD |
— | IMAP password |
ESCALATED_IMAP_MAILBOX |
INBOX |
IMAP mailbox to poll |
Routes
| Route | Method | Description |
|---|---|---|
/support |
GET | Customer ticket list |
/support/create |
GET | New ticket form |
/support/{ticket} |
GET | Ticket detail |
/support/guest/create |
GET | Guest ticket form |
/support/guest/{token} |
GET | Guest ticket view (magic link) |
/support/agent |
GET | Agent dashboard |
/support/agent/tickets |
GET | Agent ticket queue |
/support/agent/tickets/{ticket} |
GET | Agent ticket view |
/support/admin/reports |
GET | Admin reports |
/support/admin/departments |
GET | Department management |
/support/admin/sla-policies |
GET | SLA policy management |
/support/admin/escalation-rules |
GET | Escalation rule management |
/support/admin/tags |
GET | Tag management |
/support/admin/canned-responses |
GET | Canned response management |
/support/inbound/mailgun |
POST | Mailgun inbound webhook |
/support/inbound/postmark |
POST | Postmark inbound webhook |
/support/inbound/ses |
POST | SES/SNS inbound webhook |
/support/agent/tickets/bulk |
POST | Bulk actions on multiple tickets |
/support/agent/tickets/{ticket}/follow |
POST | Follow/unfollow a ticket |
/support/agent/tickets/{ticket}/macro |
POST | Apply a macro to a ticket |
/support/agent/tickets/{ticket}/presence |
POST | Update presence on a ticket |
/support/agent/tickets/{ticket}/pin/{reply} |
POST | Pin/unpin an internal note |
/support/{ticket}/rate |
POST | Submit satisfaction rating |
All routes use the configurable prefix (default: support). Inbound webhook routes use the api middleware (no auth, no CSRF).
Plugin SDK
Escalated supports framework-agnostic plugins built with the Plugin SDK. Plugins are written once in TypeScript and work across all Escalated backends.
Installing Plugins
The plugin bridge is built into escalated-laravel — no additional PHP package required. Install plugins and the runtime via npm:
npm install @escalated-dev/plugin-runtime npm install @escalated-dev/plugin-slack npm install @escalated-dev/plugin-jira
Enabling SDK Plugins
// config/escalated.php 'plugins' => [ 'enabled' => true, 'sdk_enabled' => true, // Enable the Node.js bridge ],
How It Works
SDK plugins run as a Node.js subprocess managed by @escalated-dev/plugin-runtime, communicating with Laravel over JSON-RPC 2.0 via stdio. The escalated_do_action() and escalated_apply_filters() helpers dual-dispatch to both legacy PHP plugins and new SDK plugins simultaneously — no changes to existing hook call sites.
Building Your Own Plugin
import { definePlugin } from '@escalated-dev/plugin-sdk' export default definePlugin({ name: 'my-plugin', version: '1.0.0', actions: { 'ticket.created': async (event, ctx) => { ctx.log.info('New ticket!', event) }, }, })
Resources
- Plugin SDK — TypeScript SDK for building plugins
- Plugin Runtime — Runtime host for plugins
- Plugin Development Guide — Full documentation
See the detailed Plugin Bridge section below for the full architecture, auto-generated routes, dual dispatch, and store documentation.
Plugin Bridge (SDK Plugins)
Escalated supports a second generation of plugins written in TypeScript using the @escalated-dev/plugin-sdk. These plugins run as a Node.js subprocess managed by @escalated-dev/plugin-runtime and communicate with Laravel over JSON-RPC 2.0 via stdio.
How It Works
Laravel (PHP) Plugin Runtime (Node.js)
┌──────────────────────┐ stdio ┌──────────────────────┐
│ PluginBridge │◄────────►│ @escalated-dev/ │
│ - spawns subprocess │ JSON- │ plugin-runtime │
│ - dispatches hooks │ RPC 2.0 │ ┌────────────────┐ │
│ - handles ctx.* │ │ │ Slack Plugin │ │
│ - mounts routes │ │ │ Jira Plugin │ │
└──────────────────────┘ │ │ ... │ │
│ └────────────────┘ │
└──────────────────────┘
The bridge spawns the runtime lazily on the first hook dispatch and keeps the process alive across requests (one long-lived subprocess per PHP-FPM worker). If the process crashes it is automatically restarted with exponential backoff.
Requirements
- Node.js 18+
@escalated-dev/plugin-runtimeinstalled in your project:
npm install @escalated-dev/plugin-runtime
Install any SDK plugins the same way:
npm install @escalated-dev/plugin-slack @escalated-dev/plugin-jira
Startup Sequence
EscalatedServiceProvider::boot()calls$bridge->boot()- Bridge spawns
node node_modules/@escalated-dev/plugin-runtime/dist/index.js - Protocol handshake confirms version compatibility
- Bridge fetches the plugin manifest (pages, hooks, endpoints, webhooks)
- Routes are registered in Laravel for plugin pages, API endpoints, and webhooks
- Runtime is ready to receive hook dispatches
Auto-generated Routes
For each installed SDK plugin the bridge automatically registers:
| Category | URL Pattern | Auth |
|---|---|---|
| Admin pages | {prefix}/admin/plugins/{plugin}/{route} |
Admin |
| Data endpoints | {prefix}/api/plugins/{plugin}/{path} |
Admin |
| Webhook endpoints | {prefix}/webhooks/plugins/{plugin}/{path} |
None |
Dual Dispatch (Backward Compatibility)
The existing escalated_do_action() and escalated_apply_filters() helper functions dispatch hooks to both old PHP plugins and new SDK plugins simultaneously. No changes are required to existing hook call sites.
// This automatically dispatches to PHP plugins AND SDK plugins: escalated_do_action('ticket.created', $ticket->toArray()); // Same for filters: $channels = escalated_apply_filters('notification.channels', []);
Plugin Store
SDK plugins can persist data using ctx.store. This is backed by the escalated_plugin_store table:
php artisan vendor:publish --tag=escalated-migrations php artisan migrate
Configuration
// config/escalated.php 'plugins' => [ 'enabled' => true, 'sdk_enabled' => true, // Enable the Node.js bridge 'runtime_command' => 'node node_modules/@escalated-dev/plugin-runtime/dist/index.js', 'runtime_cwd' => base_path(), // Working directory for the subprocess ],
Writing SDK Plugins
See the @escalated-dev/plugin-sdk package for the full TypeScript authoring API. A minimal plugin looks like:
import { definePlugin } from '@escalated-dev/plugin-sdk' export default definePlugin({ name: 'my-plugin', version: '1.0.0', actions: { 'ticket.created': async (event, ctx) => { const config = await ctx.config.all() // ... do something }, }, endpoints: { 'GET /settings': { capability: 'manage_settings', handler: async (ctx) => { return await ctx.config.all() }}, }, })
Documentation
Testing
composer install vendor/bin/pest
Also Available For
- Escalated for Laravel — Laravel Composer package (you are here)
- Escalated for Rails — Ruby on Rails engine
- Escalated for Django — Django reusable app
- Escalated for AdonisJS — AdonisJS v6 package
- Escalated for Filament — Filament v3 admin panel plugin
- Shared Frontend — Vue 3 + Inertia.js UI components
Same architecture, same Vue UI, same three hosting modes — for every major backend framework.
License
MIT