Release notes and version history for Escalated and its framework packages.
v1.9.15Escalated for Laravel
Upgrading
The agent ticket pages send the agent panel's optional inbox and queue props (shared frontend docs/agent-panel.md). Each list request runs a few extra count queries (3 counts, 5 built-in views, 4 stats and one per saved view, up to 50 views), cached per agent and queue for escalated.agent_inbox.cache_seconds (env ESCALATED_AGENT_INBOX_CACHE_SECONDS, default 10; 0 turns the cache off), so counts can lag a change by that long. Older frontends ignore the props.
A ticket's latest_reply is now its latest public reply. It used to be null whenever the newest reply was an internal note, so the list showed no preview, last_reply_at and last_reply_author were empty, and the agent dashboard's recent tickets did the same. Internal notes are still never used.
Added
inbox on Escalated/Agent/TicketIndex and Escalated/Agent/TicketShow (not in cloud mode, where tickets are not stored locally): { counts: { mine, unassigned, all }, views: [{ id, name, count, filters }], stats: { open, urgent, sla_at_risk, resolved_today } }. Every number is a count through the ticket list's own filters and the ticket visibility scope (department visibility, account scoping), so it is what the agent sees by opening that list.
counts and view counts are of unresolved (not resolved or closed), unsnoozed tickets, unless a view's filters name a status.
views are the built-in views urgent (priority=urgent&open=1), sla_at_risk (sla_at_risk=1), waiting_on_me (assigned to the agent, status=waiting_on_agent), waiting_on_customer (assigned to the agent, status=waiting_on_customer) and recently_updated (open=1&updated_within_hours=24&sort_by=updated_at), then the agent's own and shared saved views (SavedView), by position.
stats are for the current queue: the queue's filters plus status=open; priority=urgent (unresolved); sla_at_risk=1; status=resolved&resolved_today=1.
queue on both pages:{ key, title }, with keymine (assigned_to=<agent>, or assigned=me / assigned_to=me), unassigned (unassigned=1), all, or view:<id> for a view picked with view=<id> or by sending exactly its filters. An unknown view id, or another agent's private view, selects nothing. The ticket page gets the queue last opened in the list (kept in the session), or all.
New ticket list filters: sla_at_risk=1 (unresolved, with an SLA breached or a first response still unsent or resolution due within 60 minutes), resolved_today=1 (resolved since the start of today), open=1 (not resolved or closed) and updated_within_hours=<1..8760>. They are validated, and apply to every caller of the local driver's ticket list.
Translations for the queue and built-in view names (escalated::messages.agent_inbox.*) in all 14 locales.
agents on Escalated/Agent/TicketShow:[{ id, name }], the agents and admins the ticket can be assigned to (the staff gates, the host's escalated.authorization.agent_scope when set, and department visibility / account scoping), so the agent panel can reassign a ticket and not only take it. The shared frontend declares the prop; a frontend that does not ignores it.
requester_tickets on the agent ticket page's ticket: the requester's five most recent other tickets (id, reference, subject, status, created_at, updated_at), matched as requester_ticket_count is (the requester, or a guest's email) and through the ticket visibility scope. The agent panel's customer panel lists them as previous conversations. related_tickets is unchanged and still means linked tickets.
Agents can snooze and unsnooze from the agent panel:POST {prefix}/agent/tickets/{ticket}/snooze (snoozed_until, a future date) and POST {prefix}/agent/tickets/{ticket}/unsnooze, named escalated.agent.tickets.snooze and escalated.agent.tickets.unsnooze, authorized as a ticket update. Until now only the admin panel had snooze routes. Both need the ticket.edit permission when roles and permissions are on, as the admin routes do.
Not sent
is_unread and requester_online: Escalated tracks no per-agent read state or customer presence, so the list rows carry neither. Live chat tickets are still marked through is_live_chat.
Changed
The bundled widget is now @escalated-dev/escalated 0.12.8. The widget's source did not change in 0.12.8; the bundle is rebuilt with Vue 3.5.44, so it behaves as before.
The agent panel, redesigned. Pages under /agent now share a rail and top bar with a command palette (Cmd/Ctrl K), a dense inbox beside a detail pane, and a conversation-first ticket workspace with a customer and details panel and a lighter composer. Agents can resolve, reopen, snooze, reassign and move tickets without leaving the conversation, see toasts and loading states, set their status, and drive it all from the keyboard (j/k, g i, r, n, e, z, ...). Contrast and focus hold WCAG AA in both themes.
Every new prop (inbox, queue, agents, ticket.requester_tickets) is optional: a backend that sends none of them keeps working lists and tickets. The Laravel, NestJS, Rails, Django, Adonis, Symfony, Phoenix, Go, Spring and .NET backends send them from their 2026-11-04 releases. Read Upgrading below before deploying, and see docs/agent-panel.md for the prop shapes and routes.
Upgrading
The agent panel is redesigned (#227 to #237). Pages under /agent
use a rail and top bar instead of the old top navigation,
Agent/TicketIndex is a list beside a detail pane, and Agent/TicketShow
is a conversation-first workspace with a customer and details panel and a
new composer. Page names and the props they already read are unchanged,
and the admin layout is untouched. Hosts that restyle the agent pages with
their own CSS should check them; the panel tokens (theme.panel) still
apply.
New optional agent page props, all listed in pages.json and
documented in docs/agent-panel.md: inbox and queue on
Agent/TicketIndex and Agent/TicketShow (rail counts, saved views, the
queue title and stat strip), agents ([{ id, name }], for reassigning)
on Agent/TicketShow, and ticket.requester_tickets (the customer's
other conversations). A backend that sends none of them gets the same
lists and tickets without counts, views, the reassign list or previous
conversations. The escalated backends send them from their releases of
2026-11-04.
The customer panel reads only requester_tickets. It used to list
related_tickets (linked tickets on escalated-laravel) as the customer's
recent tickets; linked tickets now show under Related only.
Snooze, unsnooze and agent status from the agent panel call
escalated.agent.tickets.snooze / escalated.agent.tickets.unsnooze
(the admin routes for admins) and, when the host has it,
escalated.admin.chat.status. A backend without these routes keeps the
rest of the panel; the actions fail with an error toast.
TicketFilters gains showSearch and showSaveView (both true by
default) and KeyboardShortcutHelp gains withList and agent; existing
uses render as before.
Fixed
Customer history. The customer tab listed related_tickets (a
ticket's linked tickets on escalated-laravel) as the customer's recent
tickets. The panel reads only requester_tickets.
The composer's hidden file input has an accessible name.
Light panel contrast. The light theme's tertiary text (#646b78), urgent
priority (#b31b1b), SLA breach (#b91c1c) and SLA warning (#a34807)
are a shade darker so they hold WCAG AA (4.5:1) on hovered, active and
selected rows and on the urgent tint. Hosts that set these tokens keep
their own values.
Agent inbox rows. Hovering a row kept the initials circle but showed the
browser's native checkbox (a black square in dark mode); on the ticket page,
where rows are not selectable, the avatar vanished. Rows now keep the circle
and show a token-styled checkbox only where rows can be selected. The first
j/k lands on the current row instead of skipping it, and each age shows
the full date and time on hover.
Queues while searching. Searching, filtering, clicking a stat tile or
paging inside a queue keeps its title and its highlight in the rail.
Agent panel focus and contrast. Placeholders use the tertiary token
(AA in both modes); the composer shows focus on its border; every control on
the agent pages has the accent focus ring.
Popovers. The tag picker is a filterable menu with tag colours that
closes on Escape or a click outside. The filter popover drops its second
search box and the "Save as View" button nothing listened to, and closes
the same way. TicketFilters gains showSearch and showSaveView props,
both defaulting to true.
Keyboard help.KeyboardShortcutHelp is translated, uses the panel
tokens without a blurred backdrop, takes focus and closes on Escape, and
lists the keys each page binds (new withList and agent props; admin
pages no longer list the agent-only g sequences).
Layout. The list column is 340px from lg and 420px from xl, so the
ticket keeps a usable width at 1024px. On a phone the section tabs stay on
one line and the ticket header scrolls away with the thread while the
composer stays docked.
Added
Ticket actions in the agent panel: Resolve and Reopen buttons; a
Snooze menu (1 hour, 4 hours, tomorrow, next Monday, or a date and time,
and Unsnooze) using escalated.agent.tickets.snooze/unsnooze, or the
admin routes for admins; reassigning to any agent in the new optional
agents prop on Escalated/Agent/TicketShow ([{ id, name }], declared in
pages.json); changing the department; previous and next ticket from the
list (Shift K / Shift J); Ticket history in the overflow menu; z opens
Snooze. Tag chips carry a tag icon in the tag's colour.
PanelMenu takes item icons, an optional filterPlaceholder (a filter
box beside the menu) and a default slot for a footer.
Rail links match their counts. When the backend sends inbox.counts
(open tickets), Inbox, Unassigned and All tickets open their lists with
open=1, and views open as view=<id> (plus open=1 unless the view names
a status). The list says "open" in its subline and offers "Include resolved"
/ "Open only"; "Clear filters" returns to the queue as the rail opens it.
Feedback. Toasts for the backend flash and for ticket actions without
one, inline send errors and a Sending state in the composer, a skeleton
while the next ticket loads, a dimmed busy list while a search loads, a
filtered-empty state with Clear filters, an unread dot, and a check on
Copy link.
Agent status. The rail footer and a top-bar avatar show the agent's
status (Online, Away, Offline) with a dot and a menu, whether or not live
chat is on. It reads the shared escalated.agent_status (or
escalated.chat_status), then the browser's last choice, and posts changes
to escalated.admin.chat.status when the host has that route. See
docs/agent-panel.md.
Semantic panel tokens, set by theme.panel in dark and light and each
overridable by its camelCase name: --esc-panel-note-{bg,border,text},
--esc-panel-selected-{bg,border} (from the accent),
--esc-panel-primary-{bg,text} (accent with a readable text colour),
--esc-priority-{low,medium,high,urgent} and --esc-sla-{ok,warn,breach}.
useKeyboardShortcuts accepts two-key sequences such as 'g i'.
Changed
A lighter, faster composer. Idle and empty it is 75px instead of 159px
(one line under the tabs); focus, a draft, files or an open menu open it
up, and the text box grows with the text up to 40% of the window. Public
replies say who they go to; internal notes turn the whole composer amber.
Ctrl/Cmd Shift Enter sends and resolves, and both shortcuts are shown on
the Send button and its menu. Files can be dropped or pasted, and a draft
can be discarded after a confirmation. At 1280×720 the conversation gets
483px of height instead of 335px.
Agent ticket workspace. The ticket header is 103px instead of 163px
(283px with a long subject): the reference shares a row with the actions,
the subject gets the full width (two lines at most), and status, priority,
assignee, department and tags sit on one row of quiet controls. The
conversation is the only main view and scrolls on its own under a fixed
header, with the composer below it; messages are no longer cards. Customer
messages, agent replies (accent rule), internal notes (amber, lock) and
system events (from ticket.activities) are told apart, text keeps a
760px measure, and attachments are compact file chips.
The Details, Customer and Related tabs became a customer and details
panel beside the conversation (from a 900px pane, so 1920px screens show
it by default) or over it on demand (i, or the header button): customer,
organization when sent, previous conversations, details and filled-in
custom fields, related tickets, the knowledge base link and the ticket
history, in collapsible sections. Plugin slots ticket.show.sidebar and
ticket.show.context-panel render inside it.
Denser agent inbox. Rows are 69px instead of 87px and name the
requester on the first line beside the age; the reference moves next to the
latest message preview. The four KPI tiles become a single row of filter
toggles with icons (fire for Urgent, clock for SLA at risk, check for
Resolved today), and the header is one title line with a short subline and
a labelled New ticket icon button. At 1280×720 the list shows 7 rows instead
of 5 (10 instead of 7 at 1440×900).
The agent rail is a drawer below 1280px (was 1024px), so on a tablet
the list and the ticket get the width. The list column is 360px up to
1536px, then 400px. Rail items are 28px tall with tighter section spacing.
Section tabs in the agent top bar have icons (Tickets, Dashboard,
Knowledge base), hidden on phones where the tabs scroll.
Agent ticket view.Agent/TicketShow keeps the inbox list beside the
ticket on wide screens and shows the ticket in a new TicketDetailPane: the
reference with a copy-link button, the subject, an overflow menu (macros,
custom actions, merge, snooze, copy link) and a follow star; status and
priority menus, tag chips with remove and add (the tags endpoint), and the
assignee with a plain "Assign to me". Tabs: Conversation (oldest first,
pinned notes on top, internal notes tinted with a lock, attachments as file
cards), Details (a dense definition list of today's sidebar content),
Customer (profile, recent tickets, the context panel data and its plugin
slot) and Related (linked tickets, knowledge base, activity). The new
Composer sits at the bottom: Reply / Internal note, attach, canned
responses, emoji, bold, italic, link and list, a split Send with "Send and
set waiting on customer" and "Send and resolve", Ctrl/Cmd Enter, and drafts
saved per ticket and mode in localStorage. Live chats show the chat thread,
chat composer and chat action bar instead; light agents get internal notes
only. Shortcuts: r, n, s, p, f, a (assign to me), e
(resolve), and the page's actions in the command palette. The optional
inbox and queue props are accepted here too. ReplyComposer and
TicketSidebar are unchanged and still exported.
Agent inbox split view.Agent/TicketIndex is a list column (queue
title and count, stat strip, search, a filter popover holding the existing
TicketFilters, and dense rows with initials, reference, subject, preview,
age, priority and SLA markers) beside a detail pane that says "Select a
ticket" until one is opened. j/k move, x selects (the bulk bar is
unchanged), Enter opens, / searches; arrow keys move between rows.
Quick filters move to the queues in the rail. New optional props inbox
and queue are documented in docs/agent-panel.md; a backend that sends
neither gets the same list without counts, views or stats.
Agent panel shell. Pages under /agent now use a rail and top bar in the
same family as the admin panel instead of the old top navigation. The rail
lists the workspace queues (Inbox, Unassigned, All tickets), saved views and
live chats; the top bar has the section tabs (Tickets, Dashboard, Knowledge
base when it is enabled) and a search button that opens a command palette
(Cmd K / Ctrl K: open a ticket by reference, search tickets, run actions).
g i, g u and g a go to the three queues. Counts and views appear when
the backend sends the optional inbox page prop (documented with the inbox
redesign); without it the rail shows the queues without numbers. The admin
layout is unchanged. EscalatedLayout takes a new optional fullBleed prop
for pages that lay out their own panes.
README. The demo GIF opens on Pinecone, a made-up notes app, and
then shows the redesigned agent panel (#230, #233); the social image shows
the new agent inbox (#234).
The widget bundle (dist/widget/escalated-widget.js) is rebuilt with
Vue 3.5.44. The widget's source did not change.
Security
Dev dependencies only; nothing here ships in the package (its one runtime
dependency is @escalated-dev/locale).
npm audit fix moves follow-redirects to 1.16.2 (GHSA-mrwp-f3q8-3g9p)
and joi to 18.3.1 (GHSA-4528-x5fx-h6hg).
eslint 10.14, eslint-plugin-vue 10.12, eslint-plugin-storybook and
Storybook 10.6.5, @vue/test-utils 2.5.1: the latest releases of the
packages that pull in minimatch and glob.
Even those latest releases still ask for minimatch ^10 and glob ^13,
so npm overrides pin minimatch ^11.0.3 (GHSA-g3hv-75wg-m249) and
glob ^14.0.3 (GHSA-3g3m-2fjr-6pw4). Their consumers (eslint,
@eslint/config-array, @typescript-eslint/typescript-estree,
editorconfig, js-beautify) only use Minimatch#match, minimatch(),
escape(), globSync() and hasMagic(), which the new majors keep.
Drop the overrides once those packages depend on the patched majors.
An Inertia visit to an Escalated route that ends in 403, 404, 419, 429, 500 or 503 now gets the shared Escalated/Error page (props status and a translated message, as Rails sends them) instead of the host's HTML error page, which the Inertia client could only show in a modal. Full page loads, JSON and API requests, and the host's own routes are untouched, and so are validation, authentication and redirect responses. The message is generic per status (escalated::messages.errors.*, publishable); an exception's own message never reaches the page. While app.debug is on, a 500 is left to the framework's debug page. It is a render callback on the host's exception handler, registered only while the built-in UI is enabled.
Plugin ctx.tickets.create, ctx.tickets.update and ctx.replies.create now go through the ticket service (plugin contract, The plugin context), so a plugin's ticket or reply behaves like one written over the API. A new ticket gets the default priority and type, the opening activity and the default SLA; a status on create or update is a status change (ticket.status_changed, the activity log, and the configured transitions: a change they do not allow is an error and writes nothing); a priority or department change is logged and announced (ticket.priority_changed, ticket.department_changed); a reply or note is logged, its @mentions processed, and authored by the signed-in user of the request the plugin is serving, or by nobody. An unknown ticket_type is now an error. While a plugin action is being delivered, the same action raised by a plugin's own write is not delivered to plugins again (nesting stops at three), so a ticket.created handler that opens a ticket no longer triggers itself; webhooks and workflows still see every event. Ticket::addReply() accepts a null author.
ctx.replies.create, ctx.tickets.create, ctx.tickets.update and ctx.tags.create only accept the plugin contract's fields (plugin contract, The plugin context). Any other key is an error naming it, instead of being mass-assigned (replies, tags) or silently dropped (tickets). Replies: ticket_id (an existing ticket), body, is_internal (true makes a note); the author is the signed-in user of the request the plugin is serving, or nobody, and a plugin can no longer set author_*, is_internal_note, type, is_pinned or metadata. Tickets: subject/title, body/description, priority, status, department_id, ticket_type, metadata, and on create channel (default api) and contact_id or requester_email/requester_name (a contact, found or created); never a requester id, assignee, reference or SLA field. An unknown priority, status, channel, department or contact is an error. Tags: name, slug, color. Plugins that sent other keys must drop them. ctx.tickets.create works again: it wrote a body column that does not exist and never set the required description. Tickets get a new channel value, api.
SDK plugin pages, endpoints and webhooks are now mounted on the plugin's slug (plugin contract, Identity), not its raw name: @acme/plugin-crm answers at {prefix}/admin/plugins/acme--plugin-crm/..., {prefix}/api/plugins/acme--plugin-crm/... and {prefix}/webhooks/plugins/acme--plugin-crm/..., and its routes are named escalated.plugin.acme--plugin-crm.* (page route names replace / in the page route with --). The old /plugins/@acme/plugin-crm/... URLs are 404. Update any webhook URL registered with a third party for a scoped plugin; plugins without @ or / in their name keep their URLs. Menu items point at the new page URLs. Two installed plugins whose names give the same slug are a configuration error: neither is mounted, and the error names both.
ctx.* calls from the plugin runtime act for the plugin the host sent the call to, not for the plugin the runtime names (plugin contract, The plugin context). During an endpoint, webhook, cron, badge, activate or deactivate call, every ctx call acts for that call's plugin. During an action or filter, a ctx call must name one of the active plugins the hook was sent to, or it is refused; a ctx call outside any host call is refused too. ctx.log is never refused.
SDK plugins can now be activated and deactivated, and only active ones run (plugin contract, Lifecycle). Before, every SDK plugin the runtime loaded got every hook, cron schedule and badge, and its routes and menus were live, with no way to switch it off. The first time the runtime starts after upgrading, every SDK plugin installed at that moment is recorded active in escalated_plugins, so nothing that ran before stops. SDK plugins installed afterwards start inactive: activate them on Admin > Plugins. No migration is needed. Inactive SDK plugins get no hooks, cron or badges, add no menus, widgets or slots, answer 404 on their pages, endpoints and webhooks, and have their ctx calls refused (except ctx.log). The runtime still loads every installed plugin; @escalated-dev/plugin-runtime versions that honour the plugins list the host sends with action and filter run only active plugins' handlers, and with older ones an inactive plugin's handler can still run but cannot read or write through ctx.
Activating or deactivating a plugin from Admin > Plugins now answers with a 303 to the index instead of a redirect back, and an unknown slug is an error flash (404 for JSON requests) instead of creating an escalated_plugins row. The page receives can_upload: true.
SDK plugin cron schedules now run. Run php artisan escalated:plugins:cron every minute (Schedule::command('escalated:plugins:cron')->everyMinute()), or turn on escalated.scheduling.auto_register, which now schedules it unless plugins, SDK plugins or tenant mode say otherwise. Until you do, schedules still never run.
Roles & permissions is now an install-level switch, off by default (escalated.access.roles_permissions, env ESCALATED_ROLES_PERMISSIONS; ADR 2026-10-29-configurable-access-features). Roles were stored but never enforced, so off is how every install already behaved, with two visible differences:
While it is off, the roles screens ({prefix}/admin/roles*) answer 404.
While it is off, the newsletter screens no longer check newsletters.manage / newsletters.send; the admin gate decides, like every other screen. Installs that used those permissions to restrict admins must turn the switch on.
To enforce roles: seed the permissions (php artisan db:seed --class="Escalated\Laravel\Database\Seeders\PermissionSeeder"), give every staff user a role (php artisan escalated:roles:assign you@example.com admin), then set ESCALATED_ROLES_PERMISSIONS=true. Passing the admin gate grants no permissions by itself, so an admin without a role is refused everywhere but their own two-factor screen. See docs/access-control.md.
Two-factor authentication is now an install-level switch, off by default (escalated.access.two_factor, env ESCALATED_TWO_FACTOR). Enrolment was possible but no login was ever challenged. While it is off, the two-factor screen and its setup/confirm/disable routes answer 404 (they answered 200 before) and nobody is challenged; enrolments are kept. To enforce it, run php artisan migrate (adds the nullable last_used_step column to two_factor), then set ESCALATED_TWO_FACTOR=true; users who already enrolled are challenged from their next visit. See docs/access-control.md.
Single sign-on is now an install-level switch, off by default (escalated.access.sso, env ESCALATED_SSO). SSO settings were saved but nothing signed anyone in. While it is off, the SSO settings screen (escalated.admin.settings.sso*) answers 404 (it answered 200 before) and so does every sign-in route; stored settings are kept. To use it, run php artisan migrate (creates escalated_sso_message_ids), set ESCALATED_SSO=true, give the IdP the endpoints from the SSO settings page, and decide on escalated.sso.create_users / sync_roles (both default off). See docs/access-control.md.
SsoService::validateSamlAssertion() is replaced by validateSamlResponse(), and validateJwtToken() now requires exp, aud and a single-use jti and returns {email, name, role} without claims. Neither was called anywhere in the package; hosts that called them directly must update.
The two-factor screen (escalated.admin.two-factor.*, same URLs) is now open to every staff user, not only admins, since it manages the signed-in user's own account.
The shared escalated.permissions prop is ['*'] for staff while the switch is off (it was the user's role slugs, which nothing enforced), and escalated.features.roles_permissions tells the frontend whether roles are on.
Plugin UI extensions are shared as escalated.plugins (menuItems, pageComponents, dashboardWidgets), the shape the shared frontend reads. The old plugin_menu_items, plugin_dashboard_widgets and plugin_pages shared props are gone; the bundled pages never read them. Admin menu items go to admins only, and items with a permission (or an SDK capability) the viewer lacks are left out. With nothing to show, escalated.plugins is {}.
Plugins now receive the canonical domain events.ticket.created, reply.created, note.created, ticket.status_changed and the rest of the event vocabulary are delivered after commit to native plugins' escalated_add_action() callbacks and to SDK plugins' actions, with the payload in docs/plugins.md. The documented escalated_ticket_created hook was never fired; listen for ticket.created instead. A throwing plugin action is logged and no longer reaches the request.
ctx.tickets.query and ctx.replies.query only accept the plugin contract's filter keys. Tickets: id, reference, status, priority, channel, ticket_type, department_id, requester_id, contact_id, assigned_to. Replies: ticket_id, is_internal (the internal-note flag), type. Any other key, or a non-scalar value, is an error instead of a column name. Results are capped at 500 rows.
Added
SDK plugins on Admin > Plugins (plugin contract): listed by slug with name, version, description, is_active, activated_at, source: composer and provides counts; activate sends the runtime activate {plugin}, deactivate sends deactivate {plugin} (PluginBridge::activate() / deactivate()); uninstall is refused (npm manages them).
Lifecycle actions plugin.activated, plugin.deactivated and plugin.uninstalling with {plugin, slug}, to native and SDK plugins, in the contract's order. A throwing callback is logged and does not undo the transition.
Widget badges (plugin contract, Badges): an SDK plugin widget with a badge function (hasBadge: true) carries badge in escalated.plugins.dashboardWidgets, asked from the runtime as badge {plugin, component} as the viewer, within 5 s (null when it fails). A native widget registered with 'badge' => fn ($user) => ... (or a number or string) gets the same. Widgets without one carry no badge key. PluginBridge::badge().
SDK plugin cron (plugin contract, Cron): escalated:plugins:cron sends cron {plugin, schedule} to the plugin runtime for every schedule in a plugin's cronSchedules that is due that minute (five-field expressions, UTC, via dragonmantank/cron-expression), as no user, within 30 s. An unreadable expression is logged and skipped, a failing schedule is logged and never stops the others, and nothing runs in tenant mode or with plugins off. --at=<time> runs a given minute. Only active plugins' schedules run.
Roles & permissions are enforced when switched on. Every admin, agent and staff API route needs its permission from the user's roles as well as the host gate (EnforceRoutePermissions, map in src/Support/RoutePermissions.php; unmapped routes are refused, and a test checks every route is mapped). Status changes to resolved/closed/reopened also need ticket.close, bulk actions need each action's permission, and the staff API checks the token owner's permissions. Failing answers 403.
php artisan escalated:roles:assign {user} {role=admin} gives a user a role by email or ID.
Two-factor is enforced when switched on. A staff user with a confirmed enrolment must pass Auth/TwoFactorChallenge (GET|POST {prefix}/two-factor/challenge, throttled to 5 a minute) with a TOTP or single-use recovery code before any admin or agent route, once per login. A TOTP code cannot be used twice. escalated.two_factor.required (ESCALATED_TWO_FACTOR_REQUIRED) or the two_factor_required setting makes every staff user enrol first. php artisan escalated:two-factor:reset {user} clears an enrolment. The page props are action and error; shared props gain features.two_factor and two_factor_required, and Admin/Settings/TwoFactor gains required.
Single sign-on works when switched on.GET {prefix}/sso/login starts sign-in with the provider chosen in the admin. SAML (onelogin/php-saml, strict) requires a signed assertion from the configured certificate and checks Issuer, Audience, Destination, Recipient, validity windows, InResponseTo against requests this install issued (single use) and assertion replay. OAuth 2 / OIDC (league/oauth2-client) uses a session-bound state and PKCE S256 and reads the userinfo endpoint. JWT (firebase/php-jwt) pins the configured algorithm and requires exp, aud and a single-use jti. Users are matched by email; escalated.sso.create_users creates unknown ones and escalated.sso.sync_roles maps the role attribute to an Escalated role. New routes escalated.sso.login, .saml.metadata, .saml.acs, .oauth.callback, .jwt.callback; the SSO settings page gains an endpoints prop; shared features.sso.
Security
The old SAML check verified the signature over SignedInfo without checking the reference digests, so the signed content was not bound to the assertion, and it checked no Audience, Destination, InResponseTo or replay. It is replaced by onelogin/php-saml as above.
Plugin ticket and reply queries used the filter's keys as column names, so an SDK plugin could filter on any column (such as guest_token) or pass an arbitrary column expression. Keys are now mapped through a whitelist.
ctx.emit drops hook names that would impersonate a canonical event, a legacy alias of one, or a plugin lifecycle action.
Changed
POST {prefix}/admin/settings (escalated.admin.settings.update) now accepts a partial payload: every key is optional, a key that is sent is validated as before, and keys left out keep their stored value. The shared Admin/Settings/TwoFactor page uses this to save two_factor_required on its own. The full Settings page still sends every key, so nothing changes for it.
TOTP uses pragmarx/google2fa (new dependency) instead of hand-written code, and new secrets are 32 base32 characters (160 bits) instead of 16. Existing secrets keep working.
PUT {prefix}/admin/users/{user}/roles (escalated.admin.users.roles, needs role.manage) replaces a user's roles; you cannot remove your own role.manage. While roles are on, the users page also receives each user's roles and the assignable roles.
Plugin widget badges (plugin contract, "Badges"). The agent dashboard
shows escalated.plugins.dashboardWidgets[].badge (a number or a string)
next to the widget's title; a widget without a title shows the badge alone,
labelled with its component for screen readers. null, a missing key, an
empty string and 0 show nothing. Plugin menu items' badge in the admin
nav uses the same rules, through the new exported PluginBadge component.
Configurable access features (ADR 2026-10-29-configurable-access-features;
escalated-laravel #271, #273, #274).
The admin nav hides Roles when the shared prop
escalated.features.roles_permissions is false (the switch is off and the
Roles screens answer 404). When it is true, Roles also needs the
role.view permission. Backends that do not send the flag keep the link.
Admin/Users/Index takes an optional roles prop ([{id, name, slug}],
sent only while roles & permissions is on) and reads each user row's
roles (slugs). It then shows a Roles column with a checkbox per role;
changing one saves the user's whole set with
PUT route('escalated.admin.users.roles', user.id) and
{"role_ids": [...]}. Without the prop the page is unchanged.
Admin/Settings/SsoSettings takes an optional read-only endpoints prop
(login, sp_entity_id, metadata, acs, oauth_redirect,
jwt_callback) and shows each URL the backend sends in an
"Identity Provider Endpoints" card, so the admin can register the app with
the IdP. Without the prop there is no card.
Admin/Settings/TwoFactor takes an optional required prop. Every staff
user sees whether two-factor is required for all staff; an admin with
settings.manage gets a switch that posts
{"two_factor_required": true|false} to
route('escalated.admin.settings.update'). Without the prop there is no
card. The backend's settings route must accept that single key on its own
(escalated-laravel does from the release after 2026-10-29).
pages.json lists the new optional props.
Security
Dev dependency happy-dom bumped to 21.0.4 (GHSA-834v-8h66-3h79). Test-only; not shipped in the package.
The packaged-consumer contract check (npm run test:inertia) tolerates
GHSA-gcqx-w48m-w2h4 (deepmerge, no patched release) for the Inertia 1
adapter only: it comes from @inertiajs/core 1.x, not from this package.
Any other advisory still fails the check.
Disabled guest, widget and chat routes answer 404. These features are switched by admin settings stored in the database, so their routes stay registered (route caching cannot read settings) and now answer 404 while the feature is off, through the new EnsureFeatureEnabled middleware:
guest_tickets_enabled (default on): every {prefix}/guest/* route, including viewing, replying to and rating an existing guest ticket by its link, and widget ticket creation, verification and lookup for tickets. Before, viewing, replying and rating kept working with guest tickets off, and widget ticket creation, verification and lookup answered 403.
widget_enabled (default off): widget /config, /articles, /articles/{slug}, /tickets and /tickets/{reference}, which answered 403 before. The bundled widget already treats a failed /config as "no settings".
chat_enabled (default off): the visitor chat routes (/chat/start, /chat/{session}/message(s), /typing, /end, /rate) and chat verification. Before, start and verification answered 403 and the others still served an existing chat. /chat/availability still answers {"available": false, "message": "Chat is disabled."} so the widget can hide its chat tab. The agent chat screens and chat settings stay available so admins can turn chat on and agents can wrap up.
Hosts that expected 403 from these routes should treat 404 the same way. Nothing changes while the features are on, and guest tickets stay on by default.
The cloud webhook receiver is registered only when escalated.hosted.signing_secret (ESCALATED_CLOUD_SIGNING_SECRET) is set. Without it, POST /escalated/cloud/webhook now answers 404 instead of 503. Synced installs that already set the secret are unaffected; if you cache routes, run php artisan route:cache again after setting it.
Security
escalated:tenant-retry again refuses a failed job whose tenant does not own its models. Laravel 13's queue:retry now catches ModelNotFoundException while it reads the job's retry deadline and re-queues the job anyway, so a payload tagged with one tenant but carrying another tenant's models went back on the queue instead of staying failed. The command now restores the job's models under the job's own tenant before re-queuing it; a mismatch fails that ID and leaves it in the failed-jobs table, as before.
Import adapters should send each attachment's bytes as content (a string of the file's bytes); the importer stores them on the attachment disk like any upload, subject to escalated.tickets.max_attachment_size_kb. An attachment record that sends only a path is now accepted only when that path is a plain relative path inside escalated.attachments.import_path (default escalated/imports, env ESCALATED_IMPORT_ATTACHMENTS_PATH) on the import disk and the file exists; any other path fails that record and is logged on the import job. Adapters that wrote files elsewhere on the disk should move them under that directory or send content.
Security
Import adapters can no longer point attachments at arbitrary files. The importer saved whatever path an adapter returned as the attachment's path on the import disk, so a misbehaving or compromised adapter could make an imported attachment serve any file on that disk (the host's own files, or another ticket's private attachment) to whoever can download it. Paths are now confined to the import directory: absolute and drive paths, stream wrappers, backslashes, NUL bytes, ./.. segments and look-alike prefixes are rejected.
Fixed
Imported tickets failed to save: the importer wrote the adapter's title to a title column the tickets table does not have. It now writes subject (from subject, else title) and description.
Imported attachments failed to save because original_filename was never set. It now records the adapter's filename.
Live chat transfer did nothing.Agent/TicketShow never gave
ChatActionBar a transfer endpoint, so picking a department in the Transfer
menu posted nothing on any backend. It now passes
route('escalated.admin.chat.transfer', ticket.chat_session_id) (the name
escalated-laravel serves it under). If the host's route() helper does not
know that name (it throws, or a shim returns '#'), the bar gets no endpoint
and behaves as before.
ChatActionBar now posts the transfer body backends read,
{"agent_id": id} or {"department_id": id}, instead of {"type", "id"}
(which escalated-laravel rejected with 422). The transfer event still
carries {type, id}.
Live chat End Chat did nothing on the server.Agent/TicketShow never
gave ChatActionBar an end endpoint either, so confirming End Chat only
reloaded the page and the session stayed live. It now passes
route('escalated.admin.chat.end', ticket.chat_session_id), with the same
fallback (no endpoint, behaves as before) when the host does not know the
name. The bar posts an empty JSON object ({}), since the endpoint takes no
fields, and now emits end-chat after the request settles instead of before
it, so a parent that reloads on the event sees the ended session. Listeners
still receive the session id.
The agent detail report shows the agent.GET admin/reports/agent/{id}/detail (Escalated/Admin/Reports/AgentDetail) sent agent_id, metrics and percentiles, none of which the shared screen reads, so it rendered "Unknown Agent" and zeroes. It now sends what the screen declares: period_days, agent {id, name, email}, total_tickets, resolved_tickets, avg_frt, avg_resolution, csat_score (null without ratings), response_percentiles {p50..p99}, productivity (per day, {label, value, values: [replies, resolved]}), workload (tickets created per day), csat_breakdown (N stars counts), and prev_* for the same number of days before. Nothing else read the old keys. New ReportingService::agentDetail() computes them.
Admin/Users/Index accepts an optional notice string prop. When a backend
sends it (escalated-nestjs does when the host has not configured a user-list
hook), the page shows it as an informational message above the table, in
place of "No users found.". The text is server-provided and shown as given
(escaped, not translated). Pages rendered without notice are unchanged.
Run php artisan migrate. The new migration adds a nullable business_schedule_id (foreign key to business_schedules, ON DELETE SET NULL) to sla_policies and departments, honouring the table prefix and escalated.connection. Until an admin creates a business schedule, business-hours deadlines are counted exactly as before, against escalated.sla.business_hours.
Saving a webhook with the secret field left blank now keeps the stored secret, because the form no longer receives it. Send clear_secret: true to remove it.
The SSO settings form posts the mask (********) back for a secret the admin did not change, and it is ignored; any other value replaces the stored secret, as before. The general settings page's older abc***** mask is still recognised.
Added
Business schedules count SLA deadlines. For a business_hours_only policy, SlaService counts in the policy's business schedule, else the ticket department's, else the default schedule, and only falls back to escalated.sla.business_hours when none applies. This applies when a policy is attached and when a department change selects it again. BusinessHoursCalculator now honours the schedule's time zone and holidays (one-off and recurring), counts real elapsed time across daylight-saving changes, and supports overnight windows and 24:00; a schedule with no open day counts calendar time. See docs/sla-policies.md#business-hours.
SLA policy and department saves accept business_schedule_id (nullable, must exist; tenant-checked). It changes only when sent, so forms that do not send it leave it as it is. Deleting a business schedule clears it from policies and departments. The SLA policy and department form pages receive a schedules prop ([{id, name}], by name) for the shared frontend's Business hours schedule picker.
Security
Stored secrets are no longer sent to admin pages. The import pages received each import job with its decrypted source-platform credentials (ImportJob did not hide credentials), and the webhook index, edit and delivery-log pages received each webhook's signing secret. ImportJob now hides credentials and the import index and progress pages are built from an explicit field list (ImportJob::toPageProps()); the import run still reads the credentials server-side. Webhook hides secret and exposes has_secret instead. The SSO settings page now shows sso_jwt_secret and sso_oauth_client_secret as a mask instead of their values, and the general settings page masks the Mailgun signing key, Postmark inbound token and IMAP password completely (it showed their first three characters). TwoFactor (secret, recovery_codes) and ApiToken (token hash) hide their secrets too, though no page sent them.
Fixed
A business-hours deadline counted against escalated.sla.business_hours whose timezone differs from the app time zone was stored as that zone's wall-clock time, so it was off by the difference between the two zones. It is now stored at the right instant. Installs whose configured zone matches the app zone (the defaults are both UTC) are unaffected.
Business hours schedule select on Admin/SlaPolicies/Form and
Admin/Departments/Form. Shown only when the backend passes a schedules
prop ([{ id, name }]); options are "Use the default" (null) and each
schedule, posted as business_schedule_id. Business-hours SLA deadlines use
the policy's schedule, else the ticket department's, else the default one.
Pages rendered without schedules look and post exactly as before, so a
backend without schedules never receives the field.
Admin/Webhooks/Form supports backends that no longer send a webhook's
signing secret. When the webhook prop has has_secret, the secret field
starts blank, says "A secret is set. Leave blank to keep it." and offers
Remove secret, which posts clear_secret: true (with Keep secret to
undo). The form then always posts clear_secret. Backends that still send
secret see no change: the field is filled in, posted back as it is, and
clear_secret is not sent.
Admin/Settings/SsoSettings says when a JWT shared secret or OAuth client
secret is stored (the backend sends it as a mask such as ********). Leaving
the field as it is posts the mask back, which keeps the stored secret.
Run php artisan migrate. The new migration rewrites stored workflow trigger_event values from legacy names (ticket.replied, internal_note.added, tag.added, chat.session_started, ...) to their canonical names (ADR 2026-10-21-event-vocabulary). Workflows saved under a legacy name keep firing without it.
The endpoint in escalated.notifications.webhook_url receives every event, so it now also receives chat.started, chat.assigned, chat.transferred, chat.message and chat.ended. Admin webhooks receive them only once subscribed.
The workflow builder offers reply.created instead of ticket.replied.
Required department is opt-in (escalated.tickets.require_department, default false); existing installs are unaffected.
Added
Required department. New escalated.tickets.require_department (default false, env ESCALATED_REQUIRE_DEPARTMENT) and escalated.tickets.default_department_id (env ESCALATED_DEFAULT_DEPARTMENT_ID). When required, a ticket created without a department gets the default; with no default (or a default that no longer exists) creation fails with a department_id validation error: a 422 on the API, widget, mobile and guest endpoints, a redirect back with errors on web forms, and a failed inbound email. Enforced centrally when the Ticket model is created; imports are exempt. Existing installs are unaffected. See docs/ticket-access-and-routing.md.
Live chat webhooks. Outbound webhooks now receive chat.started, chat.assigned, chat.transferred, chat.message and chat.ended, and the webhook form offers them (workflows already ran on them). The payload is {"chat": {"session_id", "ticket_id", "status", "agent_id", "department_id"}}, plus from_agent_id/to_agent_id/to_department_id for a transfer, reply_id/author_type (agent or visitor) for a message and ended_by for an end. It never carries the visitor's session token (or its hash), the visitor's email or the message body. Upgrade note: admin webhooks receive them only once subscribed, but the endpoint in escalated.notifications.webhook_url receives every event, so it now gets these five as well. A subscription stored as chat.session_started / chat.session_ended receives chat.started / chat.ended.
More workflow triggers. Workflows can now run on note.created, ticket.unassigned, ticket.priority_changed, ticket.department_changed, ticket.tag_added, ticket.tag_removed, chat.assigned, chat.transferred and chat.message, and the builder offers them. A note or reply written by a workflow does not trigger note.created / reply.created workflows.
Changed
Event vocabulary (ADR 2026-10-21-event-vocabulary). The reply workflow trigger is now reply.created, the name webhooks already use. The builder offers reply.created instead of ticket.replied, and workflows fire under it. Upgrade note: run php artisan migrate; the new migration rewrites stored workflow trigger_event values from legacy names (ticket.replied, internal_note.added, internal_note.created, tag.added, ticket.tagged, tag.removed, ticket.untagged, chat.session_started, chat.session_ended) to their canonical names. Workflows saved under a legacy name keep firing without the migration, and a legacy name posted to the workflow form is stored canonically. WorkflowLog.trigger_event records the canonical name from now on.
Webhook subscriptions saved under any of those legacy names now receive the canonical event (previously only internal_note.added, tag.added and tag.removed were mapped). Webhook::LEGACY_EVENT_NAMES now equals the new Support\EventVocabulary::LEGACY.
Fixed
Test suite: tests/Feature/Api/SecurityTest.php no longer fails intermittently. Its tickets took a random ESC-nnnnn reference that could collide with another random pick or with a reference generated from a primary key; they now get the model-generated reference, and test emails use a counter instead of uniqid(). No runtime change.
Escalation rules and automations fire once per ticket. A new escalated_rule_executions table (prefix and escalated.connection aware, tenant-scoped) records when a rule or automation acted on a ticket. The sweep skips a ticket the rule has already acted on, and re-arms the rule once the ticket stops matching. An optional repeat_after_hours column on escalation_rules and automations (null by default, meaning once) lets a rule fire again after that many hours while the ticket still matches. Run php artisan migrate.
Changed
The bundled widget is now @escalated-dev/escalated 0.12.3. It keeps its script settings when embed.js is placed in the page <head>, and Enter no longer picks a stale mention suggestion.
Workflow, automation and escalation-rule actions that change status, priority, assignee or department now go through the same domain methods as an agent (changeStatus, changePriority, assign, changeDepartment). Transitions are checked against escalated.transitions, resolved_at/closed_at are stamped (so escalated:close-resolved closes workflow-resolved tickets), activity is logged and the status/priority/assignment/department events, notifications and webhooks fire. A change to the current value is a no-op. A transition the config does not allow, an unknown value, or a missing user or department is logged and skipped; previously the column was written regardless.
TicketUpdated (ticket.updated webhooks and workflows) is no longer dispatched for the SLA deadline write when a policy is attached at ticket creation.
Fixed
With escalated.sla.targeted (and escalated.sla.enabled) on, a ticket that changes department now gets the SLA policy the new department would have assigned at creation. A new ReselectSlaPolicy listener on DepartmentChanged calls SlaService::reselectPolicy(), which anchors the new deadlines on the time of the change, clears a deadline the new policy does not set, keeps a recorded first response's deadline and recorded breaches, and leaves resolved and closed tickets alone. Previously the ticket kept the policy it was created under. Hosts with targeted SLAs off are unaffected. SlaService::selectPolicy() exposes the creation-time selection.
One ticket that could not be escalated (already escalated, waiting_on_customer, an invalid priority value) aborted escalated:evaluate-escalations for every later rule and ticket. Each ticket and action is now isolated: failures are logged and the sweep continues; an already-escalated ticket is skipped.
Escalation rules and automations re-ran on every sweep, re-adding notes, re-assigning and re-dispatching events every few minutes (see Added).
A workflow on ticket.updated that wrote the ticket (e.g. snooze_ticket) re-triggered itself without end. WorkflowEngine::processEvent() now has a re-entrancy guard: the same event does not re-run workflows for the same ticket while they are running, and nesting stops at WorkflowEngine::MAX_DEPTH (3).
TicketEscalated was dispatched twice per rule escalation, and a manual escalation put the agent's serialized user record into the event's reason (broadcast payload and notification). It is now dispatched once; the reason is the rule name for rule escalations and null for manual ones.
Live chat: the visitor's opening message is now stored as the first chat reply, so it appears in the widget transcript and the agent's chat (it remains the ticket description; no extra ReplyCreated is dispatched).
GET widget/chat/availability returns message (the key the widget reads) alongside reason when chat is disabled.
Admin Settings passes supported_settings listing exactly the keys it saves, so the shared page no longer shows live-chat and widget-appearance controls that were silently discarded.
The widget kept none of its script settings when embed.js was placed in
the page <head>: it read document.currentScript only after
DOMContentLoaded, when it is already null, so requests went to the
wrong site. The script element is now captured while the script runs, with
script[src*="/widget/embed.js"] as a fallback (#207).
Pressing Enter or Tab while a mention search was still pending could insert
an agent from the previous, stale result list. Keys are now held until the
results for the current query arrive (#207).
Ticket list accent colours fall back to the light preset when the host sets
no theme.panel, and Guest/Create only offers the lookup form when both
lookup_url and verification_url are present (#207).
The widget hides its Check Status tab when /config returns
status_lookup_enabled: false, for backends that do not offer verified
guest lookup. Backends that omit the flag keep the tab.
Embed the widget, ticket form and live chat on other sites. List the embedding sites in escalated.widget.allowed_origins (ESCALATED_WIDGET_ALLOWED_ORIGINS) or the new Sites that can embed the widget admin setting (widget_allowed_origins); https://*.example.com subdomain wildcards are supported. Widget requests from a listed origin get CORS headers (including on 422 and 429 answers), skip the CSRF check and always run as a guest. Requests from any other origin are handled exactly as before. Empty by default, so nothing changes until a site is listed. See docs/embedding-the-widget.md.
GET {prefix}/widget/embed.js serves the standalone widget bundle built from @escalated-dev/escalated, so other sites need one script tag. escalated.widget.serve_script turns the route off.
Widget tickets and live chats keep host parameters sent as metadata (order number, plan, page URL, ...), sanitised to a flat map of short scalar values, plus a server-recorded embedded_on origin. /config advertises accepts_metadata: true.
Fixed
The admin settings page now saves the Support Widget settings it already showed (widget_enabled, colour, position, greeting, departments). The controller dropped them, so the widget could not be switched on from the UI.
Live chat metadata from the widget was stored unbounded; it is now sanitised like ticket metadata.
Embed the widget on other sites. A standalone bundle
(dist/widget/escalated-widget.js, exported as
@escalated-dev/escalated/widget) that backends serve at
{widgetPath}/embed.js. Loaded from there, it finds its backend without
data-base-url. Host parameters set from data-* attributes or
window.EscalatedWidget: inline mode for an on-page support form, a tab
subset and default tab, a preselected or locked department, visitor prefill,
title and greeting, open on load, a hidden launcher, and metadata sent with
tickets and chats when /config returns accepts_metadata: true. A
JavaScript API (open, close, toggle, identify, setMetadata,
setDepartment, mount, destroy) and an escalated:widget-ready event.
See docs/embedding-the-widget.md.
Escalated/Admin/Settings shows a Sites that can embed the widget field
(widget_allowed_origins) when the backend supports it, and the embed
snippet now uses an absolute script URL.
Fixed
window.EscalatedWidget.open() and close() now drive the widget's own
state instead of toggling a CSS class that the next render removed.
The widget starts on the first tab it can show, instead of an empty panel
when the knowledge base is off.
Mention suggestions ignore stale searches, IME composition and key events
another control already handled are respected, queued socket events after a
literal unsubscribe are dropped, chat typing indicators are throttled per
session, and report exports show errors instead of downloading a failed
response (#195).
Changing a ticket's department no longer throws a TypeError. Ticket::changeDepartment() dispatched DepartmentChanged with the Department model, but the event takes the department id. These all failed after the column had been written:
the agent and admin ticket screens
the API macro endpoint
bulk and macro actions
escalation rules' change_department action
The event's signature is unchanged.
TicketUnassigned now accepts a string (UUID/ULID) previous agent id, matching TicketAssigned. Unassigning a ticket on a host with string user keys threw a TypeError. Ticket::assign() now reports the assignee's model key instead of reading $user->id.
Upgrade
There are no new migrations or configuration requirements compared with v1.9.2.
Read every setting with one query per request instead of one query per key. The shared Inertia props alone read five settings on every page. Settings are memoized per connection and tenant, flushed on writes and at the start of each request, queued job and Octane operation.
Upgrade
This release has no new migrations or configuration requirements beyond v1.9.1. If upgrading from an older version, follow the v1.9.1 release notes and their linked upgrade instructions.
Preserve exact account identities, including trailing spaces, across MySQL visibility scopes, account filters and routing conditions.
Verify department restrictions through customer mobile API tokens used by staff.
Added
Opt-in department visibility for agents across ticket queries, policies, API, widget access, bulk actions, reports, notifications, and broadcast authorization. Administrators retain access within their tenant.
Nullable polymorphic ticket accounts with host resolution hooks, account filters, and independent account visibility for agents.
Account and ticket-type conditions for routing rules, plus opt-in SLA targets by department and type with deterministic fallback.
Integration documentation and regression coverage for default behavior, tenant boundaries, separate connections, and custom table prefixes. Run the additive migrations before enabling the new options.
Upgrade
Run php artisan migrate before enabling these options. Department visibility, account association/restriction and targeted SLAs remain opt-in. See the configuration and integration guide.
If upgrading from a version before 1.9.0, also follow the 1.9.0 upgrade notes, including publishing the shared frontend ^0.12.0 assets first.
This release contains breaking and behaviour changes. Read Upgrading below
before deploying.
Upgrading
Ship the shared frontend first. This release needs
@escalated-dev/escalated ^0.12.0 (the package's package.json now asks for
it). Its verified guest forms and account-scoped channels must be in the host's
built assets before this backend is deployed, or guest ticket creation and
tenant broadcasts stop working.
Run the migrations. New migrations add the attachment migration journal,
tenant namespaces on package tables, verified guest access and the Slack inbox:
php artisan migrate.
Laravel 11 hosts need 11.39 or later (illuminate/contracts^11.39|^12.0|^13.0). Laravel 12 and 13 are unchanged.
Attachments are private by default. The default disk changes from
public to local (ESCALATED_ATTACHMENTS_DISK), and serialized URLs are
expiring signed download links that check ticket access. Files uploaded before
this release stay on the public disk and stay publicly readable until moved:
run php artisan escalated:attachments:privatize --from=public --to=local
(dry run), then again with --apply, then remove any public/CDN copies. See
docs/private-attachments.md. Hosts that disable all package routes must
register the named escalated.attachments.download route themselves.
Guest permanent tokens no longer work. Guest ticket creation, live chat and
tracking lookup require a one-time code sent to the recipient's mailbox, and
access is through expiring grants. Existing ticket and chat links stop
authorizing requests; recipients renew access by verifying their email with
their ticket reference. Configure outbound mail, a shared cache with atomic
locks (for example Redis) and the same APP_KEY on every worker. See
docs/guest-access.md.
Inbound email replies are accepted only from the ticket's requester. A
matched email becomes a reply only when From is the requester's address, and
the reply is posted as the requester; the author is never taken from From.
Anything else, including an agent replying to a notification email, opens a
new ticket instead (it no longer replies to or reopens the matched ticket).
Agents should reply in the app. Once ESCALATED_EMAIL_INBOUND_SECRET is set,
only the signed Reply-To address on outbound notifications links mail to a
ticket; replies without it (for example a new email with the reference in the
subject) open a new ticket.
Tenant mode needs staff seats.Contracts\TenantResolver requires
isAgent(Model $user, string $tenantId) and
isAdmin(Model $user, string $tenantId). UnconfiguredTenantResolver returns
false for both, so with ESCALATED_TENANCY_ENABLED=true all agent and admin
access is denied until the host resolver implements seats. Host code that
checks staff access should call Escalated\Laravel\Support\StaffAccess::isAgent()
/ isAdmin() / isStaff() instead of the escalated-agent /
escalated-admin gates directly. Tenancy stays off by default; to adopt it,
follow docs/tenancy.md (backfill with writers paused, then enable).
Guest endpoints are rate-limited per client IP (60 requests, 5
submissions, 30 replies per minute, shared across browser, widget and mobile).
Configure trusted proxies, or every guest shares the proxy's budget, and point
cache.limiter at a shared store on multi-server hosts. Hosts that throttle
upstream can set ESCALATED_GUEST_RATE_LIMITS_ENABLED=false.
Only the requester can rate a ticket from the customer portal; other users
get 403.
Requester-facing payloads are allow-listed. The customer and guest ticket
pages and the mobile ticket resource no longer include ticket metadata,
external_reference, chat_metadata or agent emails/IDs. Host overrides of
these pages that read those fields must source them elsewhere.
Plugin HTTP routes need @escalated-dev/plugin-runtime v0.2.0 (HTTP
contract version 1). Older runtimes get 503 before a plugin handler runs. The
default ESCALATED_PLUGINS_RUNTIME_COMMAND now points at the runtime's
build/bin/escalated-plugins.js. Cookie, Authorization, CSRF and proxy headers
are no longer forwarded to plugins.
Added
Host account isolation (tenancy, opt-in). A host-supplied
TenantResolver scopes package records, policies, route bindings, API tokens,
host identity discovery, reports, scheduled and queued work, broadcasts and
attachments to the current account, including when host users live on another
database. escalated:tenant-backfill and escalated:tenant-provision commands.
See docs/tenancy.md. (#213)
Tenant-local staff seats through TenantResolver::isAgent/isAdmin and
Support\StaffAccess, used by the agent/admin middleware, ticket and admin
policies, API token abilities, shared Inertia props, agent directories,
workflow assignment, broadcast channels, private attachments and the Slack
service actor. (#219)
Verified, expiring guest access for guest tickets, chat and tracking
lookup, with email codes (ten-minute expiry, five attempts) and per-client and
per-mailbox delivery budgets (guest_access.challenges_per_client_per_hour,
guest_access.challenges_per_mailbox_per_hour). See docs/guest-access.md.
(#215, #220)
Agent API: create tickets for a requester (named recipient or host user)
with metadata, a host-assigned tracking reference and ordered
shipment/account/order subjects, plus GET/PUT subject endpoints. See
docs/agent-ticket-api.md. (#216)
Durable Slack inbound. Authenticated Slack messages are stored in an
encrypted inbox and processed by the scheduler into tickets or public thread
replies, with bounded retries and dead-letter recovery. See
docs/slack-inbound.md. (#218)
escalated.guest_rate_limits.enabled (ESCALATED_GUEST_RATE_LIMITS_ENABLED)
to switch the guest limiters off. (#226)
Security
Inbound email replies are accepted only from the ticket's requester, and only
through the signed Reply-To address when ESCALATED_EMAIL_INBOUND_SECRET is
set. A From naming a staff address no longer posts as that agent. (#220)
Customer portal ratings are limited to the ticket's requester. (#220)
The guest, customer and mobile ticket payloads are serialized from allow-lists,
so ticket metadata and agent emails no longer reach requesters. (#220, #224)
Guest ticket and widget entry points share per-IP request, submission and
reply quotas; blocked requests get 429 with Retry-After before any record is
created. (#214, #226)
TenantReferences rejects case-variant spellings of identity, reference and
morph type columns. (#219)
Fixed
Slack inbound text too large for a ticket description or reply body is no
longer dead-lettered as message_too_large. The body keeps the leading text
that fits, cut on a character boundary, with a truncation note, and the full
text is stored as a private slack-message.txt attachment on the ticket or
reply. Only a failure to store that attachment fails the receipt
(attachment_storage_failed, retried then dead-lettered); retries never
duplicate it. See docs/slack-inbound.md.
Slack inbound acknowledges authenticated events it does not route (unmapped
workspaces, channels and direct messages, whitespace-only text, and a second
app's copy of a recorded message) with 200 {"ignored": true} instead of
403/400/409. Text is no longer capped at 16,384 bytes. Slack entities are
decoded once before escaping, and a deleted linked ticket fails as
ticket_missing without retrying. See docs/slack-inbound.md.
The plugin HTTP bridge no longer forwards cookie, authorization, XSRF/CSRF or
proxy headers to plugins, matches SDK :param route segments and sends their
values, and encodes empty params/query as JSON objects.
Attachments now use private storage by default and expiring application download
URLs with ticket authorization, internal-note restrictions and customer token
limits. A dry-run-first command copies, verifies and removes existing public
files. See docs/private-attachments.md for required upgrade steps.
Agent performance, CSAT, workload, productivity and requester search now
resolve host identities on their own database connection. Reports and CSV
exports no longer join the host users table from Escalated's database.
Identity lookups honor morph aliases and the configured display column;
same-named agents retain separate workload series. CI runs the complete
suite with independent host and package databases to guard this boundary.
Existence and uniqueness validation now follows the owning model's connection
for ticket creation, assignment, bulk actions, skills, roles, knowledge-base
categories, custom fields and mobile identities.
Agent rankings and CSV exports no longer fail when an assigned agent has no
first response or resolution yet. (#212)
The customer and guest ticket pages load the satisfaction rating, so an
already-rated ticket no longer shows the rating form. (#220, #224)
Changed
CI now exercises Laravel 13 on PHP 8.3 and 8.5, plus pinned Laravel 13.8.0
compatibility. Pest 4 is allowed for the Laravel 13 test toolchain; Laravel
11/12 retain their Pest 3 compatibility legs. Documentation distinguishes the
Laravel 13 PHP minimum from the package's Laravel 11/12 minimum.
The minimum Laravel 11 version is 11.39. (#213)
The shared frontend dependency is @escalated-dev/escalated ^0.12.0, and the
page and route-name fixtures are refreshed to 0.12.0.
Verified guest access.Escalated/Guest/Create accepts optional
verification_url and lookup_url props. With them, the ticket and chat
forms request an eight-digit email code before posting, and a separate form
verifies a mailbox to find existing tickets by tracking or ticket reference
and renew private links. Guest/Show displays
ticket.guest_access_expires_at when supplied. The built-in widget enables
the same flow when /config returns guest_verification_required: true,
and widget status reads send the returned grant as a Bearer header (#186).
Account-scoped realtime channels. Backends with account isolation can
share escalated.broadcasting.channel_prefix through Inertia. Ticket, agent,
chat and presence subscriptions use that namespace, leave the old channels on
an account switch, and ignore stale socket callbacks and polling responses.
PresenceIndicator takes a channelPrefix prop; useRealtime and useChat
accept channelPrefix as a string, ref or getter (#184).
Backend settings capabilities.Escalated/Admin/Settings accepts optional
supported_settings and update_url props, so a backend shows and submits
only the settings it implements. Validation errors are rendered (#185).
Fixed
Guest forms clear drafts, email proofs and lookup results when the tenant
namespace (channel_prefix) or the lookup endpoint changes at the same URL,
and discard private responses that arrive after the change (#187).
The settings page resets its values and pending requests when Inertia reuses
it for another account, so credentials typed for one account cannot be
submitted to another account's settings endpoint (#185).
Every new prop and capability is optional. A backend that sends none of them
keeps the 0.11 behaviour: no email-code step, the plain escalated channels,
and the full settings form posting to the Laravel named route.
Ship this version before turning the backend features on. A backend that
requires guest email verification (escalated-laravel 1.9) or uses
account-scoped channels needs these forms and subscriptions in the browser
first, or guest ticket creation and realtime updates stop working.
Hosts pinning ^0.11.x must widen the range to ^0.12.0; npm caret ranges
on 0.x do not cross minor versions.
channel_prefix is a namespace, not authorization: the backend must still
authorize every private and presence channel join. Hiding a setting with
supported_settings is not authorization either; validate the allowlist on
the server.
Raw useRealtime().listen(channel, event, callback) still treats channel
literally and is not prefixed.
The database settings page was wider than a phone when a connection
had a long database value (a SQLite path, a long host): the summary grid
grew to fit it and the connection list never wrapped it. Both wrap now.
Its "Use this connection" button had no background: it used an
undefined --esc-accent token. It now uses the panel accent.
Ticket references, row focus, checkboxes and the active quick-filter tab
were fixed blue and indigo in light mode, so a host's panel theme never
reached them. They now use the panel accent and active tokens.
The workflow Logs page printed each run's actions as raw data. The shared
Logs page reads actions_executed as a count, as the NestJS reference sends
it; this package passed the stored array through. WorkflowController::logs
now sends the count, with the list still in action_details. (#206)
Changed
The shared frontend is now @escalated-dev/escalated ^0.11.9, whose admin
and agent panels work on narrow screens: below 1024px the admin sidebar folds
into a drawer behind a menu button, the agent nav folds into a menu, and
tables scroll inside their cards instead of widening the page. It also adds
optional panel theme hooks (activeText, headerBg, headerText,
logoTileBg, logoTileFg), set in the host's EscalatedPlugin options. No
backend change is needed; the page-name fixture is refreshed to 0.11.9.
The admin and agent panels were desktop-only. At a phone width every
panel screen was 460-1093px wide: the 256px admin sidebar never folded
away, the agent top nav ran off the screen with Back to App wrapped over
three lines, and the Tickets table overflowed even a 1024px window. Below
1024px the admin sidebar is now a drawer behind a menu button, and the
agent nav folds into a menu. Every panel table scrolls sideways inside its
card, with a shadow on the edge that has more, instead of widening the
page; report, ticket and audit-log toolbars wrap.
The agent Dashboard link was highlighted on every agent page, since
every agent URL starts with its href. It now matches only the dashboard.
Audit log entries were near-invisible on a light panel. They used fixed
dark-mode colours; they now take the panel tokens.
Added
Panel theme hooks for the pieces a host most often brands:
activeText (current nav item), headerBg and headerText (admin top bar
and agent top nav) and logoTileBg/logoTileFg (the square behind the
logo). Each is optional and falls back to the token it replaces. Panel
theming, including the existing tokens, is now documented in
docs/theming.md.
The Reports dashboard and the Workflows index failed to render. The shared
frontend builds links with Ziggy's route(), which throws on a name it does not
know or a missing required parameter, and the throw takes the whole screen down.
The Reports dashboard linked to escalated.admin.reports.response-times and
escalated.admin.reports.resolution-times; this package registered those
screens as reports.frt and reports.resolution. Both names now exist, at
/reports/response-times and /reports/resolution-times, and the old names and
URLs keep working.
The Workflows index linked to escalated.admin.workflows.logs with no workflow,
and the route required one. GET /workflows/logs now serves the shared Logs page
across every workflow, in the shape it reads: logs as a list of runs (newest
100) and workflows as {id, name} for its filter. It used to be sent a
paginator and a single workflow, so the page listed nothing useful even when it
was reached. ?workflow=<id> narrows it to one workflow, and the per-workflow
/workflows/{workflow}/logs URL of earlier releases redirects there. That route
is now named escalated.admin.workflows.workflow-logs.
The advanced report charts drew nothing and the SLA screen threw. Every chart
on the report screens reads a list of {label, value}; these screens were sent
ReportingService rows as they are (bucket/count, date/avg_hours,
period/total_breaches, group/avg), so each bar was NaN high and the browser
logged an SVG error per bar. The SLA screen's at_risk_tickets was a keyed count
forecast where the screen filters a list of tickets, which threw and took the screen
down once it rendered. The agent tables read agent_id/agent_name, the ranking
reads volume, avg_frt, avg_resolution and csat, and the cohort tabs read
name, volume, avg_resolution, breach_rate and csat; none of those were
sent, and the priority tab was a volume time series. Support\ReportScreens now maps
each screen's data to what it reads, as the Adonis and Django ports already do, and
the SLA screen lists the open tickets whose next deadline falls in the next 8 hours.
ReportingService keeps its shapes for the export and API, and gains
avg_resolution_hours on agentPerformanceRanking() and
ticketCohortsByPriority(). tests/Feature/Admin/ReportScreenShapesTest.php checks
the shapes with data present.
The report period selector did nothing on the advanced reports. The frontend
sends the period as days; response times, resolution times, SLA trends, agent
ranking, agent detail, cohorts, comparison and export read only period, so choosing 7 days reloaded
the 30-day report. They now read days too, and period still wins.
Added
tests/Feature/RouteNameParityTest.php, asserting every route name the
frontend passes to route() is registered, against the list vendored at
tests/Fixtures/escalated-route-names.json. It is the route counterpart of
PageNameParityTest: a controller test that asserts a 200 cannot see a link
that throws in the browser.
Custom object records could not be added or edited.Admin/CustomObjects/Records
kept each record's field values in a form field named data, which is one of
useForm's own methods. Inertia logged a conflict error on every visit to the
screen, and filling in a record assigned over the method the form submits with.
The values now live under values and are sent as { data }, the body every
backend validates, so nothing changes on the wire.
Synced mode delivers events through the queue.SyncedDriver dispatches
SyncEventToCloud (5 tries, exponential backoff, ESCALATED_SYNC_QUEUE to pick a
queue) instead of calling the cloud inline with a 15-second timeout, and every event
carries a stable event_id so cloud.escalated.dev ignores redeliveries. A cloud outage
no longer slows or blocks ticket writes.
Cloud to site webhook receiver.POST /escalated/cloud/webhook verifies
X-Escalated-Signature with ESCALATED_CLOUD_SIGNING_SECRET and applies
ticket.updated / ticket.status_changed from the cloud projection to the local
ticket through LocalDriver, so agent actions taken in the cloud portal reach the
site. Synced mode is now two-way. The route answers 503 until the secret is set.
Changed
The cloud vocabulary translation lives in Escalated\Laravel\Support\CloudVocabulary,
shared by CloudDriver and the receiver.
Cloud mode failed on every call.CloudDriver handed the HTTP response object to
hydrateTicket(array), so ESCALATED_MODE=cloud threw a TypeError before any ticket was
created, and HostedApiClient joined the base URL and endpoint without a slash
(/api/v1tickets/7). The driver now decodes the JSON body, raises a RequestException on
non-2xx responses, translates the cloud vocabulary both ways (medium/critical ↔
normal/urgent, waiting_on_* ↔ waiting, ticket_number → reference), reads
pagination from meta, and carries the host requester through metadata.host_requester_*
so requester_name resolves to the local user. Cloud mode now has integration tests.
pages.json now publishes the props each page reads, alongside the names
it already published, and which of those the component declares required.
The names let a backend catch a page that renders into nothing. The props are
the same fault one level down, and the one nothing could see: a backend that
sends data to a component reading period_days renders the chrome and no
content, on a 200 — and an empty report reads as a quiet week rather than a
wiring fault.
Read out of the compiler's own binding metadata rather than matched out of
the source, so a defineProps spread over lines, given types, or written as
an array still reports what Vue will actually accept.
Webhook URLs could point at internal addresses. An admin could save a
webhook for loopback, a private network or the cloud metadata address, and
the delivery log showed whatever answered. URLs must now be http(s) and
resolve only to public addresses. The check runs when a webhook is saved and
again before every delivery. Delivery connects to the address that was
checked and does not follow redirects. The workflow send_webhook action
uses the same guard (#183).
A customer could subscribe to another customer's ticket channel. Channel
authorization compared user ids with (int) casts, which turns every ULID
into 1 and every UUID into 0, and ignored the requester's type. Ids are
now compared as strings, and the requester's morph type must match (#185).
Fixed
Webhooks created on the admin Webhooks page never received an event.DispatchWebhook sent events only to notifications.webhook_url, and the
dispatcher that fans out to the saved webhooks ran only when a delivery was
retried by hand. Every event now goes to the active webhooks subscribed to it
as well as to webhook_url. The form's internal_note.added, tag.added and
tag.removed never matched the names events are sent under. They are now
note.created, ticket.tag_added and ticket.tag_removed, and webhooks
saved with the old names still receive them (#183).
A custom table_prefix broke a fresh install. The newsletter migrations,
models and validation rules named escalated_* tables directly, so any other
prefix failed at the newsletter migrations. They now use the configured
prefix (#184).
scheduling.auto_register did nothing. The documented option was never
read, so SLA checks, delayed workflow actions, snooze wake-ups and the rest
never ran unless the host scheduled each command by hand. With it on, the
package adds its recurring commands to the scheduler, and the optional ones
only when their feature is enabled (#186).
Plugin ctx.store.query failed outside MySQL. Filters and orderBy used
JSON_UNQUOTE(JSON_EXTRACT(...)), which SQLite and PostgreSQL don't have, and
MySQL sorted numbers as text. Queries are now written for each driver: SQLite,
MySQL, MariaDB and PostgreSQL. Numbers compare as numbers, and field names are
validated before they reach SQL (#187).
The widget ignored the knowledge base settings. It reported the knowledge
base as enabled and served articles when the admin had turned it off or made
it private. It now follows both settings, like the customer knowledge base
(#188).
The package read config keys the published config didn't define.
notifications.webhook_secret was never set, so requests to webhook_url
went unsigned. It is now ESCALATED_WEBHOOK_SECRET.
The ticket policy read escalated.allow_customer_close instead of
escalated.tickets.allow_customer_close, so customers could never close a
ticket.
app_name, attachments.disk, authorization.agent_scope, the plugin
runtime options, marketplace.* and version are now defined, and a
null value falls back to the default instead of being used as is (#189).
Four report screens were blank.ReportController rendered
Escalated/Admin/Reports/FirstResponseTime, ResolutionTime,
CohortAnalysis and PeriodComparison; the frontend package ships those
components as ResponseTimes, ResolutionTimes, Cohorts and Comparison.
Inertia resolves a page name with nothing behind it to nothing rather than to
an error, so all four returned 200 and rendered an empty panel.
The existing tests asserted only the status, which is why this survived. They
now assert the component name as well.
The shared workflow builder could not save a workflow, and a saved one ran
on every ticket. The builder posts the body fixed in
escalated-developer-context domain-model/workflow-admin-contract.md, and
WorkflowController refused it: set_department, add_note and
insert_canned_reply failed validation, omitting conditions was an error,
and reorder required ids. Worse, WorkflowEngine read only the legacy
{match, rules} shape, so a workflow stored as {"all": [...]} found no
rules and matched every ticket it saw.
Conditions are now read as {all}, {any}, a flat list or {match, rules},
and any other non-empty object matches nothing. The engine gains the
starts_with, ends_with, greater_or_equal and less_or_equal operators,
the department_id field, and the set_department, add_note and
insert_canned_reply actions; delay also takes a plain number of minutes
and send_webhook a plain URL. The Form page sends workflow,
trigger_events, action_types and operators, and reorder accepts
workflow_ids. A reply posted by a workflow no longer triggers workflows
itself, so a canned reply on ticket.replied cannot answer itself forever.
Stored {match, rules} workflows and the old action and operator names keep
working.
Added
tests/Unit/PageNameParityTest.php, asserting every page name this
package renders resolves to a component. It diffs them against the manifest
the frontend publishes, vendored at tests/Fixtures/escalated-pages.json, and
names the file each failing name came from. No single repo's tests can see a
blank screen like the four above on their own: a controller test asserts a
status, and the frontend never hears the name.
The workflow builder sent a shape no backend reads. It posted
trigger: 'ticket_created', conditions as {match, conditions} and actions as
{type, config}. Every backend engine evaluates trigger_event with dotted
names (ticket.created), conditions as {all: [...]} or {any: [...]}, and
actions as {type, value}. So a workflow built here failed validation on
Laravel, and wherever it did save it could never match an event.
The builder now sends the body fixed in escalated-developer-context
domain-model/workflow-admin-contract.md. It still opens workflows stored in
the older shapes, including Laravel's {match, rules}, and saves them back in
the new one.
The builder offers what the backend can actually do.Form hands the
trigger_events, action_types and operators a backend sends to the
builder, in any of the three shapes the contract allows, instead of swallowing
them. Without them it offers the contract's defaults: five triggers, the core
action catalog and twelve operators. Operators no backend evaluates (in,
matches) and fields no backend resolves (type, assigned_agent,
reply_count, custom_field) are gone.
The workflows list showed every workflow as disabled. It read active,
and backends send is_active. Toggling now POSTs to the toggle route, which
is a POST everywhere, where it used to PUT. Reordering sends workflow_ids.
Error.vue, the refusal and not-found screen. escalated-rails renders
Escalated/Error for every 403 and 404 inside the panel and no component had
ever existed behind it, so a refusal arrived as a blank page — and a refusal
that says nothing is indistinguishable from a bug.
It takes the status and the backend's already-translated message, and
falls back to its own wording when a status arrives bare. Like the two-factor
challenge it does not use EscalatedLayout: the usual reason to be here is
having been refused, and the layout's navigation is a list of places the
viewer may equally not be allowed to go. The way back is rendered only when
the backend says where that is.
pages.json is now 91 pages. Backends check their rendered page names against
it; see CONVENTIONS.md in escalated-developer-context.
Auth/TwoFactorChallenge.vue, the post-login two-factor challenge.
escalated-adonis has been rendering Escalated/Auth/TwoFactorChallenge with
no component behind it, so the challenge screen was blank — and a blank
challenge is not a locked door, it is a dead end for anyone with 2FA enabled.
It deliberately does not use EscalatedLayout: the sign-in is not finished at
that point, and nothing on the page should suggest access that has not been
granted. A TOTP code and a recovery code post to the same endpoint, and only
the field in use is sent, so a blank companion field cannot read as an attempt
with an empty code.
pages.json is now 90 pages. Backends check their rendered page names against
it; see CONVENTIONS.md in escalated-developer-context.
files listed only src, so the manifest every backend is meant to check its own page names against was exported, documented as the contract, and absent from the tarball.
exports and files are independent lists and nothing compares them: an export can name a path the package does not contain and npm publish says nothing. tests/packaging.test.js now reads the real file list from npm pack --dry-run --json and fails if any path named in exports is missing from it.
If you are consuming @escalated-dev/escalated/pages.json, this is the first version where it exists.
Makes the blank-screen class of bug visible, and publishes what the backends need to check themselves against.
resolveEscalatedPage()
Inertia resolves a page name to a component, and a name with nothing behind it is not an error — resolve() returns undefined, Vue renders nothing, and the panel comes up blank on a 200 response. It reads as a permissions problem or an empty dataset. Four screens shipped that way.
Throws in development, naming what was asked for and the nearest thing that exists; logs and renders the same message in production. Suggestions rank on path-segment overlap rather than leaf equality — nearly every page ends in Index or Form.
pages.json
Every page name this package can resolve, generated from src/pages and exported at @escalated-dev/escalated/pages.json. It is what each backend checks its own names against in CI — the only place both halves of that comparison are known.
npm run pages:check fails if it has drifted from the components, and lint runs it.
@escalated-dev/locale was depended on as a git reference, and npm refuses git-type fetches while publishing:
npm error code EALLOWGIT
npm error Fetching packages of type "git" have been disabled
npm publish failed for v0.10.0 and v0.11.0, leaving 0.9.0 as the registry's latest while two releases sat tagged and unpublished. The publish job failed after the tests passed and the release was created, so the red run sat some distance from the tag that caused it.
The same package is on npm as @escalated-dev/locale@0.1.8 — the git reference bought nothing. It now resolves from the registry.
A git dependency is also invisible to Dependabot, which is why nothing ever flagged it.
Everything in v0.11.0 reaches the registry with this release: the Automations, Public Tickets, Plugin and Workflows/Form screens, the page-parity audit script, and the dependency sweep.
An Inertia page name with no component behind it is not an error. The request succeeds, the tests pass, and the panel comes up empty. scripts/audit-page-parity.py — new in this release — compares every page name the backends render against the components this package ships, which is the only way to see it.
Automations (Admin/Automations/Index.vue, Form.vue). Rendered by six backends, with no component and no navigation entry — there was not even a way in. Automations are the time-based half of the admin automation surface; Workflows are the event-driven half. The form offers only the fields, operators and actions the backend runner evaluates, because anything else would save cleanly and then silently never match a ticket.
Public tickets settings (Admin/Settings/PublicTickets.vue). Rendered by five backends. Each guest policy mode asks for exactly the value the backend requires alongside it.
Plugin pages (Plugin/Page.vue). Rendered by two. A plugin declares a route on the backend and a component on the frontend, and this is where they meet — with nothing behind the name, every plugin page was blank, and blank the same way whether the plugin was installed on the frontend or not. It now resolves the registered component and names what is missing when there is none.
Admin/Workflows/Form.vue, the name the backends render for the workflow editor.
Dependencies
vitest 5.0.0 (the suite passes unchanged and runs in about half the time), @storybook/* 10.6.0, happy-dom 20.14.0 — applied together so the lockfile is regenerated once. A batch of separate Dependabot merges leaves a lock that only npm ci rejects.
Also
.gitattributes normalising line endings, so format:check stops failing on a Windows checkout for files that are perfectly formatted.
A minor release. Escalated::findUser() is new public API; nothing is breaking, so a host on 1.7.0 upgrades without touching anything.
The suite had only ever run on SQLite — the one driver no host deploys on, and the one that enforces the least. Running it on PostgreSQL went 385 failures → 135 → 12 → 0, and three of those were production bugs rather than test bugs.
Fixed
Every SLA report was a 500 on PostgreSQL. Eight raw-SQL expressions in ReportingService compared the boolean columns sla_first_response_breached and sla_resolution_breached against the integer 1. PostgreSQL rejects that outright — operator does not exist: boolean = integer. MySQL and SQLite store the columns as integers, so neither noticed. A boolean column is already a predicate and is now used as one.
An unknown user id escaped as a QueryException instead of the documented InvalidArgumentException. Ticket::assign(), mention resolution, chat assignment and the assignment notification all passed whatever id they were given straight to find(). An id that cannot be the host user's key — a UUID against an integer key, or anything arriving from a request — made PostgreSQL and MySQL raise a driver error where SQLite quietly returned nothing. A host that handled "not found" got a 500 with SQL in it instead.
New Escalated::findUser() treats an impossible id as not found, and every one of those lookups goes through it.
Three migrations could not be rolled back:
a complete index name passed inside the array form of dropIndex(), which asks Laravel to build a name from it — producing escalated_tickets_escalated_tickets_ticket_type_index, which no database has
NOT NULL restored on requester_type / requester_id while guest tickets — rows that by definition have neither — were still present
a column dropped while its unique index still named it, which SQLite rejects
Changed
The CI matrix gains a mysql and a pgsql leg. ESCALATED_TEST_DRIVER selects the driver and throws on an unrecognised value rather than falling back: a leg that quietly ran SQLite would report green having tested nothing the matrix exists for, and every assertion in the suite would still pass.
The tests/Connection suite stays on two in-memory SQLite databases whatever the driver — what it exercises is Eloquent's connection resolution, which has no dialect content.
738 tests green on SQLite, PostgreSQL 17 and MySQL 8.4.
v1.6.0 made the database Escalated's tables live on configurable. This release is where an admin can see and change it, rather than it being deploy-only.
Reachable at /admin/settings/database. The Vue page ships in @escalated-dev/escalated v0.10.0.
Two things make it safe enough for a web panel
The choice is not stored in the database it selects. It cannot be — reading it back would mean already knowing it, and an admin who pointed Escalated at an empty database would lose the only record of how to point it back. It goes to storage/app/escalated/connection.php; deleting that file returns Escalated to whatever config says.
A switch is refused unless the target is reachable and already migrated. This is the failure worth designing around, because it does not look like a failure: Escalated on an unmigrated database does not error, it renders a panel with no tickets, no departments and no settings — which reads exactly like data loss. Every candidate is probed first, so the screen refuses rather than performs it.
Config outranking the stored choice keeps infrastructure-as-code authoritative, and is why the screen reports itself read-only when config pins the value rather than accepting a change the backend would ignore.
Also
Escalated::useConnection() — force a connection for the length of a queued job, a console command or a test, and restore the previous value.
ConnectionInspector reports driver, database label (credentials stripped) and ticket count, so two similar-looking connections can be told apart.
Every probe is wrapped: a connection whose database is down renders as an unusable option, not a 500 that takes the settings page with it.
The admin surface for the configurable database connection shipped in escalated-laravel v1.6.0. Escalated's tables can live on a database other than the host's default; this screen shows and changes which one.
Reachable at /admin/settings/database.
Built around the two ways it goes wrong
Pointing Escalated at a database with no Escalated tables does not error. The panel comes up with no tickets, no departments and no settings — which reads exactly like data loss. An unmigrated or unreachable connection renders disabled with the reason beside it, and the server refuses the switch independently.
A connection pinned in config or .env is deployed infrastructure. The form is not rendered at all in that case, and the screen says where the value actually lives rather than accepting a change the backend would ignore.
Also
On an actual change, the screen states plainly that no data moves, names how many tickets the target holds, and says how many stay behind on the current database and stop being visible.
Each connection can be probed without committing to it.
Status is Ready / Not migrated / Unreachable, with driver, database label and ticket count — enough to tell two similar-looking connections apart.
Every model and migration resolved the host application's default connection, with no way to change it — which made the package unusable in any host that partitions its database: a schema shared with a legacy system, a multi-tenant split, a separate reporting store, or simply a host that would rather keep support tables out of its primary database.
null keeps the historical behaviour, so an unconfigured host is unchanged — the existing suite passes untouched.
This moves Escalated's models, migrations, query-builder reads and transactions together. It deliberately does not move your users table: that belongs to the host, and Escalated follows your user model to wherever it already lives.
Two things the setting alone does not solve
Both were found by the new tests rather than reasoned about up front, and both are the substance of this release.
Eloquent pushes the parent's connection onto related models. Once Escalated moved, a ticket's requester and assignee were being looked up in Escalated's database — no such table: users, because the host's users table was never over there. Propagation is now kept for Escalated's own models (so $ticket->setConnection('archive') still carries across our graph) and stops at the host's.
Four relations join an Escalated pivot to the host's users table — department agents, role members, skill agents, ticket followers — and no database can join across two connections. Those resolve in two steps when the connections differ: read the pivot, then load the users by key. ->agents, ->followers, eager loading, withCount(), attach()/sync()/detach() and pivot columns all behave the same. withCount() counts the pivot alone, which is also cheaper on a single connection. A pivot row whose host user was deleted is skipped rather than hydrated as a hole, since no cross-connection foreign key can cascade it away. On a single connection every override falls through to Eloquent and the ordinary JOIN is still issued.
Upgrading
Nothing to do unless you want it. Setting connection on an existing install does not move existing data — migrate the tables yourself, or run the package migrations against the new connection and copy the rows across, before pointing Escalated at it.
usePermissions() composable + permission-gated admin nav — the newsletter entry renders only for users with newsletters.manage, matching backend enforcement. (#96)
Central translations consumed from @escalated-dev/locale; local src/locales/*.json win as overrides.
Changed
Newsletter admin pages wired to locale strings (i18n). (#96)
Patch release on top of v1.5.0 (the newsletter system release).
Fixed
CI/build: ignore the laravel/framework 11.x security advisories that Composer 2.9+ excludes during dependency resolution, which was breaking the Laravel 11 compatibility test matrix. Scoped to this package's root config.audit.ignore — does not propagate to host apps. No runtime/code changes.
See v1.5.0 for the newsletter system, add_follower, ticket subjects, and permission enforcement.
Patch release fixing upgrade-safety regressions found in a breaking-change audit of v1.4.0.
Fixed
Skill management on upgraded installs. v1.4.0 added the routing_tag_ids/routing_department_ids skill columns by editing the original create_escalated_skills_table migration, which never re-runs on an existing install — so apps upgraded from an earlier version were missing the columns and every skill create/edit failed (Skill::saving() always writes them). Adds a Schema::hasColumn-guarded backfill migration (no-op on fresh installs).
AssignTicketRequest (Admin/Agent assign endpoints) again validates that agent_id exists, so unknown/garbage input returns a clean 422 instead of a 500. UUID/string keys are still accepted.
AssignmentService::__construct()$skillRoutingService is now optional (container fallback), restoring the v1.3.0 new AssignmentService($manager) single-argument signature.
Upgrade notes (from < 1.4.0)
The int → int|string widening for UUID/string user-key support touches the public TicketDriver::assignTicket() contract and Ticket methods (assign, follow, unfollow, isFollowedBy, scopeAssignedTo). If you implement TicketDriver or subclass Ticket with int-typed parameters, widen them to int|string to avoid a PHP "must be compatible" fatal. Callers are unaffected.
TicketAssigned::$agentId is now int|string (a string for UUID/string-keyed apps). Integer-keyed apps are unchanged.
UUID / string host user keys — Escalated for Laravel now works with host apps whose user primary key is a UUID, ULID, or string, not just an integer. SavedView::scopeForUser() and every other user-id parameter accept int|string (fixing a TypeError 500 on /support/admin/tickets for non-integer-keyed apps), user ids are never cast to int (which corrupted UUIDs), and the package's user-referencing migration columns are auto-typed to match your user_model via the new escalated.user_key_type config (auto by default). Integer-keyed apps are unaffected.
Custom Ticket Actions — register host-defined agent ticket buttons that dispatch a TicketCustomActionTriggered event (with an audit note) when clicked.
Added
Custom Ticket Actions via events (#107, #108)
Auto-detect host user key type for migration columns + escalated.user_key_type (#112)
v0.8.0 - Admin users page + staff-panel banner on /support
Added
Admin → Users page (#62): a paged, searchable list of host users at /support/admin/users with toggles to grant or revoke is_admin / is_agent. Lands a Users entry in the admin nav between Roles and Capacity. Self-demote on the Admin toggle is disabled in the UI and rejected server-side. Pairs with escalated-laravel#94. Surfaced from #60, where the reporter asked "how do you create agent?"
Staff-panel banner on /support (#61): the customer index scopes its ticket list to the current user, so admins / agents who landed on /support saw only their own tickets and concluded that customer-submitted tickets were invisible to staff. Customer tickets are visible in /support/admin/tickets and /support/agent/tickets — the new banner just makes that path obvious from the customer view.
Backwards compatibility
Both additions are forward-compatible. The Users page assumes hosts are using the is_admin / is_agent columns the install command suggests; hosts wiring the gates differently (Spatie roles, etc.) can override the controller in the companion Laravel package.
Admin → Users page (#94): new Admin/UserController exposes the host User table (paged, searchable) plus a PATCH /admin/users/{user}/role endpoint that flips one role at a time. Companion Vue page lands in escalated@v0.8.0. Surfaced from escalated#60, where the reporter asked "how do you create agent?" — admins no longer have to drop into tinker to grant or revoke staff access.
Demoting yourself from admin is rejected server-side so an admin cannot lock themselves out of the panel they're using. Demoting an admin via the agent toggle revokes both flags in one step.
Notes
The default install pins this to the is_admin / is_agent columns the install command tells hosts to add. Hosts wiring the gates differently (Spatie roles, custom pivots) should override Admin/UserController in their own routes — there's a comment to that effect in the class.
Upgrade
composer update escalated-dev/escalated-laravel
No new migrations. The Users nav entry shows up automatically once you upgrade @escalated-dev/escalated to 0.8.0.
peerDependencies.\"@inertiajs/vue3\" widened from ^1.0.0 || ^2.0.0 to ^1.0.0 || ^2.0.0 || ^3.0.0. Host apps on @inertiajs/vue3@3.x no longer trip an npm install peer-dep conflict (#39). We import only Link, router, useForm, and usePage, all stable across the v1 → v2 → v3 line.
Fixed
Widget's API endpoint path is now configurable via data-widget-path (on the script tag) / widgetPath option (on createEscalated). Default stays /support/widget for backward compatibility. Unblocks NestJS hosts where the base path isn't /support (#35).
useChat() threads the resolved widgetPath through all six chat API endpoints; Agent TicketShow, ActiveChatsPanel, ChatQueue read page.props.escalated?.prefix to build the right path on the agent side.