Trail guide

Documentation

Everything you need to saddle up and build a production admin panel with Saddle.

Getting started

Saddle is the open-source admin panel framework for Laravel, built on Inertia and Vue 3. Three commands are all you need to go from a fresh Laravel app to a working admin panel.

Requirements

  • Laravel 11.48+, 12 or 13 and PHP 8.4+. Every supported Laravel version runs the full test suite in CI.
  • inertiajs/inertia-laravel 3.1+ on the server. An app still on the 2.x adapter must upgrade it first.
  • Inertia 2, Vue 3, and Tailwind CSS 4 (the panel bundle brings these; your app just needs a working Vite setup)

Laravel 11 is end-of-life. It stopped receiving security fixes in March 2026, and every 11.x release is affected by an unpatched advisory (PKSA-mdq4-51ck-6kdq), so Composer refuses to install it unless advisory blocking is turned off. Saddle keeps working on it to ease upgrades, but run new panels on Laravel 12 or 13.

Install

composer require saddlephp/saddlephp
php artisan saddle:install
php artisan saddle:resource HorseResource --model=Horse

The service provider is auto-discovered by Laravel, so no manual wiring is needed.

What saddle:install publishes

Running saddle:install does three things:

  1. Publishes config/saddle.php with sensible defaults.
  2. Publishes the compiled panel assets to public/vendor/saddle/.
  3. Creates the app/Saddle/ directory where your resource classes live.

It also offers to add saddle:upgrade to composer.json's post-update-cmd scripts so assets stay fresh after every package update. Accept the prompt to set this up automatically.

Visiting the panel

Start your application and open /admin in a browser. The panel sits behind the auth middleware by default, so you must be logged in. The path config key controls the URL prefix if you need something other than admin.

Staying up to date

After each composer update that bumps the package, re-publish the panel assets:

php artisan saddle:upgrade

If you accepted the post-update-cmd prompt during install, Composer runs this for you automatically.

Since 1.5.0 the bundle also publishes under Laravel's own laravel-assets tag, which a fresh Laravel application's composer.json already calls on post-update-cmd. On such an application the assets are refreshed by composer update with no Saddle-specific setup at all.

This is not optional maintenance. The compiled Vue bundle is a published file under public/; upgrading the package replaces the PHP and leaves that file exactly where it was. A panel in that state passes every server-side check -- the version constant, the new classes, the new methods -- while rendering the previous frontend, so the only symptom is that the feature you upgraded for appears to be missing. If you pin a specific tag, or deploy from a build artifact, make sure republishing the assets is part of that pipeline.

Generating a resource

php artisan saddle:resource PostResource --model=Post

The --model option is optional. When omitted, the model name is inferred from the resource name (PostResource infers Post). The generated class is placed in app/Saddle/PostResource.php and registered automatically on the next request.

Resources

A resource is a PHP class that maps one Eloquent model to an admin panel section. It declares the form fields used to create and edit records, and the table columns and filters used to list them.

Anatomy of a resource

Place resource classes in app/Saddle/. Each class extends SaddlePHP\Resource and implements two static methods: form() and table().

<?php

declare(strict_types=1);

namespace App\Saddle;

use App\Models\Horse;
use Illuminate\Http\Request;
use SaddlePHP\Fields\BelongsTo;
use SaddlePHP\Fields\Date;
use SaddlePHP\Fields\Number;
use SaddlePHP\Fields\Select;
use SaddlePHP\Fields\Text;
use SaddlePHP\Fields\Textarea;
use SaddlePHP\Fields\Toggle;
use SaddlePHP\Forms\Form;
use SaddlePHP\Resource;
use SaddlePHP\Tables\Columns\BadgeColumn;
use SaddlePHP\Tables\Columns\BooleanColumn;
use SaddlePHP\Tables\Columns\TextColumn;
use SaddlePHP\Tables\Filters\BooleanFilter;
use SaddlePHP\Tables\Filters\SelectFilter;
use SaddlePHP\Tables\Table;

class HorseResource extends Resource
{
    public static string $model = Horse::class;

    public static ?string $title = 'name';

    public static ?string $icon = 'collection';

    public static array $with = ['rider'];

