Skip to main content

Changelog

Release notes and version history for Escalated and its framework packages.

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/criticalnormal/urgent, waiting_on_*waiting, ticket_numberreference), 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 errorresolve() 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
v1.2.5Escalated for Laravel

Highlights

Resolves the install failure reported in #88 for real this time (#92)

The actual underlying issue (surfaced thanks to verbose migrate output): MariaDB and older MySQL versions reject escalated_macros's foreign key to users(id) with errno: 150 "Foreign key constraint is incorrectly formed" whenever the host app's users table doesn't exactly match what we expect. Reproduced on MariaDB 11 under four common host configurations:

  • users table on MyISAM (legacy Laragon / WAMP default_storage_engine=MyISAM)
  • users.id is signed BIGINT (project upgraded from Laravel pre-5.8)
  • users.id is INT UNSIGNED (project from pre-5.8 increments())
  • users.id is CHAR(36) UUID (custom user model with HasUuids)

We can't control any of those four from a package, so this release drops the foreign-key constraints from the five migrations that pointed at the host's users table:

  • escalated_macros.created_by
  • escalated_ticket_followers.user_id
  • escalated_saved_views.user_id
  • escalated_mentions.user_id
  • escalated_tickets.snoozed_by

Each becomes a plain unsignedBigInteger column. Foreign keys to our own tables (e.g. escalated_tickets) stay — those always work because we own both sides.

This is the same pattern spatie/laravel-permission, spatie/laravel-activitylog, and Filament use for the same reason.

