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-laravel3.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:
- Publishes
config/saddle.phpwith sensible defaults. - Publishes the compiled panel assets to
public/vendor/saddle/. - 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:
HorseResourceproduces uriKeyhorsesand labelHorses.TrailRideResourceproduces uriKeytrail-ridesand labelTrail 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:
titleAttribute('attribute')if you have set one on the field.- The related model's registered resource
$title. - 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, labelDelete, coloraccent - 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
TextColumnthat also callsdate(), the callback sees the raw value (aDateTimeInterface), anddate()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.
Global search
Global search lives at the top of the panel sidebar. It searches every resource at once and links each result straight to its view page, so you can jump to any record without first navigating to its list.
What gets searched
A resource is included in global search when its table declares searchable() columns:
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
]);
}
The same columns power the index search box and global search. A resource with no searchable columns is simply skipped.
Opting out
A resource is searchable by default. Set $globalSearch to false to keep it out of global search while leaving its index search intact:
class AuditLogResource extends Resource
{
public static bool $globalSearch = false;
// ...
}
Authorization and tenancy
Global search only includes resources the current user may viewAny, and every query runs through the resource's scoped base query. Policy gates and multi-tenancy apply automatically, so a user never sees a record they could not reach through the resource itself.
Result limits
Each resource contributes at most saddle.global_search.per_resource results (default 5). Raise or lower it in config/saddle.php:
'global_search' => [
'per_resource' => 5,
],
One resource that errors while searching is skipped (and reported) rather than breaking the whole search, the same way a broken resource never takes down the sidebar.
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): boolto returntruefor a genuinely public tile, - or set
saddle.authorization.require_widget_resourcetofalseto 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.
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
valueandfieldDOM properties; listens for asaddle:inputCustomEvent whosedetailis the new value. - CustomColumn: sets
valueandcolumnDOM 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
relationshiprelation.
Scope guarantees
Every data path applies the tenant scope server-side:
- Index listings run through a
whereBelongsToconstraint on the declared$tenantrelation. - 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
whereBelongsToscope automatically when the related model's registered resource declares$tenant. ThemodifyOptionsQueryhook 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. |
