Skip to main content

Changelog

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 key mine (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.
View on GitHub
v0.12.8Escalated

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.
View on GitHub
v1.9.14Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v1.9.13Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v1.9.12Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v0.12.7Escalated

Added

  • 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.
View on GitHub
v1.9.11Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v1.9.10Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v0.12.6Escalated

Fixed

  • 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.
View on GitHub
v1.9.9Escalated for Laravel

Fixed

  • 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.
View on GitHub
v0.12.5Escalated

Added

  • 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.
View on GitHub
v1.9.8Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v0.12.4Escalated

Added

  • 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.
View on GitHub
v1.9.7Escalated for Laravel

Upgrading

  • 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.
View on GitHub
v1.9.6Escalated for Laravel

Added

  • 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.
View on GitHub
v0.12.3Escalated

Fixed

  • 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).
View on GitHub
v1.9.5Escalated for Laravel

Changed

  • The bundled widget is now @escalated-dev/escalated 0.12.2. It hides its Check Status tab when /config returns status_lookup_enabled: false.
View on GitHub
v0.12.2Escalated

Added

  • 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.
View on GitHub
v1.9.4Escalated for Laravel

Added

  • 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.
View on GitHub
v0.12.1Escalated

Added

  • 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).
View on GitHub
v1.9.3Escalated for Laravel

Fixed

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

Full changes: https://github.com/escalated-dev/escalated-laravel/compare/v1.9.2...v1.9.3

View on GitHub
v1.9.2Escalated for Laravel

Performance

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

Full changes: https://github.com/escalated-dev/escalated-laravel/compare/v1.9.1...v1.9.2

View on GitHub
v1.9.1Escalated for Laravel

Fixed

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

View on GitHub
v1.9.0Escalated for Laravel

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.
View on GitHub
v0.12.0Escalated

Added

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

Security

  • Patched vulnerable transitive dependencies (brace-expansion, Joi, postcss-selector-parser, source-map-js, shell-quote) (#188).

Upgrading

  • 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.
View on GitHub
v0.11.10Escalated

Fixed

  • 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.
View on GitHub
v1.8.6Escalated for Laravel

Fixed

  • 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.
View on GitHub
v0.11.9Escalated

Fixed

  • 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.
View on GitHub
v1.8.5Escalated for Laravel

Fixed

  • 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.
View on GitHub
v0.11.8Escalated

Fixed

  • 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.
View on GitHub
v1.8.4Escalated for Laravel

Added

  • 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.
View on GitHub
v1.8.3Escalated for Laravel

Fixed

  • 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.
View on GitHub
v0.11.7Escalated

Added

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

    "props": {
        "Escalated/Admin/Reports/AgentRanking": { "props": ["agents", "period_days"], "required": [] },
        "Escalated/Admin/Import/Progress": { "props": ["job"], "required": ["job"] }
    }
    

    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.

View on GitHub
v1.8.2Escalated for Laravel

Security

  • 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).
View on GitHub
v1.8.1Escalated for Laravel

Fixed

  • 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.
View on GitHub
v0.11.6Escalated

Fixed

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

View on GitHub
v0.11.5Escalated

Added

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

View on GitHub
v0.11.4Escalated

Added

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

View on GitHub
v0.11.3Escalated

pages.json shipped in 0.11.2 — except it did not.

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.

View on GitHub
v0.11.2Escalated

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.

createInertiaApp({
    resolve: (name) =>
        resolveEscalatedPage(name, () =>
            resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**')),
        ),
});

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.

637 tests.

View on GitHub
v0.11.1Escalated

The package could not be published

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

View on GitHub
v0.11.0Escalated

Four screens that rendered into nothing

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.

621 tests, up from 590.

View on GitHub
v1.8.0Escalated for Laravel

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.

View on GitHub
v1.7.0Escalated for Laravel

v1.7.0 — Database connection admin screen

Database connection admin screen

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.

Precedence

Escalated::useConnection()   →   config('escalated.connection')   →   admin's stored choice

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.

735 tests / 3,053 assertions. Pint clean.

View on GitHub
v0.10.0Escalated

v0.10.0 — Database connection settings

Database connection settings screen

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.

590 tests / 49 files. ESLint and Prettier clean.

View on GitHub
v1.6.0Escalated for Laravel

v1.6.0 — Configurable database connection

Configurable database connection

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.

// config/escalated.php
'connection' => env('ESCALATED_DB_CONNECTION'),   // null = your app's default

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.

723 tests / 3,018 assertions. Pint clean.

View on GitHub
v0.9.0Escalated

v0.9.0 — Newsletter system admin UI

Newsletter system admin UI (Wave 0)

The shared Vue/Inertia frontend now ships the full newsletter admin surface, consumed identically by every Escalated backend.

Added

  • Newsletter admin UI — compose / index / show pages, deliveries table, analytics tiles, dynamic segment-filter builder, list-member table, markdown editor, merge-field dropdown, preview iframe (+ Storybook stories). (#75)
  • 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)

Full Changelog: https://github.com/escalated-dev/escalated/compare/v0.8.0...v0.9.0

View on GitHub
v1.5.1Escalated for Laravel

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.

View on GitHub
v1.5.0Escalated for Laravel

v1.5.0 — Newsletter system

Added

  • Newsletter system — admin-only broadcast feature: campaigns, recipient lists (static + dynamic segments), templates, per-recipient deliveries with open/click tracking, one-click unsubscribe, view-in-browser, and ESP bounce/complaint webhooks (Postmark/Mailgun/SES/SendGrid). Driven by the escalated:newsletters:dispatch scheduled command (per-minute rate limiting, retry backoff, auto-pause on high bounce rates). Disabled by default behind escalated.enable_newsletters. (#103, #128)
  • add_follower workflow action — auto-subscribe a host user as a ticket follower from a workflow rule. (#127)
  • Ticket subjects — attach host-app entities a ticket is about (Project, Customer, asset…), distinct from the requester. (#89)
  • escalated.permissions is now shared with the Inertia frontend for per-permission nav gating. (#129)

Security

  • Newsletter admin routes now enforce newsletters.manage (all actions) and newsletters.send (send/test). Previously seeded but unchecked. (#129)

Full changelog: https://github.com/escalated-dev/escalated-laravel/blob/main/CHANGELOG.md

View on GitHub
v1.4.1Escalated for Laravel

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.

Full changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.4.0...v1.4.1

View on GitHub
v1.4.0Escalated for Laravel

Highlights

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)
  • Skills-based ticket routing (#95)
  • Mobile customer & guest support API (#104)
  • Expanded SSO provider configuration (#96)
  • Shared escalated-dev/locale translation consumption

Fixed

  • UUID/string host user keys throughout (#110)
  • Restrict skill assignment to role-bearing users; transactional skill store/update (#100)
  • Show 2FA recovery codes after confirmation (#97)

Security

  • Bump transitive qs 6.15.1 → 6.15.2 in the demo host-app (CVE-2026-8723) (#113)

Full changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.3.0...v1.4.0

View on GitHub
v0.8.0Escalated

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.

Full Changelog: https://github.com/escalated-dev/escalated/compare/v0.7.1...v0.8.0

View on GitHub
v1.3.0Escalated for Laravel

v1.3.0 - Admin users-management page

Added

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

Full Changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.2.5...v1.3.0

View on GitHub
v0.7.1Escalated

v0.7.1 — Inertia v3 + configurable widget path

Changed

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

Full Changelog: https://github.com/escalated-dev/escalated/compare/v0.7.0...v0.7.1

View on GitHub
v0.7.0Escalated

See CHANGELOG.md for full details.

View on GitHub