Better diagnostics on install (#91)

escalated:install was using callSilently('migrate') wrapped in a task component, which swallowed Laravel's actual SQL error and just printed FAIL. Now it prints the full migrate output (per-migration timing, failing migration name, full PDO/SQL error). Same treatment for db:seed. Took a back-and-forth with the reporter to get the real error before — won't happen again.

Backwards compatibility

Forward-compatible only. Anyone with a successful prior install keeps their existing FK constraints because Laravel doesn't re-run migrations once recorded. Anyone hit by #88 sees migrations 14+ run cleanly on their next php artisan migrate.

If a host app hard-deletes users, orphaned created_by / user_id rows in our tables won't be cascade-cleaned anymore (most apps soft-delete users so this is a non-issue in practice). If you need cleanup, attach a User-deleting model observer in the host app.

Upgrade

composer update escalated-dev/escalated-laravel
php artisan escalated:install

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

View on GitHub
v1.2.4Escalated for Laravel

Highlights

Install translations now actually flow from escalated-dev/locale (#90)

The central escalated-dev/locale package was already declared as a composer dep but wasn't actually serving any translations — its JSON uses camelCase keys (matching the Vue frontend) while every PHP-side __() call used snake_case, so the central package was effectively dead weight and translations came entirely from the package's own resources/lang/ PHP fallback.

This release wires the install command to actually pull from central:

  • New JSON-to-Translator bridge in EscalatedServiceProvider (bridgeCentralGroups()) reads each <locale>.json from escalated-dev/locale and injects the nested data into Laravel's translator under the escalated namespace via addLines(). After the bridge fires, __('escalated::commands.install.publishingConfig') resolves against the central JSON. The PHP fallback's commands.php is no longer loaded for the commands group in production — central is the single source of truth.
  • The bridge auto-converts central's {placeholder} syntax (matches the Vue t() helper) to Laravel's :placeholder convention on the way in, so call sites stay idiomatic.
  • All escalated::commands.install.* keys converted from snake_case to camelCase in InstallCommand.php and in every locale's commands.php fallback. The fallback now serves as a parity-with-central offline backup for the dev path.
  • Composer constraint bumped to escalated-dev/locale: ^0.1.8 so the new install keys (seedingPermissions, runningMigrations, runMigrationsConfirm, stepSeed from escalated-locale v0.1.8) are guaranteed present.

Scope (and what's still local)

Only the commands group is bridged today. Other groups — enums, notifications, emails, messages, validation — continue to load from resources/lang/ because their snake_case enum-value keys (e.g. enums.status.in_progress) don't yet have camelCase parity in central. Migrating those is a separate follow-up since enum values are persisted in user databases.

Backwards compatibility

  • Composer install with v0.1.8 central: install command translations resolve via central (production path).
  • Dev/offline scenarios without composer: bridge resolver returns null, install command falls back to the package's resources/lang/ PHP files (now camelCase, matching the call sites).
  • Host-app overrides at lang/vendor/escalated/{locale}/commands.php still work via Laravel's loadNamespaceOverrides mechanism (unchanged).
  • No public API changed — internal i18n plumbing only.

Test plan

  • New tests/Unit/CentralLocaleBridgeTest.php (3 tests, 23 assertions) locks in the bridge: populates $loaded['escalated']['commands'], resolved values match central JSON exactly across en and fr, and {placeholder}:placeholder rewriting works.
  • Full suite: 609 passed.

Full Changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.2.3...v1.2.4

View on GitHub
v1.2.3Escalated for Laravel

Highlights

escalated:install no longer breaks on a clean install (#89, resolves #88)

Previously the command published migration files and then immediately tried to seed escalated_permissions — a table that hadn't been created yet, causing every fresh install to fail with SQLSTATE[42S02] Table doesn't exist. The command now prompts to run migrations + seed default permissions in the correct order, and the printed setup instructions are state-aware (they drop the migrate / seed steps when the user opted into auto-running them).

Also adds the missing seeding_permissions translation key across all 14 locales, fixes a __() call that was silently passing a fallback string into the $locale slot, and adds new running_migrations, run_migrations_confirm, step_seed keys for the new prompts.

Migration / index fixes

  • fix(migration): assign explicit name to delayed_actions index (#87) — pre-empts the Identifier name too long MySQL error for users with a custom escalated.table_prefix longer than 13 characters.
  • fix(migration): assign explicit name to ticket_links unique index (#86, thanks @matalaweb) — fixes the same class of bug for the default escalated_ prefix on MySQL/PostgreSQL.

Other changes since v1.2.2

  • feat(i18n): consume central translations from @escalated-dev/locale (#85)
  • chore(deps): modernize host-app frontend (Vite 8 + Tailwind 4) (#84)
  • Various dependabot updates

Backwards compatibility

All changes in this release are backwards-compatible. The migration index renames in #86 / #87 only take effect on fresh installs — anyone who already ran the original migrations keeps the old auto-generated index names (Laravel doesn't re-run migrations).

Full Changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.2.2...v1.2.3

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
v1.2.2Escalated for Laravel

Fixed

  • Customer priority filter now actually filters (#66, fixes #64) — Customer\TicketController@index was dropping the priority URL param (and several other visible filters) before calling the driver. The shared TicketFilters.vue component still rendered a priority dropdown, so the param landed in the URL but never reached a where clause. Allow-list now matches every key the UI sends: status, priority, ticket_type, search, tag, has_attachments, created_after, created_before, plus sort controls. Agent/admin/api paths were never affected — regression tests added for all three to prevent drift.

  • Graceful fallback when the host's users table has no name column (#65, fixes #63) — ticket search (Ticket::scopeSearch + LocalDriver requester filter) assumed a name column existed on the host's user model and would either error against Postgres (column users.name does not exist) or silently return empty against sqlite. The new Escalated::applyUserSearch() helper now consults config('escalated.user_display_column'), checks Schema::hasColumn once per process, and silently degrades to email-only search when the column doesn't exist. Hosts with split first_name/last_name columns now work out of the box; setting ESCALATED_USER_DISPLAY_COLUMN=first_name gives them full first_name+email search.

  • Install command no longer duplicates migrations (#62, fixes #61) — re-running php artisan escalated:install (e.g. during package upgrades) previously republished every migration with a fresh timestamp prefix, leaving the host with duplicate *_create_escalated_*_table.php files and a broken migrate run. Install now globs for existing *_create_escalated_*_table.php files and skips publication if any are present; --force deletes the stale copies first before republishing.

Full changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.2.1...v1.2.2

View on GitHub
v1.2.1Escalated for Laravel

Fixed

  • Postgres compatibility for the Reports endpoint (#60, fixes #59) — ReportingService and ReportController::avgFirstResponseHours previously emitted MySQL-only TIMESTAMPDIFF(HOUR, …) and DATE_FORMAT(…) SQL, which Postgres rejects with column "hour" does not exist. Helpers now match on the driver across sqlite | pgsql | mysql and emit EXTRACT(EPOCH FROM (to - from)) / 3600 / to_char(…, 'YYYY-MM') etc. on Postgres. 16 new unit tests assert exact SQL per driver.

Internal

  • New Docker dev/demo environment under docker/ (#58). docker compose up --build from docker/ boots a 3-container Postgres-backed Laravel host (PHP 8.3 + Postgres 16 + Mailpit) with the package installed and a /demo click-to-login picker. Excluded from the Composer dist via archive.exclude — won't appear in vendor/.
  • Added Escalated\Laravel\Database\Factories\ to the production PSR-4 autoload so Model::factory() resolves at runtime in real installs (same class of bug as #55).

Full changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.2.0...v1.2.1

View on GitHub
v1.2.0Escalated for Laravel

Security

  • SSRF: WorkflowEngine::actionSendWebhook() now validates URL scheme (http/https only) and rejects URLs that resolve to private/reserved IPs (#49)
  • ReDoS: regex injection blocked in compareValues() matches operator via safeRegexMatch() with pattern validation and a PCRE backtrack limit of 10,000 (#49)
  • Action-type injection: strict in: validation for actions.*.type in WorkflowController store/update (#49)
  • Field-access whitelist: resolveFieldValue() default case now whitelists subject, description, ticket_type, channel instead of open $ticket->{$field} access (#49)
  • Rate limiting: ticket creation 5/min, chat start 5/min, chat message 30/min (#49)
  • Audit log: workflow create/update/delete and report exports now produce AuditLog entries (#49)

Fixed

  • php artisan escalated:install no longer aborts with Target class [Escalated\Laravel\Database\Seeders\PermissionSeeder] does not exist — the seeder namespace is now registered in production autoload (#56, fixes #55)
  • Attachment serialization now includes url (#50)
  • Ticket serialization includes computed fields (#51), chat / context panel / activity fields (#52), and the previously missing workflow / workflow log computed fields (#54)
  • Expensive computed fields moved off $appends so list endpoints stay fast; now only included in detail serialization (#53)

Internal

  • CI: minimum-stability set to stable and audit.ignore added for two phpunit advisories that were preventing the resolver from selecting any compatible phpunit version (#57)

Full changelog: https://github.com/escalated-dev/escalated-laravel/compare/v1.1.0...v1.2.0

View on GitHub
v0.7.0Escalated

See CHANGELOG.md for full details.

View on GitHub
v1.1.0Escalated for Laravel

What's Changed

Model-scoped ticket operations (#25)

  • Moved ticket operations (status transitions, assign/unassign, priority, department, replies, activity logging) into Ticket model scope
  • Use mass assignment for ticket/reply creation
  • Fire TicketCreated/TicketUpdated via model hooks, ReplyCreated/InternalNoteAdded via Reply::booted()
  • Added LogTicketStatusChange listener

Reference generation at model creation (#30)

  • Centralized reference generation in model boot hooks — no more scattered TEMP + saveQuietly patterns
  • Auto-generates reference if not explicitly provided
  • Auto-sets status to Open if not provided

Bug fixes (#25, #26, #31)

  • Fix TicketCreated event timing — listeners now receive the final ESC-XXXXX reference, not TEMP-{uuid}
  • Fix double-dispatch of InternalNoteAdded
  • Fix markEscalated() incorrectly setting resolved_at
  • Fix variable shadowing in assign() and changeDepartment() error messages
  • Restore TicketPriority::from() cast in inbound email guest ticket creation
  • Register LogTicketStatusChange listener in service provider
  • Add Notifiable trait to HasTickets

Housekeeping

  • PSR-12 code style fixes
  • Removed accidental package-lock.json
View on GitHub
v1.0.0Escalated for Laravel

Escalated for Laravel v1.0.0

The first stable release of Escalated for Laravel — a full-featured, embeddable support ticket system.

Highlights

  • Laravel 13 + Inertia v3 support with backwards compatibility for Laravel 11/12 and Inertia v2
  • Core-only boot mode — run Escalated without the Inertia UI (API/headless)
  • 6 critical security vulnerabilities fixed
  • 36 new features since v0.6.0

New Features

Ticket Management

  • Custom Fields & Forms — define custom fields per ticket type with conditional logic
  • Custom Statuses — configurable ticket status workflows beyond open/closed
  • Ticket Merging — merge duplicate tickets into a single thread
  • Problem/Incident Linking — link related tickets as problem/incident pairs
  • Side Conversations — private threads within a ticket for internal collaboration
  • Ticket Type Categorization — categorize tickets with automation support
  • Advanced Search — expanded search to requester, with advanced filter params

Agent Workflows

  • Custom Agent Roles / RBAC — granular role-based access control for agents
  • Agent Collision Detection — prevent multiple agents from working the same ticket
  • Light Agents — view-only agent access for stakeholders
  • Skills-Based Routing — route tickets to agents based on skill matching
  • Agent Capacity Management — set and enforce agent workload limits
  • Configurable User Display Column — choose which user field shows in agent selects

Automation & Rules

  • Time-Based Automations — trigger actions based on time conditions (idle tickets, SLA breach)
  • Outbound Webhooks — send ticket events to external services
  • Escalation Rule Categories — organize escalation rules by category

Knowledge Base

  • Knowledge Base — full KB with articles, categories, and controllers

Reporting & Analytics

  • Enhanced Reporting — ReportingService with expanded report types
  • CSAT Settings — customer satisfaction survey configuration

Authentication & Security

  • Two-Factor Authentication — TOTP-based 2FA for agents
  • SSO Service — single sign-on integration with SAML validation
  • JWT & DKIM Validation — token and email authentication verification
  • 6 Critical Security Fixes — patched vulnerabilities in input validation, auth, and data exposure

Data Management

  • Import System — CLI command + admin UI for importing tickets, users, and settings
  • ImportService — orchestrator with entity persistence, resumability, and event suppression
  • Audit Log — full audit trail of all actions

Platform & Extensibility

  • Plugin Bridge — JSON-RPC communication with SDK-based plugins
  • Plugin Marketplace Commandartisan escalated:plugins for discovering plugins
  • Business Hours & Schedules — define working hours for SLA calculation
  • Granular Permission Seeder — default roles with fine-grained permissions
  • Show/Hide Powered By — configurable branding setting

Framework Support

  • Laravel 13 + Inertia v3 — full support while maintaining backwards compat
  • Core-Only Boot Mode — run without Inertia UI for API-only deployments
  • Configurable Table Prefix — consistent prefix support across all migrations

Bug Fixes

  • Register LogTicketStatusChange listener for TicketStatusChanged event
  • Resolve bugs in model functions PR
  • Pass $prefix to Schema::create closure via use() keyword
  • Fix missing use ($prefix) in knowledge base migration closure
  • Update Inertia render path for Plugins page
  • Prevent false positive trait detection in addHasTicketsTrait

Upgrading from v0.6.0

composer require escalated-dev/escalated-laravel:^1.0
php artisan migrate
php artisan escalated:install --force

Review the new config options in config/escalated.php after upgrading.

Full Changelog: https://github.com/escalated-dev/escalated-laravel/compare/v0.6.0...v1.0.0

View on GitHub