Skip to content

Releases

v1.6.0

Choose a tag to compare

@mpge mpge released this 26 Sep 02:20
bbb8b16

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-laravel 3.1+ on the server. An app still on the 2.x adapter must upgrade it first. On 3.x, point inertia.pages.paths at your pages directory if it is not the new lowercase resources/js/pages default: 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

Choose a tag to compare

@mpge mpge released this 09 Sep 14:59
788f858

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

Choose a tag to compare

@mpge mpge released this 09 Sep 14:08
832c373

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 $resource and no canSee() override is now hidden. Widgets are gated on the viewAny policy of the resource they declare, and a widget that declares none cannot be authorized by anything. Set saddle.authorization.require_widget_resource to false for 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 to greeting(?string $name = null): ?string and returns null unless brand.greeting is 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. ResourceOptionsController authorized the owning resource, then queried the related model directly, so anyone who could reach a BelongsTo could walk the entire related table a prefix at a time. The same rows also leaked without touching the endpoint, because a non-searchable BelongsTo inlines 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 ESCAPE clause, 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. greeting may contain :name. Both default to null, 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, so sortable() and searchable() still refer to the real database column — which is exactly what a model accessor cannot give you. Applies to the CSV export too. (#21)
  • Password field — 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 the hashed cast the row keeps a valid-looking bcrypt hash and the only symptom is that nobody can sign in as them again. Password ignores 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, plus Field::component() and Widget::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() calls share() 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

Choose a tag to compare

@mpge mpge released this 10 Aug 01:35
bea7cac

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 by viewAny, 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.
  • FileUpload restricts types by default. With no image() or acceptedTypes() it produced exactly ['nullable', 'file'] — no type restriction, no size cap — onto a disk defaulting to public. Laravel names a stored file from its detected content type, so an upload called notes.txt containing HTML lands as <random>.html on 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_case column 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.50 became '-12.50, and decimal columns arrive as strings from MySQL and Postgres); blank cells were written as '' into typed columns, which is a 22P02 on 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 calls eagerLoadRelations(), 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:install cannot destroy your composer.json. json_encode() returns false on invalid UTF-8, and false . PHP_EOL is a single newline — which was written straight over the file, after which the command reported success and exited 0.
  • migrate:rollback cannot drop a notifications table 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 the update ability. Add ->withoutAuthorization() to any action that genuinely needs no per-record check.
  • A FileUpload with no image() or acceptedTypes() now enforces a default type allowlist and a size cap. Configure via saddle.uploads.allowed_extensions and saddle.uploads.max_size.

Known work is tracked in #6 through #11.

v1.2.0

Choose a tag to compare

@mpge mpge released this 21 Jul 14:31

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 no title callback, 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 a title callback suffixes the configured brand name — the tab shows e.g. Products · Saddle. (#4)

Full changelog: v1.1.0...v1.2.0

v1.1.0

Choose a tag to compare

@mpge mpge released this 20 Jul 00:48

Second stable release since v1.0.1.

Security

  • Relation viewAny gate 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/fgetcsv now pass escape: '', 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:install now warns when it cannot parse composer.json.

Full changelog: v1.0.1...v1.1.0

SaddlePHP 1.0.1

Choose a tag to compare

@mpge mpge released this 20 Jun 10:32

Patch release

Fixes a tenant-scoping isolation issue and hardens misconfiguration handling.

  • Per-resource tenant storage — each resource now owns its $tenant state, 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 $tenant statically, were unaffected).
  • Clear error on a bad tenant relation — when $tenant names a relation the model does not have, the resource query now throws an actionable LogicException (Resource [X]: $tenant names [y] but Model has no such BelongsTo relation.) instead of a cryptic deep RelationNotFoundException.

No API changes. Drop-in patch over 1.0.0.

SaddlePHP 1.0

Choose a tag to compare

@mpge mpge released this 19 Jun 19:00

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 — BelongsTo fields 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

Choose a tag to compare

@mpge mpge released this 20 Jun 00:50

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 InteractsWithSaddle trait to assert your resources without driving the browser.

Stack: Laravel 13+ / PHP 8.4+, Inertia 2, Vue 3, Tailwind 4.

Insight

Choose a tag to compare

@mpge mpge released this 20 Jun 00:50

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.