    public static function form(Form $form): Form
    {
        return $form->schema([
            Text::make('name')->required()->rules('max:120'),
            Select::make('breed')->options([
                'quarter' => 'Quarter Horse',
                'mustang' => 'Mustang',
                'appaloosa' => 'Appaloosa',
            ]),
            Textarea::make('notes')->rows(3)
                ->canSee(fn (Request $request) => (bool) $request->user()?->is_admin),
            Toggle::make('is_saddled'),
            BelongsTo::make('rider')->searchable(),
            Number::make('age')->integer()->min(0)->max(50),
            Date::make('foaled_on'),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table->columns([
            TextColumn::make('name')->sortable()->searchable(),
            BadgeColumn::make('breed')->colors([
                'quarter' => 'accent',
                'mustang' => 'ink',
                'appaloosa' => 'muted',
            ]),
            BooleanColumn::make('is_saddled'),
            TextColumn::make('rider.name')->label('Rider'),
            TextColumn::make('created_at')->date('M j, Y')->sortable(),
        ])->filters([
            SelectFilter::make('breed')->options([
                'quarter' => 'Quarter Horse',
                'mustang' => 'Mustang',
                'appaloosa' => 'Appaloosa',
            ]),
            BooleanFilter::make('is_saddled'),
        ]);
    }
}

Static properties

Property Type Description
$model string (class-string) Required. The Eloquent model class this resource manages.
$title string|null The model attribute used as the record title. Falls back to the primary key when null.
$icon string|null Icon name shown next to the resource in the sidebar.
$group string|null Sidebar group heading. Resources with the same group value are visually grouped together.
$with string[] Relations eager-loaded on every index query. Required for relation columns (e.g. rider.name).

Auto-discovery

Saddle scans app/Saddle/ at boot and registers every class it finds that extends SaddlePHP\Resource. No manual registration is needed for application resources. The scan path and namespace are configurable via config/saddle.php if your project uses a non-standard layout.

URI key and label derivation

The panel derives the URL key and navigation label automatically from the class name:

  • HorseResource produces uriKey horses and label Horses.
  • TrailRideResource produces uriKey trail-rides and label Trail Rides.

The class base name (everything before the Resource suffix) is pluralised and converted to kebab-case for the URI, and to headline case for the label.

Reserved route keys

The panel owns the static path segments create, options, actions, export, and import under each resource, so a record whose route key is literally one of those words is not reachable by its view, edit, update, or delete URLs. Use an integer key or a different slug for such records.

Record titles

recordTitle() returns the string the panel uses to identify a record in breadcrumbs and relation pickers. It reads the attribute named by $title. When $title is null, it falls back to the primary key.

// In a resource:
public static ?string $title = 'name';

// The panel calls:
$resource::recordTitle($record); // returns $record->name as a string

View page

Every record has a read-only view page, reachable from the View link on each index row. It renders the same form fields the edit page uses, in display mode: select and relation fields show their label, toggles show a checkmark, dates are formatted, files become links. Field layout (sections, grids, tabs) is preserved. Fields hidden by canSee are omitted here too.

The view page is gated by the view policy ability, and carries an Edit button when the user may update the record. It is also the host for relation managers (see the relations section).

Fields

Fields live inside form() and define what the create and edit forms render. Every field is constructed with a static make(string $name) call, then chained with modifiers.

Text

A single-line text input.

Text::make('name')->required()->placeholder('Full name')->rules('max:120'),

Modifiers specific to Text: type(string) changes the HTML input type (e.g. 'email', 'number'). The default type is 'text', which also adds a string validation rule. 'email' adds an email rule; 'number' adds numeric.

Password

A masked input that leaves the stored value alone when it is submitted blank.

Password::make('password')->helper('Leave blank to keep the current password.'),

Masking on its own was never the missing piece — Text::make('password')->type('password') does that. The behaviour every panel that manages users has to write for itself is this one: 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.

Laravel's hashed cast makes that worse rather than better. It faithfully hashes '', so the account keeps a valid-looking bcrypt hash and the only symptom is that nobody can sign in as them again. No error, no log line.

Password also returns null from resolve(), so the stored hash never reaches the edit page's HTML, the Inertia payload, or the read-only view page. It is unusable in a form, and shipping it to the browser buys nothing.

Saddle does not hash for you — the hasher, and whether the column is hashed at all, belongs to the application:

protected function casts(): array
{
    return ['password' => 'hashed'];
}

Leave the field optional. required() works, but because the field always renders empty it forces the password to be retyped on every edit of any other attribute.

Textarea

A multi-line text input.

Textarea::make('notes')->rows(3),

rows(int) controls the visible height. Default is 4 rows.

Select

A fixed-options dropdown. Pass an associative array of value => label pairs.

Select::make('breed')->options([
    'quarter' => 'Quarter Horse',
    'mustang'  => 'Mustang',
    'appaloosa' => 'Appaloosa',
]),

You may also pass a backed enum class-string instead of an array. Submitted values are validated against the declared options, so undeclared values are rejected.

Toggle

A boolean on/off switch. Defaults to false. Toggles cannot be required() (calling it is a no-op by design) because an unchecked toggle is a valid false submission, not a missing value.

Toggle::make('is_saddled'),

On save the value is cast to a real bool before being assigned to the model.

Number

A numeric input.

Number::make('age')->integer()->min(0)->max(50),
Number::make('weight')->min(0.1)->step(0.1)->max(9999),
Modifier Effect
integer() Adds an integer validation rule (default is numeric).
min(int|float) Sets the minimum allowed value.
max(int|float) Sets the maximum allowed value.
step(int|float) Controls the increment step shown in the browser input.

Date

A date input. Values are resolved as Y-m-d strings (any DateTimeInterface attribute is formatted automatically).

Date::make('foaled_on'),

DateTime

A date-and-time input (datetime-local). On save the value is passed directly to the model; use a datetime cast on the attribute so Eloquent stores it correctly. When resolving for the edit form, a DateTimeInterface value is formatted to Y-m-d\TH:i automatically.

DateTime::make('last_vet_visit'),

Markdown

A textarea with a formatting toolbar. The stored value is a plain string bounded to 65 535 characters (matches a MySQL TEXT column).

Markdown::make('notes'),

All modifiers from the common base (required(), rules(), placeholder(), helper(), canSee(), label()) apply as usual. Markdown extends Field directly, not Textarea, so it has no rows().

FileUpload

A multipart file upload. The stored value is the file path returned by Storage::put.

FileUpload::make('photo')->image()->directory('horses')->maxSize(4096),
FileUpload::make('attachment')->acceptedTypes(['pdf', 'docx'])->maxSize(10240),
Modifier Effect
disk(string) Storage disk to write to. Defaults to saddle.uploads.disk from config.
directory(string) Sub-directory within the disk. Defaults to saddle.uploads.directory from config.
image() Restricts the upload to image files (adds the image validation rule and sets the browser accept attribute to image/*).
acceptedTypes(array) List of accepted MIME extensions, e.g. ['pdf', 'docx']. Adds a mimes: validation rule and sets the browser accept attribute accordingly.
maxSize(int $kilobytes) Maximum file size in kilobytes (maps to Laravel's max file rule).

Edit-form behavior. When a file is already stored, leaving the upload input untouched keeps the existing file. Clearing the input stores null. Picking a new file replaces the stored path. Replaced or cleared files are not deleted from disk automatically.

BelongsTo

A relation select for BelongsTo relationships. The argument is the Eloquent relation method name on the model.

BelongsTo::make('rider'),
BelongsTo::make('rider')->searchable(),
BelongsTo::make('rider')->limit(50)->modifyOptionsQuery(
    fn ($query) => $query->where('stable_id', $this->stable_id)
),

The field reads $model->rider(), confirms it is a BelongsTo relation, and submits the foreign key on save.

Label resolution order. Option labels are resolved in this order:

  1. titleAttribute('attribute') if you have set one on the field.
  2. The related model's registered resource $title.
  3. The related model's primary key.

Set titleAttribute('name') explicitly if the related model has no registered resource, or your options list will show raw IDs.

Limit. Options are capped at 100 by default. Override with limit(int).

Async picker. searchable() switches from a static list to an async picker that queries the related table as you type. On the edit form, only the currently saved value is embedded; the full list is never loaded up front.

Scoping options. modifyOptionsQuery(fn ($query) => ...) scopes the option list for tenancy or visibility. It applies to both the static list and async search results. A record's saved foreign key always renders its label even when the related row falls outside modifyOptionsQuery's scope -- but not outside the tenant: a foreign key pointing at another tenant's row renders no label at all.

Authorization. The option list is gated on the related resource's viewAny policy, not the resource being edited. The rows returned belong to the related resource, and the endpoint accepts a search term, so an ungated picker would let anyone able to open the form walk the entire related table a prefix at a time.

If the related model has no registered resource there is no viewAny to consult, so the list fails closed and comes back empty. For genuine lookup tables -- countries, currencies, breeds -- opt out explicitly:

BelongsTo::make('country')->publicOptions(),

CustomField

Renders a custom element supplied by a plugin. See the Plugins section for the full contract.

CustomField::make('mood')->tag('mood-picker')->rules('max:32'),

tag(string) is required. It must match the custom element name registered by your plugin script.

Common modifiers

All fields share these modifiers from the base Field class:

Modifier Effect
required() Adds required validation; removes nullable.
rules(string|array) Appends extra validation rules (accepts anything Laravel's validator accepts).
default(mixed) Sets the value shown on the create form when no record exists.
placeholder(string) Hint text shown inside the input when empty.
helper(string) Short help text rendered below the field.
canSee(Closure) Gates field visibility per request. See the Authorization section.
label(string) Overrides the auto-generated label (headline-cased attribute name by default).

Form layout

Layout containers group fields for presentation. They are declared inside form() alongside regular fields and nest freely inside one another.

Section

A labeled card that groups related fields visually.

Section::make('Identity')->description('Who this horse is on the ranch.')->schema([
    Text::make('name')->required(),
    Select::make('breed')->options([...]),
]),

make(string $label) sets the heading. description(string) adds an optional subtitle line. schema([...]) accepts fields and other containers.

Grid

Arranges its children in a CSS grid.

Grid::make(2)->schema([
    Text::make('name')->required()->rules('max:120'),
    Select::make('breed')->options([...]),
]),

Grid::make(int $columns) sets the column count (default 2). Each child field uses columnSpan(int) to span multiple columns within the grid:

Grid::make(3)->schema([
    Text::make('name')->columnSpan(2),
    Select::make('breed'),
]),

Tabs and Tab

Tabs wraps one or more Tab panes in a tabbed interface.

Tabs::make([
    Tab::make('Care')->schema([
        Markdown::make('notes'),
        DateTime::make('last_vet_visit'),
    ]),
    Tab::make('Assignment')->schema([
        BelongsTo::make('rider')->searchable(),
        Toggle::make('is_saddled'),
    ]),
]),

Tab::make(string $label) creates a single pane. Each pane takes its own schema([...]) of fields and containers.

Validation error indicator. When any field inside a tab fails validation, the tab label shows an error indicator so users can locate the problem without switching to every pane manually.

Flat schemas

Passing a plain list of fields to $form->schema([...]) with no containers produces a single-column stacked layout and is fully supported. Layout containers are purely opt-in. Validation and authorization treat fields identically regardless of whether they live inside a container or at the top level.

Actions

Actions let you run operations on individual records or a selection of records directly from the index table. Row actions appear as buttons on each row; bulk actions appear in a toolbar when one or more rows are selected.

Declaring actions

Override actions() and bulkActions() on your resource class. Each method returns an array of action objects.

use Illuminate\Database\Eloquent\Collection;
use SaddlePHP\Actions\Action;
use SaddlePHP\Actions\BulkAction;

public static function actions(): array
{
    return [
        Action::make('unsaddle')
            ->handle(fn (Horse $horse) => $horse->update(['is_saddled' => false]))
            ->requiresConfirmation('Unsaddle this horse?')
            ->color('accent'),
    ];
}

public static function bulkActions(): array
{
    return [
        BulkAction::make('saddle-up')
            ->label('Saddle up')
            ->handle(fn (Collection $horses) => $horses->each->update(['is_saddled' => true])),
        BulkAction::delete(),
    ];
}

Fluents

Method Description
label(string) Display label for the button. When omitted, the action name is converted to title case automatically.
color(string) Button color token: accent, ink, or muted. Defaults to ink.
requiresConfirmation(?string) Show a confirmation dialog before the action runs. Pass a custom message string or omit to use the default prompt.
authorize(string) Name a policy ability to check per record before the handler runs.
successMessage(string) Flash message shown to the user after successful execution. Defaults to Done..

How execution works

Actions post to an endpoint that sits behind the same middleware stack as the rest of the panel. Records resolve through the same scoped base query used by index, edit, and delete, so tenancy scopes and per-resource query constraints apply automatically. Cross-tenant or otherwise out-of-scope record IDs return 404 before any handler runs.

Every action invocation wraps the handler in a database transaction. For bulk actions this means the operation is all-or-nothing: if the handler throws, the transaction rolls back and no record is partially modified. Bulk requests are capped at 100 records per call; submitting more than 100 IDs is rejected at the validation layer.

When authorize('ability') is set, the panel calls your policy's named ability for each record in scope before running the handler. For bulk actions, every record in the selection must pass; a single failure aborts the entire operation with 403. Declare authorize() on any action that modifies or removes data.

BulkAction::delete()

BulkAction::delete() is a pre-built preset for the common case of bulk deletion. It ships with:

  • Name delete, label Delete, color accent
  • Confirmation prompt Delete the selected records?
  • authorize('delete') wired automatically
  • A handler that calls delete() on every record in the collection

Add it to bulkActions() without any further configuration:

public static function bulkActions(): array
{
    return [
        BulkAction::delete(),
    ];
}

Tables and columns

The table() method on a resource configures the index listing: which columns to show, how they can be sorted or searched, and which filters are available. Columns are declared inside Table::columns([...]).

TextColumn

Renders the raw attribute value as text.

TextColumn::make('name')->sortable()->searchable(),
TextColumn::make('created_at')->date('M j, Y')->sortable(),
Modifier Effect
sortable() Allows the column to be sorted by clicking its header.
searchable() Includes this column in the panel's full-text search.
label(string) Overrides the auto-generated column heading.
date(string $format) Formats a DateTimeInterface attribute with the given format string. The default format when date() is called without an argument is Y-m-d H:i.
formatUsing(Closure) Transforms the resolved value before it is rendered. Available on every column type — see Formatting a cell.

BadgeColumn

Renders a pill badge. Use colors() to map option values to color tokens.

BadgeColumn::make('breed')->colors([
    'quarter'   => 'accent',
    'mustang'   => 'ink',
    'appaloosa' => 'muted',
]),

Available color tokens: accent, ink, muted. Values not present in the map are rendered without a color token.

BooleanColumn

Renders an accent check mark for true and a muted cross for false.

BooleanColumn::make('is_saddled'),

The resolved value is cast to a real bool before being passed to the frontend, so a BooleanColumn on its own only ever renders one of those two marks.

Before 1.5.0, false rendered an em dash. That made the pair read as "yes / unknown" rather than "yes / no", because an em dash means no value everywhere else in the panel -- it is what a StatWidget shows for a null. On a table of integrations, "switched off" and "we have no idea" are different facts and both rendered identically.

The em dash is now reserved for a boolean cell with no value at all. A BooleanColumn never produces one by itself, but formatUsing() can, which is how you say "unmeasured" on a boolean column:

BooleanColumn::make('connected')
    ->formatUsing(fn (mixed $value, Model $record) => $record->checked_at === null ? null : $value),

All three cell states carry a translated aria-label (booleans.yes, booleans.no, booleans.unknown).

CustomColumn

Renders a custom element supplied by a plugin. The column sets value and column DOM properties on the element (read-only; no input event expected). See the Plugins section for details.

CustomColumn::make('mood')->tag('mood-cell'),

Formatting a cell

formatUsing() sits between resolving a cell's value and rendering it. It is available on every column type.

TextColumn::make('spend')
    ->sortable()
    ->formatUsing(fn (mixed $value) => $value === null ? '—' : Number::currency($value)),

TextColumn::make('name')
    ->formatUsing(fn (mixed $value, Model $record) => "{$value} ({$record->breed})"),

The callback receives the value and the record, so it can format from a related field without a second query.

The reason this belongs on the column rather than on a model accessor is sorting. An accessor cannot be ->sortable() — sorting happens in the database and the accessor does not exist there. formatUsing() runs after the value is resolved, so sortable() and searchable() still refer to the real column while the cell renders whatever you want. Without it, a panel that renders a null as an em dash in a StatWidget (whose value() returns a string) has no way to do the same in the table underneath.

Two things worth knowing:

  • It applies wherever a cell's value is resolved, including the CSV export. An export of a formatted column exports the formatted text.
  • On a TextColumn that also calls date(), the callback sees the raw value (a DateTimeInterface), and date() then formats only what is still a date afterwards. Format the date inside the callback if you want both.

Relation columns and eager loading

Dotted names read through a loaded relation:

TextColumn::make('rider.name')->label('Rider'),

For this to work, the relation must already be loaded. Declare $with on the resource to eager-load it on every index query:

public static array $with = ['rider'];

Relation columns are not sortable or searchable in the current release.

Search behavior

The panel applies a LIKE %search% query across all columns marked searchable(), combined with orWhere clauses. The search term arrives via the search query string parameter.

Sort behavior

Clicking a sortable column header toggles between ascending and descending order. The active sort and direction are reflected in the sort and direction query string parameters. When no valid sort is requested, records default to descending primary key order.

Filters

Filters narrow the index listing to a subset of records. They are declared on the table via ->filters([...]) and appear as dropdown controls above the table on the index page.

Declaring filters

public static function table(Table $table): Table
{
    return $table->columns([
        // ...
    ])->filters([
        SelectFilter::make('breed')->options([
            'quarter'   => 'Quarter Horse',
            'mustang'   => 'Mustang',
            'appaloosa' => 'Appaloosa',
        ]),
        BooleanFilter::make('is_saddled'),
    ]);
}

SelectFilter

An exact-match dropdown. The options array defines both the dropdown choices and the allowlist of accepted values. Submitted values not present as keys are silently ignored.

SelectFilter::make('status')->options([
    'active'   => 'Active',
    'inactive' => 'Inactive',
    'pending'  => 'Pending',
]),

On application, the filter adds a WHERE column = value clause to the query.

BooleanFilter

A Yes/No dropdown over a boolean column. Accepts '1' (truthy) and '0' (falsy) as values; anything else is ignored.

BooleanFilter::make('is_saddled'),

Query string shape

Active filters are read from filter[name]=value query string parameters. For example:

/admin/resources/horses?filter[breed]=mustang&filter[is_saddled]=1

Multiple filters can be active at the same time. Each one is applied as an additional WHERE clause, so they combine with AND logic.

Combining with search

Filters and full-text search work together. When both are active, the query applies search conditions first (as an orWhere group across searchable columns) and then adds each active filter on top.

Relation managers

A relation manager renders a table of a record's HasMany children on its view page, with full create, edit, and delete, all scoped to the parent. A ranch manages its horses; an author manages their posts. The related records never leave the parent's relationship, so a child from another parent (or another tenant) can never be read or written through it.

Declaring a relation manager

Create a class that extends RelationManager and names the parent's HasMany method. It carries its own table() and form(), exactly like a resource.

use SaddlePHP\Fields\Text;
use SaddlePHP\Fields\Toggle;
use SaddlePHP\Forms\Form;
use SaddlePHP\RelationManager;
use SaddlePHP\Tables\Columns\BooleanColumn;
use SaddlePHP\Tables\Columns\TextColumn;
use SaddlePHP\Tables\Table;

class HorsesRelationManager extends RelationManager
{
    protected static string $relationship = 'horses'; // Ranch::horses() — a HasMany

    public static ?string $title = 'name';

    public static function table(Table $table): Table
    {
        return $table->columns([
            TextColumn::make('name'),
            BooleanColumn::make('is_saddled'),
        ]);
    }

    public static function form(Form $form): Form
    {
        return $form->schema([
            Text::make('name')->required(),
            Toggle::make('is_saddled'),
        ]);
    }
}

Generate one with the maker command:

php artisan saddle:relation HorsesRelationManager --relationship=horses

When you omit --relationship, it is guessed from the class name (HorsesRelationManager → horses).

Registering it on a resource

Return your relation managers from relations() on the parent resource. They appear on the parent's view page.

class RanchResource extends Resource
{
    public static string $model = Ranch::class;

    public static function relations(): array
    {
        return [HorsesRelationManager::class];
    }

    // form(), table() as usual
}

Authorization

Each relation manager authorizes against the related model's policy, not the parent's. Listing checks viewAny, the New button and store check create, and per-row Edit and Delete check update and delete. With no policy registered for the related model, every ability is denied by default (the same fail-closed default as resources); set saddle.authorization.require_policy to false to opt into fail-open.

Scoping guarantees

Every read and write flows through $parent->{relationship}(). New records are created through the relationship, so the foreign key is stamped by the framework and can never be smuggled in through the payload. Resolving a related record for edit, update, or delete is constrained to the parent, so an ID belonging to a different parent returns 404 before any handler runs. When the parent resource is tenant-scoped, the parent itself resolves through the tenant query, so its children are isolated per tenant automatically.

Scope (v0.9)

Relation managers cover HasMany relationships and render on the view page. Create and edit happen in a slide-over modal; delete asks for confirmation. The table shows the first page of related records; paging through additional pages of related rows lands in a later release.

Soft deletes

Saddle supports Laravel's soft deletes with no configuration. Add the SoftDeletes trait to a model and the panel adapts automatically: deletes become soft deletes, the index gains a trashed filter, and trashed rows can be restored or permanently removed.

Enabling

Use Laravel's trait on the model and make sure the table has a deleted_at column:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Horse extends Model
{
    use SoftDeletes;
}

That is the only step. Saddle detects the trait and turns on the soft-delete behavior for that resource.

The trashed filter

A soft-deletable resource's index gains a Status filter with three options:

  • Active (default) hides trashed records.
  • With trashed shows active and trashed records together.
  • Only trashed shows just the trashed records.

Deleting, restoring, purging

The row Delete control soft-deletes a record (it disappears from the default Active view but is recoverable). Surface trashed records with the Status filter and each one offers:

  • Restore brings the record back into the active set.
  • Delete permanently removes the row from the database for good, behind a confirmation.

Authorization

Restore and force-delete are gated by the standard Laravel policy abilities restore and forceDelete. As with every ability, a resource without a registered policy denies them by default (fail-closed), unless saddle.authorization.require_policy is set to false. Multi-tenancy applies too: a trashed record belonging to another tenant can never be restored or purged.

Dashboard widgets

Dashboard widgets turn the panel's landing page into a live overview. A widget is a small PHP class that computes its data per request; Saddle discovers them automatically and renders them at the top of the dashboard.

Stat widgets

A StatWidget shows a number with a label, an optional description, and an optional inline bar chart, exactly like the cards on the marketing banner.

use Illuminate\Http\Request;
use SaddlePHP\Widgets\StatWidget;
use App\Models\Horse;

class HorseCountWidget extends StatWidget
{
    public static int $sort = 0;

    public function label(): string
    {
        return 'Horses';
    }

    public function value(Request $request): int
    {
        return Horse::count();
    }

    public function description(Request $request): ?string
    {
        return Horse::where('is_saddled', true)->count().' saddled';
    }

    // Optional inline mini bar chart on the card.
    public function chart(Request $request): array
    {
        return [3, 5, 2, 8, 6, 9];
    }
}

Chart widgets

A ChartWidget renders a full-width bar chart from an ordered label => value map.

use Illuminate\Http\Request;
use SaddlePHP\Widgets\ChartWidget;
use App\Models\Horse;

class HorsesByBreedWidget extends ChartWidget
{
    public static int $sort = 1;

    public function heading(): string
    {
        return 'Horses by breed';
    }

    public function data(Request $request): array
    {
        return Horse::query()
            ->selectRaw('breed, count(*) as c')
            ->groupBy('breed')
            ->pluck('c', 'breed')
            ->all();
    }
}

Discovery, ordering, and visibility

Place widget classes in app/Saddle/Widgets/ and Saddle discovers them automatically, the same way it discovers resources. Widgets render in ascending $sort order. To register widgets explicitly instead of by discovery, call Saddle::registerWidgets([...]) from a service provider. The discovery path is configurable via config('saddle.widgets').

Authorization

Declare the resource a widget summarises, and it is gated by that resource's viewAny policy:

class HorseCountWidget extends StatWidget
{
    public static ?string $resource = HorseResource::class;
}

An aggregate discloses more than it looks like it does. A bare count on a shared dashboard tells a competitor tenant your customer count, your order volume, and — watched over time — your growth rate.

A widget that declares no resource cannot be authorized by anything, so it is hidden. You have three ways to show one deliberately:

  • point it at a resource with $resource (recommended),
  • override canSee(Request $request): bool to return true for a genuinely public tile,
  • or set saddle.authorization.require_widget_resource to false to restore the old fail-open behaviour panel-wide.

canSee() still overrides everything, so any existing gate you have written keeps working.

Saddle tells you when it hides one. A missing tile with no explanation is a confusing absence rather than a one-line fix, so a widget hidden for this reason 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. Any of the three fixes above makes the widget authorizable, and an authorizable widget says nothing — including one whose policy simply denies the current user, which is a decision rather than a misconfiguration.

Tenancy

Widgets query models directly, so Resource::query()'s tenant scoping never applies to them. Wrap the query in scopeToTenant() and it is confined to the bound tenant using the relation declared on the widget's $resource:

public function value(Request $request): int
{
    return $this->scopeToTenant(Horse::query())->count();
}

It is a no-op when tenancy is off, when no tenant is bound, or when the declared resource is global by design, so it is safe to leave in place either way. Pass a relation name explicitly (scopeToTenant($query, 'ranch')) when the widget's model is scoped differently from its resource.

Forgetting this is quiet rather than loud: the tile simply reports the global figure to every tenant.

One widget that throws while building is skipped (and reported) rather than breaking the whole dashboard.

Notifications

Saddle surfaces Laravel's database notifications as a bell in the panel sidebar, with an unread badge and one-click mark-as-read. Your application triggers the notifications; Saddle displays them.

Setup

Add Laravel's Notifiable trait to your authenticatable user and run your migrations, Saddle ships the notifications table (guarded, so it is harmless if you already created one with php artisan notifications:table):

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;
}
php artisan migrate

Sending notifications

Send notifications from anywhere in your app over the database channel. A notification whose toArray returns a message (and optionally a url) renders cleanly in the bell:

use Illuminate\Notifications\Notification;

class HorseEscaped extends Notification
{
    public function via(object $notifiable): array
    {
        return ['database'];
    }

    public function toArray(object $notifiable): array
    {
        return [
            'message' => 'Cisco has escaped the corral!',
            'url' => '/admin/resources/horses',
        ];
    }
}
$user->notify(new HorseEscaped);

In the panel

The bell shows the unread count and the ten most recent notifications. Clicking a notification marks it read and, if it carries a url, follows it. Mark all read clears the badge. The unread count and recent items are shared with every page, so the bell stays current as you navigate.

Theming and dark mode

The panel is built on CSS custom-property design tokens, so theming is a matter of overriding a handful of variables. It ships with light and dark palettes and a per-user theme toggle.

Dark mode

A Light / Dark / System toggle sits in the sidebar. The choice persists in the browser (localStorage) and is applied by a tiny inline script before the page paints, so there is no flash of the wrong theme. Dark mode adds a dark class to the <html> element, which swaps the dark palette in. No configuration is required.

Content-Security-Policy

That pre-paint script is the panel shell's only inline <script>, and a strict CSP refuses it: every panel page logs a violation and the theme class never applies before paint, so dark-mode users see a flash of the light theme on every navigation.

Saddle cannot guess how your application produces a nonce, so hand it a callback from a service provider. It is invoked once per shell render, so a per-request nonce stays per-request:

use SaddlePHP\Facades\Saddle;

public function boot(): void
{
    Saddle::resolveNonceUsing(fn () => app('csp.nonce'));

    // Or, with Laravel's Vite nonce:
    // Saddle::resolveNonceUsing(fn () => Vite::cspNonce());
}

Register nothing and the shell renders exactly as it always has. If the callback throws, or returns anything other than a non-empty string, the attribute is omitted rather than the page failing — a misconfigured nonce provider costs you the pre-paint theme class, not the panel.

Theme tokens

Override panel colors with the saddle.brand.theme config map. Keys are token names; values are validated CSS colors (a bare hex, or a single rgb()/hsl()/oklch()).

// config/saddle.php
'brand' => [
    'name' => 'Acme',
    'accent' => '#2563eb',
    'theme' => [
        'ink' => '#0f172a',
        'surface' => '#f8fafc',
    ],
],

Available tokens: bg, surface, surface-2, ink, ink-2, ink-3, line, line-2, accent. Unknown keys and malformed colors are ignored. Plugins can add tokens to this allowlist with Saddle::registerThemeTokens(...) (see the plugins guide).

Custom CSS

The tokens are plain CSS variables (--color-bg, --color-ink, --color-accent, …), so you can also override them from your own stylesheet, including under .dark for a custom dark palette:

.dark {
    --color-bg: #0b1020;
    --color-accent: #60a5fa;
}

Localization

The panel's interface text is translatable. Strings run through vue-i18n on the frontend, sourced from Laravel language files, so you localize the panel the same way you localize the rest of your app.

How it works

The panel follows your application's active locale (app()->getLocale()). On each request it shares the resolved translation strings with the frontend, where vue-i18n renders them. Because the messages come from server-side lang files, you can add or override translations without rebuilding the panel's assets.

Overriding and adding locales

Publish the language files:

php artisan vendor:publish --tag=saddle-lang

This copies panel.php into lang/vendor/saddle/en/. Edit it to change the English strings, or add a locale by creating lang/vendor/saddle/{locale}/panel.php (for example lang/vendor/saddle/fr/panel.php). Set your app's locale and the panel follows.

What ships

Saddle ships English. The keys cover the panel's own chrome (buttons, row controls, empty states, confirmations, notifications, and flash messages). Your resource labels, field labels, and data are yours to translate through your application as usual.

Testing helpers

Saddle ships a testing trait so you can assert your panel is wired the way you expect, without driving the browser.

Using the trait

Add InteractsWithSaddle to a test class (PHPUnit or Pest):

use SaddlePHP\Testing\InteractsWithSaddle;

uses(InteractsWithSaddle::class); // Pest

it('exposes the horse panel', function () {
    $this->assertResourceRegistered('horses');
    $this->assertResourceHasField(HorseResource::class, 'name');
    $this->assertResourceHasColumn(HorseResource::class, 'breed');
});

Helpers

Method Description
assertResourceRegistered(string $uriKey) The panel has a resource for that URI key.
assertResourceHasField(string $resource, string $name) The resource's form contains the field (searched through sections, grids, and tabs).
assertResourceMissingField(string $resource, string $name) The inverse, for negative tests.
assertResourceHasColumn(string $resource, string $name) The resource's table contains the column.
saddleForm(string $resource): array The serialized form schema, for custom assertions.
saddleTable(string $resource): array The serialized column list, for custom assertions.

These introspect your resource definitions directly, so they are fast and need no HTTP round-trip. For end-to-end checks, drive the panel's routes with Laravel's HTTP and Inertia testing helpers as usual.

Serializing a form or table by hand

If you build a Form or Table in a test rather than going through a resource, serialize it with toArray() — the same name the leaves use:

$fields = Form::make()->schema([Text::make('name')])->toArray($record);
$columns = Table::make()->columns([TextColumn::make('name')])->toArray($request);

toInertia() is the original name and still works; since 1.5.0 toArray() is an alias for it, so Field, Column, Form and Table all answer to the same method. Both go through the same canSee() gates — the alias is not a way to serialize a hidden field or column.

Authorization

Saddle consumes standard Laravel policies. Register a policy for a model and the panel enforces it throughout: the index listing, the view page, create and edit forms, row actions, relation managers, and the relation options endpoint. Your role system stays completely in your application code.

No-policy default

Saddle is fail-closed by default: when no policy is registered for a model, every ability is denied (403). Register a policy for each resource's model to grant access, so a forgotten policy can never silently expose data. To opt into the old fail-open convention (a resource without a policy allows every authenticated user), set saddle.authorization.require_policy to false in config/saddle.php. Only do that on panels whose guard is exclusively administrators.

Because viewAny also drives the sidebar and global search, a panel with no policies registered while fail-closed shows an empty sidebar and search returns nothing (every resource is hidden), with no error. If your panel looks empty after registering resources, register policies for their models or set require_policy to false.

Policy abilities

Ability Where it is checked
viewAny Resource index page; sidebar visibility (resources whose viewAny returns false are hidden from the nav); relation manager listing
view Record view page; per-row View link
create Create form; store action; relation manager New button and store; relation options endpoint
update Edit form; update action; per-row Edit link; relation manager row edit; relation options endpoint (checked against a fresh model instance when no specific record is in scope)
delete Destroy action; per-row Delete button; relation manager row delete
restore Restore control on trashed rows; restore endpoint (soft-deletable resources)
forceDelete Delete-permanently control on trashed rows; force-delete endpoint (soft-deletable resources)

Relation managers check these abilities against the related model's policy. See the relations section for details.

Using any role system

Any layer that backs your policies works unchanged. The panel calls $user->can($ability, $modelOrClass) through Laravel's Gate, so whether you use Spatie Permissions, a homegrown is_admin flag, or anything else, the integration is transparent.

Field visibility with canSee

Individual fields can be gated per request using the canSee modifier. Hidden fields are excluded entirely: they are stripped from the form payload sent to the frontend, contribute no validation rules, are never written on save, and their relation options endpoint returns a 404.

use Illuminate\Http\Request;

Textarea::make('notes')->rows(3)
    ->canSee(fn (Request $request) => (bool) $request->user()?->is_admin),

The callback receives the current Request and must return a real boolean. Keep the closure cheap and idempotent because it may be called several times per request (once per call to visibleFields()). Avoid database queries inside the closure; prefer pre-loaded authorisation decisions.

Return a real boolean. Using Gate::inspect(...) is a common mistake: its Response object is always truthy and will never hide the field. Use Gate::allows('ability', $model) instead.

Column visibility with canSee

Gating a field does not gate the column of the same name. The form and the table are separate surfaces, and a field's gate never reaches the index cells, the CSV export, or the sort and search lists. Columns take the same modifier:

use Illuminate\Http\Request;

TextColumn::make('notes')->sortable()->searchable()
    ->canSee(fn (Request $request) => (bool) $request->user()?->is_admin),

A hidden column is dropped from four places at once: the index cells payload, the columns payload, the CSV export, and the sortable and searchable lists.

Dropping it from the last two matters as much as hiding the value. A column that is merely un-rendered is still an oracle: ?sort=notes leaks the ordering of a hidden attribute, and ?search= leaks which rows match a guess, one guess at a time. Both are closed by the same gate.

If you gate a field, check whether a column of the same name needs the same rule.

Plugins

A plugin is a regular Composer package. Its service provider registers resources, scripts, and stylesheets through the Saddle facade, and Laravel's package auto-discovery boots it alongside your application with no manual wiring.

Plugin anatomy

// In your plugin's service provider:

public function boot(): void
{
    Saddle::register([MoodBoardResource::class]);
    Saddle::script('/vendor/mood-board/field.js');
    Saddle::style('/vendor/mood-board/field.css');
}
  • Saddle::register(array) adds resource classes to the panel's resource list. Plugin resources are indistinguishable from application resources once registered.
  • Saddle::script(string) queues a script URL to be loaded on every panel page after the core bundle.
  • Saddle::style(string) queues a stylesheet URL in the same way.

Extending the panel shell

Three hooks let a plugin extend the panel itself. Call them from your service provider's boot():

use Illuminate\Http\Request;

// Contribute extra data to the shared `saddle` Inertia prop (read it from your
// plugin's own frontend). Core keys always win, so a plugin cannot clobber the
// documented shape.
Saddle::sharing(fn (Request $request) => [
    'moodBoard' => ['recentColors' => MoodColor::recent()],
]);

// Transform the sidebar navigation: reorder or filter groups, or append custom
// links. The callback receives the computed nav array and the request.
Saddle::navUsing(fn (array $nav, Request $request) => [...$nav, [
    'group' => 'Plugins',
    'items' => [['label' => 'Mood Docs', 'uriKey' => 'mood-docs', 'icon' => null, 'active' => false]],
]]);

// Allow extra theme tokens so your styles or custom elements can read them as
// CSS custom properties (injected as `--color-<token>` when set in
// `saddle.brand.theme`). Token names must start with a letter and contain only
// lowercase letters, digits, and hyphens (a-z, 0-9, -); other names are ignored.
Saddle::registerThemeTokens('mood-accent');

Publishing assets

Compile your frontend assets (custom elements, styles) and publish them to public/vendor/{plugin}/ using Laravel's standard publishes mechanism in your service provider:

public function boot(): void
{
    $this->publishes([
        __DIR__.'/../dist/field.js'  => public_path('vendor/mood-board/field.js'),
        __DIR__.'/../dist/field.css' => public_path('vendor/mood-board/field.css'),
    ]);

    Saddle::script('/vendor/mood-board/field.js');
    Saddle::style('/vendor/mood-board/field.css');
}

Users install the assets with php artisan vendor:publish --tag=mood-board-assets.

Registering Vue components

The panel is a Vue app, so the most direct way to ship a renderer is a Vue SFC. Register it against a component key and the panel's dispatch tables will use it:

import ColorPicker from './ColorPicker.vue';

window.Saddle.registerField('color-picker-field', ColorPicker);
window.Saddle.registerLayout('accordion', AccordionLayout);
window.Saddle.registerWidget('gauge-widget', GaugeWidget);

Plugin scripts registered with Saddle::script() load on every panel page and run before the app mounts, so registering at script top level is enough.

The registry is consulted before the built-in maps, so you can add a new key or override a shipped one.

On the PHP side, point a field at your key:

Text::make('brand_color')->component('color-picker-field'),

Field::component() reads the key with no argument and sets it with one. Widget::component() reads a widget's key, which is what registerWidget() matches on — so Widget is now genuinely open to subclassing beyond StatWidget and ChartWidget.

A registered field component receives the same props as a built-in one: field (the serialized field) and modelValue, and it should emit update:modelValue.

Custom fields and columns

If you would rather not ship Vue — for a renderer you want to reuse outside the panel, or to avoid a build step — plugins can ship field and column renderers as custom elements instead. On the PHP side:

CustomField::make('mood')->tag('mood-picker')->rules('max:32'),
CustomColumn::make('mood')->tag('mood-cell'),

The panel fulfils the following contract:

  • CustomField: sets the element's value and field DOM properties; listens for a saddle:input CustomEvent whose detail is the new value.
  • CustomColumn: sets value and column DOM properties (read-only; no input event expected).

Custom element implementation

A complete vanilla custom element that correctly handles the field contract, including early value assignment (the panel may set the value property before the element connects to the DOM):

class MoodPicker extends HTMLElement {
    connectedCallback() {
        // The panel may set the value property before the element is
        // connected, so seed the input from whatever arrived early.
        this._input = document.createElement('input');
        this._input.value = this._value ?? '';
        this._input.addEventListener('input', () => {
            this.dispatchEvent(new CustomEvent('saddle:input', {
                bubbles: true,
                detail: this._input.value.toUpperCase(),
            }));
        });
        this.appendChild(this._input);
    }

    set value(v) {
        this._value = v ?? '';
        if (this._input) this._input.value = this._value;
    }

    get value() { return this._input ? this._input.value : (this._value ?? ''); }
}

customElements.define('mood-picker', MoodPicker);

Define elements at the top level of your script. The browser upgrades any matching elements the panel has already rendered as soon as customElements.define runs, so load order never matters.

Framework-agnostic

The contract is framework-agnostic. Anything that compiles to a standard custom element works: Vue's defineCustomElement, Lit, React wrappers, Svelte custom elements. Plugin authors are not tied to the panel's internals.

Multi-tenancy

Saddle supports opt-in, URL-scoped multi-tenancy. A tenant is any Eloquent model. When enabled, the panel mounts under /admin/{tenant} and every data path is scoped server-side. Tenancy off means byte-identical v0.5 behaviour.

Enabling tenancy

Set tenancy.model in config/saddle.php to your tenant model class:

'tenancy' => [
    'model' => App\Models\Ranch::class, // null disables (default)
    'relationship' => 'users',          // relation that lists the tenant's members
],

relationship is the Eloquent relation on the tenant model that returns its members. Saddle calls $tenant->{relationship}()->whereKey($user)->exists() to enforce membership.

Declaring tenant scope on a resource

Add $tenant to each resource you want scoped. Its value is the Eloquent BelongsTo relation name on the record model pointing back to the tenant:

class HorseResource extends Resource
{
    public static ?string $tenant = 'ranch';
}

Resources without $tenant (shared lookup tables, global reference data) remain unscoped by design. They are accessible from any tenant context.

URL shape and access control

When tenancy is enabled, every panel URL includes the tenant's route key:

/admin/{tenant}/resources/horses
/admin/{tenant}/resources/horses/create
/admin/{tenant}/resources/horses/42/edit

Saddle enforces two guards before any controller runs:

  • Unknown tenant key returns 404. The segment is resolved by route-key lookup on the configured model.
  • Non-member returns 403. Membership is checked via the configured relationship relation.

Scope guarantees

Every data path applies the tenant scope server-side:

  • Index listings run through a whereBelongsTo constraint on the declared $tenant relation.
  • Search and filters apply on top of the same scoped base query, so search results and filter options never leak cross-tenant rows.
  • Record lookups for edit, update, and destroy all resolve through the scoped query. A cross-tenant ID returns 404 before any policy runs.
  • Stores stamp the current tenant on new records after filling form values. Any tenant foreign key submitted by the client is overwritten, so the scope cannot be bypassed by crafting the request payload.
  • Relation option lists apply the same whereBelongsTo scope automatically when the related model's registered resource declares $tenant. The modifyOptionsQuery hook composes on top of this built-in scope.

Tenant switcher

When the authenticated user belongs to more than one tenant, the panel sidebar shows a select listing all their memberships. Selecting a different tenant navigates to the same panel path under the new tenant key.

Caveats

Do not expose the $tenant relation as a form field on a scoped resource. Saddle stamps the tenant server-side on store, but placing a BelongsTo::make('ranch') field on an edit form would let a submitted value re-point the record to a different tenant on update. Keep the tenant relationship out of the form schema entirely.

A saved relation label still renders on the edit form even when the related row falls outside the current scope. BelongsTo resolves the current selection with an unscoped query so that a record's label never disappears after a scope change (for example, when a rider is later moved out of the current ranch). Only the option list for new selections is filtered by scope.

Changing the tenancy config requires php artisan route:clear, because the {tenant} URL prefix is decided at boot time from the config. Long-running application servers must ensure that request state is fully reset between requests. The bound tenant lives on the Saddle singleton, which is resolved fresh per request under the default container singleton lifetime, so standard PHP-FPM and Octane request isolation both behave correctly.

Tenancy extras

Two optional hooks extend multi-tenancy: an access gate (for billing/subscription checks) and a tenant registration flow.

Access gate

Set saddle.tenancy.gate to an invokable class. It runs after a tenant is resolved and membership is confirmed, receiving the request and the tenant. Return a Response (such as a redirect) to deny access, or null to allow it. This is where a billing or subscription check lives.

// config/saddle.php
'tenancy' => [
    'model' => App\Models\Team::class,
    'gate' => App\Saddle\RequireSubscription::class,
],
class RequireSubscription
{
    public function __invoke($request, $tenant)
    {
        return $tenant->subscribed() ? null : redirect('/billing');
    }
}

Tenant registration

Set saddle.tenancy.registration to a class implementing SaddlePHP\Tenancy\RegistersTenants. Saddle mounts a /{path}/register page and shows a + New workspace link in the tenant switcher. Your handler declares the form fields and creates the tenant.

use SaddlePHP\Tenancy\RegistersTenants;

class TeamRegistration implements RegistersTenants
{
    public function fields(): array
    {
        return [Text::make('name')->required()];
    }

    public function register(array $validated, $user): Model
    {
        $team = Team::create($validated);
        $team->users()->attach($user);

        return $team; // Saddle redirects to the new tenant's panel
    }
}

Registration is off until you configure a handler; the panel renders the fields, validates against their rules, calls your register(), and redirects to the new tenant.

Import and export

Every resource can export its records to CSV and import records from a CSV, straight from the index.

Export

The Export button downloads the records currently shown, honoring the active search and filters, scoped to the current tenant. The CSV columns are the resource's table columns, and the header row uses their labels. Export is gated by the viewAny policy ability. It streams the result, so large tables export without exhausting memory.

// The export reuses your table definition — no extra config.
public static function table(Table $table): Table
{
    return $table->columns([
        TextColumn::make('name'),
        TextColumn::make('breed'),
    ]);
}

Import

The Import button opens an upload page. Upload a CSV whose header names match your field names (case-insensitive); each data row is validated against the resource's form rules and, on success, created like a normal store (tenant-stamped when the resource is tenant-scoped). Rows that fail validation are skipped, and the result flash reports how many were created and skipped. Import is gated by the create ability.

name,breed
Cisco,quarter
Scout,mustang

Scope (v1.0)

Export and import are synchronous and CSV-only. Import is create-only (it does not update existing records) and maps columns by header name (there is no mapping UI). For very large or recurring jobs, run your own queued command using the same resource definitions.

Configuration

Saddle ships with a single config file published by saddle:install. All keys have sensible defaults so you can ignore this file until you need to change something.

Config keys

saddle:install publishes config/saddle.php. Available keys:

Key Default Description
path 'admin' URL prefix for the panel. The value 'admin' makes the panel available at /admin.
middleware ['web', 'auth'] Middleware stack applied to all panel routes. Add your own guards or throttle rules here.
resources.path app_path('Saddle') Filesystem path scanned for resource classes at boot.
resources.namespace 'App\\Saddle' PHP namespace corresponding to resources.path.
resources.discovery true Scan resources.path at boot. Discovered resources are merged with anything passed to Saddle::register(). Set to false to skip scanning and curate the list by hand.
per_page 25 Default number of rows shown on index tables.
global_search.per_resource 5 Maximum results returned per resource by global search.
widgets.path app_path('Saddle/Widgets') Filesystem path scanned for widget classes.
widgets.namespace 'App\\Saddle\\Widgets' PHP namespace corresponding to widgets.path.
widgets.discovery true As resources.discovery, for widgets.
brand.name 'Saddle' Panel name shown in the sidebar and browser tab.
brand.accent '#d9501f' Accent colour used for buttons and active states.
brand.greeting null Dashboard headline. May contain :name. null keeps Saddle's own wording.
brand.subgreeting null The line under the headline. null keeps Saddle's own wording.
authorization.require_policy true Deny when a model has no registered policy. Setting this to false makes the panel fail open.
tenancy.model null Tenant model class. Tenancy is off entirely while this is null.
tenancy.foreign_key 'tenant_id' Column used to scope tenant-owned records.
tenancy.gate null Optional closure name gating tenant access.
tenancy.registration null Handler enabling self-service tenant registration.
uploads.disk 'public' Default filesystem disk used by FileUpload fields when no per-field disk() is set. Note that public means any panel user can host content on your own origin; see below.
uploads.directory 'saddle' Default upload directory within the disk when no per-field directory() is set.
uploads.max_size 10240 Ceiling in kilobytes for any FileUpload that does not set its own maxSize().
uploads.allowed_extensions [] Accepted types for a FileUpload with no image() or acceptedTypes() of its own. Empty uses the framework default set.
import.max_rows 5000 Rows accepted by a CSV import before the request is rejected with a 422.
export.max_rows 50000 Rows written by a CSV export. 0 removes the cap.

Dashboard greeting

The dashboard opens with two lines. Both are configurable, and both default to null, which keeps the wording Saddle has always shipped ("Howdy, Matthew." / "Pick a resource and get ridin'.").

'brand' => [
    'greeting' => 'Welcome, :name.',
    'subgreeting' => 'Spend, by channel, for the last 30 days.',
],

:name is replaced with the signed-in user's name. With nobody signed in the placeholder is removed along with the separator in front of it, so 'Welcome, :name.' reads Welcome. rather than Welcome, ..

One rough edge worth knowing: a separator after a leading placeholder is kept, so ':name — spend' degrades to '— spend'. Removing it would mangle 'Howdy :name,', which is the far commoner shape. Put the placeholder last if that matters for your wording.

Upload safety

acceptedTypes() and image() are security controls, not UX hints.

Laravel names a stored file from its detected content type, not the name the uploader supplied. A file called notes.txt whose contents are HTML is stored as <random>.html, and on the default public disk it is then served from your application's own origin. An admin who opens the record and clicks the attachment is running attacker-controlled script with their own session.

Since 1.3.0 a FileUpload with no constraint of its own falls back to uploads.allowed_extensions, whose default set excludes html, svg, xml, js and the php family. Declaring acceptedTypes() per field is still better, and storing on a private disk with signed URLs is better again.

Artisan commands

Command Description
saddle:install Publishes the config file, publishes panel assets to public/vendor/saddle/, and creates app/Saddle/. Offers to add saddle:upgrade to composer.json's post-update-cmd.
saddle:upgrade Re-publishes the panel assets. Run after every package update. If you accepted the post-update-cmd prompt, Composer runs this for you automatically. Since 1.5.0 the bundle also carries Laravel's laravel-assets tag, so a stock composer.json republishes it on composer update regardless.
saddle:resource NameResource --model=Name Scaffolds a new resource class in app/Saddle/. The --model option is optional; when omitted the model name is inferred from the resource class name.