Repository navigation
Releases
v1.6.0
Saddle now supports Laravel 11, 12 and 13. Before this release it required Laravel 13. No breaking changes, and nothing in the package's source had to change: the whole suite (563 tests / 2076 assertions), PHPStan level 5 and Pint pass unchanged on every supported version. (#30)
Requirements
illuminate/*^11.48|^12.0|^13.0- PHP 8.4+
inertiajs/inertia-laravel3.1+ on the server. An app still on the 2.x adapter must upgrade it first. On 3.x, pointinertia.pages.pathsat your pages directory if it is not the new lowercaseresources/js/pagesdefault: the mismatch only shows on case-sensitive filesystems.
The lower bound is 11.48, not 11.0, because that is the lowest version the suite actually proves (the prefer-lowest CI job resolves to it).
Laravel 11 is end-of-life
Laravel 11 stopped receiving security fixes in March 2026, and every 11.x release carries the unpatched advisory PKSA-mdq4-51ck-6kdq (CRLF injection in the default email rule), so Composer refuses to install it unless advisory blocking is turned off. Saddle keeps working on 11 to ease upgrades. Run new panels on 12 or 13.
CI
Every pull request now runs:
- PHP 8.4 and 8.5 against Laravel 11, 12 and 13 (except PHP 8.5 with Laravel 11, which Laravel 11 never supported), plus a prefer-lowest run
- the MySQL 8 and Postgres 16 suites against all three Laravel versions
That is 13 jobs, all green for this release.
v1.5.0
563 tests / 2076 assertions. Pint and PHPStan level 5 clean; the suite runs on SQLite, MySQL 8 and Postgres 16.
Four fixes, all found consuming 1.4.0 in production. No breaking changes.
Upgrading
If you are on 1.4.0 or earlier, republish the panel assets on this upgrade. The compiled Vue bundle is a published file under public/, so updating the package replaces the PHP and leaves that file exactly where it is. A panel in that state passes every server-side check — the version constant, the new classes, the new methods — while rendering the previous frontend, and the only symptom is that the feature you upgraded for appears to be missing.
On a stock Laravel application the new laravel-assets tag (below) handles this for you on this upgrade already: Composer writes the new package files before running post-update-cmd, and vendor:publish resolves its paths from the booted provider — 1.5.0's by then. Run php artisan saddle:upgrade by hand if you removed that line from post-update-cmd, or if you deploy from a build artifact where Composer scripts do not run.
The one that cost a deploy
The compiled bundle now publishes under laravel-assets as well as saddle-assets. (#25)
laravel-assets is the tag a fresh Laravel application's own composer.json calls on post-update-cmd, and it is the only thing that republishes a package's compiled frontend automatically. Published under saddle-assets alone, every consumer hit the stale-bundle failure above on every upgrade unless they had happened to wire saddle:upgrade into their deploy — a silent failure that misdiagnoses itself. Two of 1.4.0's five features were invisible in production for one consumer this way.
A test asserts the laravel-assets tag carries the bundle and nothing else. It runs unattended on every composer update, so config, migrations or language files riding along on it would silently overwrite a host's customised copies.
Fixes
A false boolean cell no longer shares the em dash that means "no value". (#26)
true rendered an accent check and false rendered —, so the pair read as "yes / unknown" rather than "yes / no". That is actively misleading, because an em dash already means no value elsewhere in the panel — it is what a StatWidget shows for a null. On a table of integrations, "switched off" and "we have no idea" rendered identically, and those are different facts.
false now renders a muted cross. The em dash is reserved for a boolean cell with no value at all, which BooleanColumn never produces on its own but formatUsing() now can:
BooleanColumn::make('connected')
->formatUsing(fn (mixed $value, Model $record) => $record->checked_at === null ? null : $value),The read-only view page had the same collision and gets the same fix. All three cell states carry a translated aria-label across the fourteen shipped locales — the old Yes/No labels were hardcoded English, and adding an untranslated Unknown would have made that worse.
Developer experience
A widget hidden for having no $resource now says so. (#27)
The 1.4.0 fail-closed default is right, but a widget that declares no resource and does not override canSee() simply did not appear, with nothing anywhere saying why — the dashboard was just missing a tile. It now throws a LogicException naming the class in the local environment and logs a warning everywhere else. The split is deliberate: a live panel losing a tile must not become a live panel losing its dashboard.
There is nothing to silence — declaring $resource, overriding canSee(), or turning require_widget_resource off all make the widget authorizable, and an authorizable widget says nothing. Nor does one whose policy simply denies the current user; that is a decision rather than a misconfiguration.
Form::toArray() and Table::toArray(). (#28)
Field::toArray() and Column::toArray() serialize a leaf, while the containers named the same operation toInertia() — so Form::toArray(), the reasonable guess, returned Call to undefined method. Both names now work on both classes. Aliases, not renames: toInertia() is public API. Tests assert the two names return the identical array and that neither bypasses canSee().
v1.4.0
546 tests / 2039 assertions. Pint and PHPStan level 5 clean; the suite runs on SQLite, MySQL 8 and Postgres 16.
Breaking
- A dashboard widget with no
$resourceand nocanSee()override is now hidden. Widgets are gated on theviewAnypolicy of the resource they declare, and a widget that declares none cannot be authorized by anything. Setsaddle.authorization.require_widget_resourcetofalsefor the old fail-open behaviour. An aggregate discloses more than it looks like it does — a bare count on a shared dashboard tells a competitor tenant your customer count and, watched over time, your growth rate. (#18) Saddle::greeting()changed signature togreeting(?string $name = null): ?stringand returnsnullunlessbrand.greetingis configured, rather than a fixed string. It was dead code — nothing rendered it — but it was on the facade. (#20)
Security and correctness
- Relation option lookups are gated on the related resource's
viewAny.ResourceOptionsControllerauthorized the owning resource, then queried the related model directly, so anyone who could reach aBelongsTocould walk the entire related table a prefix at a time. The same rows also leaked without touching the endpoint, because a non-searchableBelongsToinlines its options into the form payload. (#14) - Columns gained
canSee(), closing four leak paths: the index cells, the columns payload, the CSV export, and the sortable/searchable lists. The last two matter as much as the cells —?sort=leaks the ordering of a hidden attribute and?search=leaks which rows match a guess. (#13) - Search compiles an
ESCAPEclause, so a term containing%or_matches literally instead of silently returning zero rows with a 200. (#12)
Features
Saddle::resolveNonceUsing()— supply a Content-Security-Policy nonce for the panel shell's one inline script, the pre-paint dark-mode bootstrap. Under a strict CSP the browser refused it, costing a violation on every page and a flash of the light theme on every navigation. Resolved fresh per render; a failing provider degrades to no nonce rather than a 500. Register nothing and the shell renders byte-identically to 1.3.0. (#19)brand.greeting/brand.subgreeting— the dashboard's opening two lines are configurable.greetingmay contain:name. Both default tonull, which keeps Saddle's own wording. (#20)Column::formatUsing()— transform a cell between resolving and rendering, on every column type. It runs after the value is resolved, sosortable()andsearchable()still refer to the real database column — which is exactly what a model accessor cannot give you. Applies to the CSV export too. (#21)Passwordfield — masking was never the missing piece. An edit form cannot show the current password, so the field always renders empty, and a plain field therefore sets the password to an empty string every time somebody edits a user's name. With thehashedcast the row keeps a valid-looking bcrypt hash and the only symptom is that nobody can sign in as them again.Passwordignores a blank submission and never returns the stored hash to the form. (#22)- A runtime registry for plugin Vue components —
window.Saddle.registerField/registerLayout/registerWidget, consulted before the built-in dispatch maps, plusField::component()andWidget::component(). A plugin can now ship a Vue SFC instead of compiling to a custom element. (#17)
Fixes and performance
- Row actions no longer wrap in the actions cell. The cell was a hardcoded 112px, so three actions broke over three lines and the tallest cell set the row height for every row. Wide tables now scroll rather than being clipped. (#23)
- Shared props and row policies that get discarded are no longer computed.
Inertia\Middleware::handle()callsshare()before the request is handled, so every eager value was paid by the global-search XHR, the options picker, the CSV download and every write that only redirects. (#16)
v1.3.0
A correctness and hardening release. Everything below came out of a full-codebase audit and was reproduced against the source before it was fixed.
Fixed things you could see
The panel stopped telling everyone their assets were stale. Releases 1.1.0 and 1.2.0 bumped only src/Saddle.php, so every install compared 1.2.0 against a bundle still stamped 1.0.1 and displayed "Panel assets are out of date. Run php artisan saddle:upgrade" on every page — and saddle:upgrade republished the same bundle, so following the instruction could never clear it. vite.config.js now reads the version out of the PHP constant, so the two cannot drift.
File uploads work on edit forms. They never have. Inertia serializes the payload as FormData once a File is present and dispatches the verb it was given; it does not spoof the method. PHP only parses multipart bodies on POST, so the PUT arrived with $_POST and $_FILES both empty — every required field failed validation with "the name field is required" for values the user had just typed, and the file was silently dropped. Create worked; edit never did.
Installing a plugin no longer blanks your panel. Saddle::register() and auto-discovery were mutually exclusive: the first register() call anywhere in the boot cycle permanently suppressed scanning of app/Saddle. Since plugins register from their own service providers, installing one removed every application resource from the nav, from global search, and 404'd every /resources/{key} route. The two are now merged, with saddle.resources.discovery to opt out.
Search works on Postgres. Postgres LIKE is case-sensitive, so searching cis for "Cisco" matched nothing at all — index search, global search and the BelongsTo picker were all dead on every Postgres install.
Security
- Actions are no longer fail-open. An action with no
authorize()was gated only byviewAny, so a read-only account could invoke any action a developer had not annotated — "approve refund", "reset password", "mark verified". The shipped example resource had two such actions, so that was the pattern being copied. FileUploadrestricts types by default. With noimage()oracceptedTypes()it produced exactly['nullable', 'file']— no type restriction, no size cap — onto a disk defaulting topublic. Laravel names a stored file from its detected content type, so an upload callednotes.txtcontaining HTML lands as<random>.htmlon your own origin, and an admin who opens the record is running attacker-controlled script with their session.- Updates re-stamp the tenant. Stamping ran on store and import but not update, and
Form::fill()bypasses$fillable, so a resource exposing its tenant relation as a field let an update move a record into another tenant.
Data integrity
- CSV export → import round-trips. Export wrote human labels while import matched raw field names, so every
snake_casecolumn was silently dropped and a required one skipped the entire file. Also: the formula-injection apostrophe was never stripped on the way back (so-12.50became'-12.50, and decimal columns arrive as strings from MySQL and Postgres); blank cells were written as''into typed columns, which is a22P02on Postgres and — inside the import transaction — discarded every good row before it; and a UTF-8 BOM fused onto the first header so the first column never mapped. - Export no longer N+1s.
Builder::cursor()never callseagerLoadRelations(), so every->with()was discarded and each relation column lazy-loaded once per row. Measured 23 queries against the index page's 5 for identical data. Also adds an export row cap. saddle:installcannot destroy yourcomposer.json.json_encode()returnsfalseon invalid UTF-8, andfalse . PHP_EOLis a single newline — which was written straight over the file, after which the command reported success and exited 0.migrate:rollbackcannot drop anotificationstable you own. The guard tested emptiness rather than ownership, so any environment that had the table but no notifications yet lost it.- The reserved-word route guard actually works. Symfony strips
^and$from route requirements before compiling, so the constraint was a no-op wherever{record}was not the final segment.
Testing
CI now runs the suite against MySQL 8 and Postgres 16 alongside SQLite, across PHP 8.4 and 8.5, plus a --prefer-lowest resolution. The suite had been pinned to sqlite::memory:, which made a whole class of driver-portability bug invisible by construction — the Postgres search bug above was caught by these jobs on their first run.
451 tests, 1716 assertions. PHPStan and Pint clean.
Upgrading
Two behaviour changes worth knowing about:
- An action with no
authorize()now checks theupdateability. Add->withoutAuthorization()to any action that genuinely needs no per-record check. - A
FileUploadwith noimage()oracceptedTypes()now enforces a default type allowlist and a size cap. Configure viasaddle.uploads.allowed_extensionsandsaddle.uploads.max_size.
v1.2.0
Fixed
- Browser-tab title showed the URL. The panel's
<title inertia>element was handed to Inertia to manage, but no page rendered an Inertia<Head>and there was notitlecallback, so the tab title was emptied on navigation and browsers fell back to the URL. Every page now sets a<Head :title>(resource label / record title / "New " / etc.) and atitlecallback suffixes the configured brand name — the tab shows e.g.Products · Saddle. (#4)
Full changelog: v1.1.0...v1.2.0
v1.1.0
Second stable release since v1.0.1.
Security
- Relation
viewAnygate on the record view page (#3) — the view page no longer ships related rows the relation's own policy denies. - RFC-4180 CSV import/export (#2) —
fputcsv/fgetcsvnow passescape: '', fixing backslash corruption, hardening the formula-injection neutraliser, and clearing the PHP 8.4 deprecation. - Additional audit hardening and regression fixes.
Added
- Plugin extension seams for shared Inertia props, navigation, and theme tokens.
Changed / Internal
- De-duplicated the resource and relation controllers.
- Larastan static analysis added to CI; locked the shared Inertia prop keyset across tenancy on/off.
saddle:installnow warns when it cannot parsecomposer.json.
Full changelog: v1.0.1...v1.1.0
SaddlePHP 1.0.1
Patch release
Fixes a tenant-scoping isolation issue and hardens misconfiguration handling.
- Per-resource tenant storage — each resource now owns its
$tenantstate, so toggling it on one resource can never leak to another (a footgun the test harness could trip via runtime mutation; production resources, which declare$tenantstatically, were unaffected). - Clear error on a bad tenant relation — when
$tenantnames a relation the model does not have, the resource query now throws an actionableLogicException(Resource [X]: $tenant names [y] but Model has no such BelongsTo relation.) instead of a cryptic deepRelationNotFoundException.
No API changes. Drop-in patch over 1.0.0.
SaddlePHP 1.0
SaddlePHP 1.0 🤠
The open-source, stack-agnostic admin panel framework for Laravel reaches 1.0. Define an Eloquent resource, get a polished Inertia + Vue panel — and your host app installs zero npm.
What's in 1.0
- Resources — CRUD from a model, with a record view / detail page
- Forms — field + layout builder (sections, grids, tabs), file uploads, validation, per-field visibility
- Tables — columns, filters, search, sort, row + bulk actions
- Relations —
BelongsTofields and HasMany relation managers (parent-scoped CRUD) - Global search across every resource
- Soft deletes — trashed filter, restore, force-delete
- Dashboard widgets — stat cards + a chart
- Database notifications — a sidebar bell with read/read-all
- Dark mode + theming hooks, host-overridable design tokens
- Localization (i18n) via vue-i18n, sourced from publishable lang files
- Host testing helpers —
InteractsWithSaddle - CSV import / export
- Multi-tenancy — membership-scoped, plus an access gate and opt-in tenant registration
- Policy-driven authorization throughout, fail-open or fail-closed
- Plugins — register resources, scripts, and framework-agnostic custom-element fields/columns
Since v0.8
This release series added: relation managers + the view page (v0.9), global search + soft deletes (v0.10), dashboard widgets + notifications (v0.11), dark mode/theming + i18n + testing helpers (v0.12), and CSV import/export + tenancy extras (v1.0).
Stack
Laravel 13+ / PHP 8.4+, Inertia 2, Vue 3, Tailwind CSS 4.
First-party plugins
Three first-party plugins demonstrate the plugin story: a Markdown field, a media (image) field, and a model-change audit log.
Polish
Polish
- Dark mode + theming hooks — a light / dark / system toggle (persisted, no flash) and host-overridable design tokens via
saddle.brand.theme. - Localization (i18n) — the panel runs through vue-i18n, sourced from publishable Laravel lang files; follows the app locale.
- Host testing helpers — the
InteractsWithSaddletrait to assert your resources without driving the browser.
Stack: Laravel 13+ / PHP 8.4+, Inertia 2, Vue 3, Tailwind 4.
Insight
Insight
- Dashboard widgets — auto-discovered stat cards (with inline mini charts) and a chart widget, computed per request and tenant-aware.
- Database notifications — a sidebar bell with an unread badge, recent items, and mark-read / mark-all-read.
Stack: Laravel 13+ / PHP 8.4+, Inertia 2, Vue 3, Tailwind 4.