GAIT Mobile
CommunityServes your existing Filament panel as a policy-aware mobile admin API, reading each resource's own form, infolist, relations and dashboard widgets so a native app can list, view, create, edit and delete records without redeclaring anything.
Author:
GAIT
Package health
BetaAutomated checks of this plugin's Composer package
15 checks
- Failed: GitHub Actions pinned to SHA — View details on Plumb
- Skipped: GitLab CI includes pinned to SHA
- Passed: Open security advisories
- Passed: Dependabot PR responsiveness — No open Dependabot PRs.
- Skipped: Renovate MR responsiveness
- Failed: Dependabot or Renovate configured — No dependency updater configuration found. View details on Plumb
- Skipped: Dependency update cooldown configured
- Failed: Provides a security policy — View details on Plumb
- Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
- Passed: Commit and release recency — Active: last commit 0 days ago; last release 1 days ago.
-
Passed:
composer.lock not committed by library
—
composer.lockis absent from the released dist archive. - Passed: Dist archive is lean
-
Warning:
Current Laravel version supported
—
Package does not co-install with current Laravel
13.0; the newest co-installable line is12.0, which receives security patches only (until 2027-02-24). -
Passed:
Current PHP version supported
—
Constraint
^8.4supports current PHP8.5. - Skipped: Current Symfony version supported
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
Documentation
- Install
- Read this first: two things that will bite you
- Opt a resource in
- What ships
- Supported form inputs
- Actions
- Upload
- Medialibrary
- Repeater
- Radio
- Toggle buttons
- Slider
- Tags
- Spatie tags
- Sluggable
- Translatable
- Key/value
- Colour
- Time and date bounds
- Relations
- Rich text
- Dashboard
- Locale and direction
- Schema caching
- Authorization
- php artisan filament-mobile:doctor
- Configuration
- Known limitations in this release
- Testing

Serves an existing Laravel Filament panel as a mobile admin JSON contract. It
reads your resources' existing form() and infolist() — you do not redeclare
them — and exposes only the resources you explicitly opt in.
| Endpoint | Returns |
|---|---|
GET /api/mobile-panel/schema |
Every opted-in resource the user may see: labels, permissions, card, sorts, form and infolist |
GET /api/mobile-panel/{resource} |
A paginated list of card payloads |
GET /api/mobile-panel/{resource}/{record} |
One record, widened to the form's and infolist's fields, plus per-record permissions |
GET /api/mobile-panel/{resource}/{record}/relations/{relation} |
One relation manager's child rows, same envelope as the list |
POST /api/mobile-panel/{resource}/{record}/relations/{relation} |
Create a child row through the relationship, validated from the child resource's own form |
PUT /api/mobile-panel/{resource}/{record}/relations/{relation}/{child} |
Update a child row, same validation |
DELETE /api/mobile-panel/{resource}/{record}/relations/{relation}/{child} |
Delete a child row — 200 with the deleted row, deliberately not 204 |
POST /api/mobile-panel/{resource} |
Create, validated from the resource's own schema |
PUT /api/mobile-panel/{resource}/{record} |
Update, same validation |
DELETE /api/mobile-panel/{resource}/{record} |
Delete, gated on the record's own policy |
POST /api/mobile-panel/{resource}/state |
Re-evaluate the schema against submitted values, for reactive forms |
GET /api/mobile-panel/dashboard |
The panel's opted-in dashboard widgets, values computed live |
Every write is authorised through the panel's own policies, and its validation rules are extracted from the same schema the read path publishes — so the rules a client is shown and the rules the server enforces cannot drift apart.

And this is what the panel becomes — the client's stock screens, driven entirely by what this package publishes:

#Install
Requires PHP 8.4+, Laravel 12 and Filament 4 or 5. The PHP floor is this
package's own — Filament itself allows 8.2 — and it is deliberately the version
this package is developed on, because a feature above the floor is a parse error
rather than a degradation and the old ^8.2 promise had already been broken
without anyone noticing. On PHP 8.2 or 8.3, pin ^0.6.1.
composer require gait/filament-mobile
php artisan vendor:publish --tag=filament-mobile-config
Then apply the two items below, and opt at least one resource in.
#Read this first: two things that will bite you
Both produce a working-looking HTTP 200 with an empty or wrong payload, not an error. Neither is obvious from a stack trace, because there isn't one.
#1. The routes are not in a middleware group, so session auth cannot work
The package registers its routes with loadRoutesFrom() and applies only
auth (or auth:{guard}). They are not in your web or api group, so
no session middleware runs. Laravel's default guard is a session guard, so
out of the box $request->user() is always null and every request is a 401.
Use a token guard. For Sanctum:
// config/auth.php
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'sanctum' => ['driver' => 'sanctum', 'provider' => 'users'], // add this
],
// config/filament-mobile.php
'guard' => 'sanctum',
#2. If you use filament-shield or spatie/laravel-permission, pin $guard_name
This is the one that costs a day. Laravel's auth:sanctum middleware calls
Auth::shouldUse('sanctum'), which rewrites auth.defaults.guard for the
rest of the request. Spatie's Guard::getDefaultName() then resolves your
user's guard to sanctum and looks for view_any_* permissions under that
guard. Every permission row your admin panel created is guard_name = 'web',
so nothing matches, every policy denies, and:
// GET /api/mobile-panel/schema, as a super admin
{ "version": 1, "panel": {...}, "resources": [] }
That response is byte-identical to what a genuinely unauthorized user gets. There is no error, no warning, and no way to tell the correct-secure answer from the completely-broken one by looking at it.
Fix it on the model:
class User extends Authenticatable
{
use HasRoles;
// Roles and permissions are stored with guard_name = 'web'. Pin the
// lookup so a token-authenticated API resolves the same permissions the
// session-authenticated web panel does.
protected $guard_name = 'web';
}
The root cause is in Laravel and Spatie, not this package — but the package's only viable configuration walks you straight into it, so it is documented here rather than left to be discovered.
If /schema returns "resources": [] for a user you know is an admin, this
is almost certainly why. Confirm with php artisan filament-mobile:doctor.
#Opt a resource in
A Filament resource is invisible to the mobile API until it declares a static
mobile(). That is the safety property: pointing this package at a 200-resource
admin exposes nothing until you say so, one resource at a time.
use Gait\FilamentMobile\MobileCard;
use Gait\FilamentMobile\MobileResource;
class CompanyResource extends Resource
{
public static function mobile(): MobileResource
{
return MobileResource::make()
->card(fn (MobileCard $card): MobileCard => $card
->title('company_name')
->subtitle('user.name') // dotted: eager-loaded automatically
->badge('status', ['pending' => 'warning', 'approved' => 'success'])
->meta('city')
->meta('created_at'))
->searchable(['company_name', 'city'])
->sorts([
'company_name' => __('Name'),
'created_at' => __('Created'),
])
->defaultSort('created_at', 'desc');
}
}
You do not declare the form or the infolist. They are read from the
resource's existing form() and infolist() methods.
#MobileCard
| Method | Notes |
|---|---|
title(string $field) |
|
subtitle(string $field) |
|
leadingImage(string $field, ?string $fallback = null) |
Must be a real attribute or accessor — see the limitation below |
badge(string $field, array $colors = []) |
$colors maps a value to a semantic colour name |
meta(string $field, ?string $format = null) |
Repeatable |
Every dotted path is eager-loaded automatically. subtitle('user.name')
adds ->with('user') to the list query and nests as {"user":{"name":…}}.
This is the entire N+1 defence and it needs no declaration.
The card's fields are also the serialisation whitelist. A column no declared screen references never reaches the phone, so adding a secret column to a table cannot leak it. The detail endpoint additionally serialises the fields the infolist names.
#MobileResource
| Method | Notes |
|---|---|
card(callable $configure) |
|
searchable(array $columns) |
Plain columns only — a dotted path would need a join and is not supported |
sorts(array $labels) |
['column' => 'Label']; the key is spent on the database |
defaultSort(string $key, string $direction = 'asc') |
Must be one of the declared sorts() keys |
relationSearchable(string $relation, array $columns) |
Per-relation searchable() — same semantics, plain columns only |
relationSorts(string $relation, array $labels) |
Per-relation sorts() |
relationDefaultSort(string $relation, string $key, string $direction = 'asc') |
Must be one of the declared relationSorts() keys |
#What ships
| Feature | What it gives you |
|---|---|
| Resources and cards | Opt in per resource; the card is declared, the form and infolist are read from the resource |
| Actions | The panel's own record actions, published per record with their authorization already applied |
| Upload | Single- and multi-file FileUpload / SpatieMediaLibraryFileUpload, with the field's own accept and size rules enforced per file |
| Medialibrary | spatie/laravel-medialibrary fields and entries — media UUIDs plus a full-metadata sibling, wholesale-replaced on save |
| Repeater | JSON-column repeaters, validated per row |
| Radio | Real radio buttons, sharing Select's own options |
| Toggle buttons | ToggleButtons — single or multiple, always inlined, never a search URL |
| Slider | Single or range slider, bounds published and enforced |
| Tags | Free-form string tags, per-tag rules enforced, a configured separator mirrored into the stored column |
| Key/value | Free-form key-value pairs, gated by four client hints |
| Colour | ColorPicker in the format the panel declared, never converted |
| Time and date bounds | TimePicker as its own type, and the minDate/maxDate a picker declares |
| Relations | Relation managers as child lists — writable when exactly one resource owns the child model, searchable and sortable where the host declares it |
| Rich text | RichEditor columns as a structured document, sanitised by construction |
| Dashboard | The panel's opted-in widgets, computed live |
| Locale and direction | The panel's own locale and ltr/rtl, so the phone lays out the way the panel does |
| Schema caching | An ETag on /schema so a cold start is one conditional request |
Which fields work? See Supported form inputs — the table to check before pointing this at a panel.
Everything below is reference. Read the section for the feature you are wiring; each ends with a Known weaknesses list stating plainly what it does not do.
#Supported form inputs

The one table to check before pointing this at a panel. A field whose
component is not here is dropped — the walker reports it as
unsupported component type, doctor names it, and, because a dropped field
gets no validation rule either, a NOT NULL column behind one fails at the
database rather than at validation.
| Filament component | Wire type | Notes |
|---|---|---|
TextInput |
text |
refines itself to email, password or number from its own accessors |
Textarea |
textarea |
|
Select |
select |
multiselect when ->multiple(); a searchable relationship select publishes an optionsUrl instead of inlining |
Radio |
radio |
shares Select's own options; always inlines, never falls back to a search URL |
ToggleButtons |
toggle_buttons |
same options shape; ->multiple() publishes multiple: true; always inlines, never an optionsUrl |
CheckboxList |
multiselect |
|
TagsInput |
tags |
per-tag rules enforced; a ->separator() is mirrored into the stored column |
SpatieTagsInput |
tags |
wholesale sync through Filament's own relationship closure, not the column write above; no separator participation; per-tag rules unenforced (see Spatie tags) |
KeyValue |
keyvalue |
four gates published as client hints, not enforced on write |
Toggle |
toggle |
|
Slider |
slider |
single or range; range mode is detected from an array ->default() on /schema (see Slider) |
Checkbox |
checkbox |
|
DatePicker |
date |
publishes minDate / maxDate / seconds |
DateTimePicker |
datetime |
same, plus hoursStep / minutesStep / secondsStep when > 1 |
TimePicker |
time |
same; a bound may be a bare 09:00 or a full datetime |
ColorPicker |
color |
in the format the panel declared, never converted |
FileUpload |
file |
single or ->multiple(); a multiple field's value is a List of stored paths, count bounds enforced on write |
SpatieMediaLibraryFileUpload |
file |
same shape, but the value is media UUID strings, not paths, and saving reconciles the whole collection — see Medialibrary |
SpatieMediaLibraryImageEntry |
image_entry |
infolist-only, read-only; card-bound the same way leadingImage() already works — see Medialibrary |
RichEditor |
textarea |
edited as raw HTML; it renders as a document on read (see Rich text) but editing is still markup |
Repeater |
repeater |
JSON-column or ->relationship(); only when every child round-trips |
Hidden |
— | deliberately skipped from the wire; its ->default() still applies on create |
Placeholder |
text_entry |
display-only; publishes as the existing entry type, admits no rule, and renders nothing in a form |
Layout components pass through as containers: Section, Grid, Tabs,
Tabs\Tab (flattened to a section — tabs are a poor control on a phone) and
Fieldset.
#Not supported
Builder, CodeEditor, MarkdownEditor, ModalTableSelect, MorphToSelect,
OneTimeCodeInput, TableSelect, ViewField.
ViewField is on this list by design, not by omission: it is an arbitrary
Blade view with a state path and no introspectable data contract — the same
ruling the P5 custom-Blade-widget decision already made — so there is nothing
to publish. It keeps the ordinary unmapped treatment: dropped with a warning,
named by doctor, rule withheld so its state is discarded on write.
There is an escape hatch. config('filament-mobile.types') maps any
component class onto a type this contract already defines, and host entries win
over the built-ins:
// config/filament-mobile.php
'types' => [
\Ysfkaya\FilamentPhoneInput\Forms\PhoneInput::class => 'text',
\Filament\Forms\Components\MarkdownEditor::class => 'textarea',
],
That is how the pilot panel handled its phone-input and icon-picker plugins. The constraint is that the value must be a type the contract already defines — you can point a new component at an existing renderer, but you cannot invent a new one without a change to this package.
#Rule hints travel with the field — and are enforced
->url(), ->regex(...) and ->confirmed() publish rules.url: true,
rules.regex — the pattern verbatim, because a rewritten pattern could
disagree with the server's — and rules.confirmed: true, and the write path
enforces all three: RuleExtractor emits the matching Laravel rules, so a
field that 422'd on the web panel no longer sails through mobile. Mobile
looser than web is the one direction this package's validation can never
drift. confirmed is the only one of the three with no Filament accessor:
->confirmed() registers an ordinary rule('confirmed', ...), so both the
walker and the extractor detect it by scanning the field's own resolved
getValidationRules(). The walker's scan is a silent probe
(declaresConfirmed()), deliberately not the guarded read() every other
accessor goes through — a component whose rule list cannot resolve headlessly
(a Select whose in: rule needs relationship context) throws as an
ordinary event here, and a warning about a probe is noise, not a defect.
A rule-message translation failure degrades per-field. The messages
map is generated through the same translator the 422 uses; a translator
that throws costs that one field's messages — the client falls back to its
own strings per rule — never the component, and never the document.
#Actions
A resource opts specific table record actions into the mobile API by
name. The package never builds an action of its own — declaring
->actions([...]) says WHICH of the resource's own table() actions travel
to a phone; the real Filament\Actions\Action object still decides label,
color, icon, confirmation, authorization, and what the closure does.
MobileResource::make()
->card(...)
->actions(['approve', 'archive'])
Names are de-duplicated and travel in declaration order. Each must resolve
to a record action the resource's table() already defines (record actions
nested in a group are found too — the search is flat). Two things never
reach a phone, both reported by filament-mobile:doctor with a non-zero
exit rather than failing silently:
- a name that resolves to nothing — a typo, or an action that lives only on the resource's page rather than its table;
- a name that resolves to an action carrying a form — modal-form actions are not supported this slice, so the action is omitted from the wire rather than shipped half-working.
A table() doctor cannot even build headlessly (outside Livewire) is
reported the same way — actionable, non-zero. None of this breaks /schema
or the record endpoints; a misconfigured action is simply absent, the same
degrade every other closure in this package gets.
#On the record payload
GET /{resource}/{record} gains actions, a sibling of permissions:
evaluated per record, so an action hidden or unauthorized for THIS row is
not in the list — no disabled button, the same rule permissions already
follows. Always present, [] when the resource opted none in. See
contract/README.md for the exact node shape.
A throwing label, color, or icon closure degrades only that field — label
falls back to the action's own machine name, color/icon to null — and the
action stays in the list and stays runnable: a cosmetic failure must not
cost a capability. A throwing visibility or authorization closure omits the
action entirely, same as every other gate in this package. A throwing
confirmation closure is the one exception that fails closed: the action
still carries a non-null confirmation, with a generic heading and empty
submit/cancel, so nothing can be tricked into running promptless — see
contract/README.md for what a client must do with an empty
submit/cancel.
#Running one
POST /api/mobile-panel/{resource}/{record}/actions/{action}
Resolution order matches every other endpoint: resource 404 → viewAny 403
→ record 404 → the action's own gate, 403. Every refusal — unopted,
unresolved, form-carrying, hidden, unauthorized — is the same bodyless 403,
so a probing client cannot tell which reason it hit. The published actions
array on the record is a hint; this endpoint re-answers the gate against
the record as it stands at call time, never trusting what it last
published.
| Status | Meaning |
|---|---|
200 {"message": string|null} |
The action ran. message is its own success notification title, when it declared one. |
422 {"message": string|null} |
The action halted ($action->halt() / Filament's failure-notification path). message is its failure title. |
500 |
The action's own closure threw. Never reported as a success — an action that half-ran must not tell the client it finished. |
The response carries no record body. The client re-fetches: an action's
most common effect is exactly what permissions and actions report, so
the re-fetch refreshes both.
#Not supported this slice
Actions with modal forms, bulk actions, page-level (header) actions, action
groups, and list-card actions. Row actions declared on the resource's
table() are the whole surface.
#Upload
A FileUpload (and SpatieMediaLibraryFileUpload) field is editable from
a phone, single or ->multiple(). config.multiple is always present on a
file node; a multiple field additionally publishes maxFiles/minFiles
when it declared them, and its value everywhere — /state, a record, a
write body — is a List<String> of stored paths.
Multiplicity does not change this endpoint's shape — one file per request. A multi-file field is served by N calls: the client picks one file, uploads it through the endpoint below, and appends the returned path to the field's list, once per file. Every per-file enforcement below (content-sniffed type, size, extension clamp, the component's own disk/directory/visibility) applies to each call exactly as it does for a single-file field.
POST /api/mobile-panel/{resource}/upload
Multipart: file (the bytes) and field (the form field's name — the
statePath, so a nested field is addressable). No record is involved — an
upload happens while a form is open, on create as well as edit. Success is
200 { "path": "<stored path>" }
Gate order matches every other endpoint: resource 404 → viewAny 403 →
field-resolution 403. Upload\UploadFieldResolver re-derives the field
through the same SettledSchema/WritableNames machinery the write path
uses — the published schema is a hint, never the gate — and it returns
null, never throws, for every reason a write would be refused: an
unresolvable name, a disabled field, a disabled ancestor container, a name
that resolves to more than one component, or a component that isn't an
upload at all. Every refusal comes back as the same bodyless 403, so a
client cannot map a panel's field names by probing which ones 403 differently.
Its final check is the write path's own WritableNames allow-set, so a field
this endpoint accepts and a field store()/update() will actually persist
cannot drift apart.
Constraints are enforced server-side, never trusted from the client.
getAcceptedFileTypes() and getMaxSize() are read off the resolved
component and applied as real Laravel validation rules — a violation is a
422 in the same shape the write path's validation errors use, so a client
renders it with machinery it already has. The type check uses Laravel's
mimetypes: rule, which sniffs the file's actual content, never
mimes:, which trusts the client-supplied extension — a .png that is
really a shell script fails the mimetypes: check regardless of what its
Content-Type header or filename claim. A field whose
acceptedFileTypes()/maxSize() closure throws fails closed with a 422
rather than accepting anything.
The stored filename is never the client's filename, and its extension
comes from the sniffed MIME clamped to a fixed allow-list. The extension
is looked up from the sniffed MIME type and then checked against a
package-controlled SAFE_EXTENSIONS constant (png, jpg, jpeg, gif,
webp, pdf); anything outside that list stores with no extension at
all, never the mapped one. This is deliberate, not incidental: Symfony's
full MIME-to-extension table maps types a misconfigured webserver can be
made to execute (application/x-httpd-php → php) straight through, and
whether a given upload's sniffed MIME ever lands on one of those entries
depends on the deployment's libmagic build. Clamping to an explicit,
package-controlled list means "can this endpoint ever write an executable
extension" is answerable by reading one constant, not by auditing every
libmagic quirk a host might run — it's what makes an uploaded polyglot inert
on disk regardless of what a client claimed it was. If a real panel needs
more (.zip, say), widen SAFE_EXTENSIONS deliberately, by name — never
by falling through to Symfony's table: the moment the lookup trusts the
full table, that safety question becomes a question about each deployment's
libmagic build again.
Storage bypasses Filament's saveUploadedFile(). That method's
signature requires a Livewire TemporaryUploadedFile, which a plain Laravel
UploadedFile from this endpoint does not satisfy — importing that
lifecycle is exactly the coupling this package exists to avoid. Instead the
file is stored directly through the resolved component's own
getDiskName()/getDirectory()/getVisibility(), landing at the same path
Filament's own web panel would produce.
The returned path becomes the field's value, and the ordinary write path
saves it — no change to store()/update(). This mirrors Filament's own
web panel, which also stores on pick, not on save. RuleExtractor admits a
file field's rule — single or multiple — so the stored path(s) enter the
validated payload exactly like any other column value. A multiple field's
rule is array/list plus count-semantics max:{maxFiles}/
min:{minFiles} when declared (Laravel's min/max on an array count its
elements), with a per-element string under name.* — so a crafted
[1, 2] 422s keyed attachments.0, and the count bound is the server's
rule, not a client hint. Removal is wholesale-replacement, the
relationship-repeater model: a submitted list is the whole new set, a
submitted empty list clears the column, and an unmentioned field is
untouched. A field whose isMultiple() closure throws keeps its rule
withheld — the closed answer the schema walker and this endpoint's
resolver both share. The flip side: a file column accepts any
string (or list of strings) through the ordinary write path — matching the
web panel's own Livewire-tamperable property — so a host must not feed that
column to Storage::download()/Storage::url() (or any path-sensitive
sink) unchecked.
Orphaned files accumulate. A user who picks a file and abandons the form leaves a stored file with no row pointing at it — the same property Filament's own temporary-upload directory has, and a multiple field makes it more frequent, not different: one abandoned form can strand a whole list. This package does not clean them up; a host that wants that prunes the storage directory on its own schedule (e.g. delete anything older than N days with no matching row). No claim-on-save handshake exists to avoid this — that would be a second subsystem beside the one being built.
#Medialibrary
spatie/laravel-medialibrary's own Filament components —
SpatieMediaLibraryFileUpload and SpatieMediaLibraryImageEntry — work on
mobile the way the plain ones already do, plus a media collection actually
renders: a media-backed path publishes a flat sibling beside its raw value,
the same pattern .__rich already established for rich text.
// a ->multiple() field; a single-file field's raw value is one uuid String
{
"id": 7,
"photo": ["9a1f6c…-uuid"], // the field's VALUE: media UUID strings
"photo.__media": [
{
"uuid": "9a1f6c…-uuid",
"url": "https://…/photo.jpg",
"thumbUrl": "https://…/conversions/photo-thumb.jpg",
"name": "photo.jpg",
"size": 34211,
"mime": "image/jpeg"
}
]
}
The raw key keeps the P12 invariant: a List<String> for a multiple field,
a String for a single one, everywhere — /state, a record, a write body.
For a medialibrary field the strings are media UUIDs, never storage
paths; the P12-era "stored paths" reading of that key is superseded by this
sibling wherever a medialibrary field is in play. __media is always a
list, one entry per media item in the collection's own order — also for a
single-file field (one element or none) — the same "one shape, no inference"
rule multiple itself follows. thumbUrl is the thumb conversion's URL
when the collection declares one, else null; url is always the original,
and both come from medialibrary's own getUrl(), never a Storage::url()
guess. A genuinely empty collection publishes photo.__media: []; a model
without HasMedia, a non-media path, or an old server omit the key
entirely — absence means "no media information", never an error, the same
split relations: []-versus-absent already uses. The sibling appears on
GET /{resource}/{record}, both write response bodies, relation rows, and
list rows when a card slot is bound to the media path (leadingImage()
keeps its existing signature; the binding is by field name against the
resource's own media paths, no new card API) — never on /schema, which is
record-scoped like actions.
Uploading is unchanged (see Upload above): one file per call,
still {"path": …}, still a stored path with no media row yet — a value
only becomes a media UUID after the save that consumes it. On save, a
Spatie upload is reconciled against the model's collection, wholesale, the
same replacement model P12 already pinned for a plain multiple file field:
- a submitted string that is an existing media UUID on this record's collection is kept;
- a submitted string that is a stored path from the upload endpoint is
consumed into the library (
addMediaFromDisk()->toMediaCollection(...)); - an existing media item whose UUID the submission omits is deleted;
- a field the submission never mentions is untouched (absence is not
emptiness); a present
[]clears the whole collection. - a submitted UUID that belongs to another record or another collection
is refused with a
422keyed to the field — the same addressing-boundary rule relation writes already follow. - a request naming more than one medialibrary field reconciles all of them together, atomically: one field 422ing never leaves another already applied.
Gated on the model actually having HasMedia, detected by
method_exists/FQCN string, never a runtime dependency on the package —
spatie/laravel-medialibrary is a dev dependency of this package. A
Spatie component on a model without HasMedia, or one whose collection()
closure throws, publishes readOnly: true — fail-closed, the same shape a
throwing constraint closure already gets — and doctor names why.
#doctor diagnostics
Three medialibrary-specific findings, none of them fatal to the CI exit code — informational, the same as an unresolvable card path or a prose-only card field:
- a media component (upload or entry) on a model without
HasMedia— the field always reads empty (a form upload additionally gets an actionable line under "Unsupported components", since it is also publishedreadOnly: true); - a media component whose name collides with a real column on the model's table — the media pass overwrites the column's raw value at that same key in the payload, almost certainly an accidental collision rather than a deliberate shadow;
- a card slot bound to a media path on a model without
HasMedia— the slot will always publishnull, since the sibling pass that would fill it never runs.
#Known weaknesses, stated now
- No curation UI beyond what the file field already offers — no reordering, no per-media custom properties, no responsive-images markup. Wholesale replacement stays the write model.
SpatieMediaLibraryImageColumnon a relation card is not supported. The two-column derivationRelationCard::fromColumns()uses would title a card with an image; that stays the existing degrade-to-null and is named bydoctorlike any other unresolvable card path.- An old client ignores the sibling and renders the raw UUID string as if it were the stored filename — degraded but functional, not wrong: the file field still shows something selected, just not a real name. A new client against an old server sees no sibling at all: exactly today's null.
#Repeater
Repeater::make('items')->schema([...]) over a JSON/array-cast column is a
working, validated, editable field on the phone. Before this it was entirely
unmapped: the walker dropped it with a warning and emitted no node, so the
field was invisible on mobile and any data in it was unreachable.
Repeater::make('line_items')
->schema([
TextInput::make('sku')->required()->maxLength(20),
TextInput::make('qty')->numeric(),
])
->minItems(1)
->maxItems(5);
publishes a repeater node carrying the item template as children — the
same shape layout components already use, so the client's existing recursive
walk renders it unchanged:
{
"type": "repeater",
"name": "line_items",
"children": [ /* the item template's own nodes, each with its own rules */ ],
"config": {
"addable": true,
"deletable": true,
"minItems": 1,
"maxItems": 5,
"itemLabel": null,
"reorderable": false,
"readOnly": false
}
}
children is the template, published once — not once per stored row.
addable/deletable/minItems/maxItems come straight off the field's own
isAddable()/isDeletable()/getMinItems()/getMaxItems(). Only
minItems/maxItems become server rules. addable/deletable are
client affordances — they tell a renderer not to draw an Add or a Remove
control, and nothing on the server refuses a crafted payload that adds or
removes rows anyway. That is deliberate, not an oversight: a row-count bound
is a statement about the stored value, which the server must own, while
addable(false) is a statement about the editing gesture, which only a
renderer can make. Enforcing it server-side would need a stored-versus-
submitted row-count comparison the write path does not do for any other
field. If you need the count fixed, say so with
minItems(n)->maxItems(n) — that is enforced. reorderable is published for
a host rendering its own repeater; this package's own Flutter client always
treats it as false regardless of what is published.
readOnly is always published, both ways round — false here, true
for a refused repeater. A client reads an absent readOnly as read-only,
because absence means a server predating repeater support and a client must
never invent a capability the server did not declare; that rule only works
while this server states the ordinary case explicitly.
Two things earn readOnly: true, and the write path refuses on the
same two predicates, so the published flag and the server's answer cannot
disagree:
- A nested repeater — a repeater inside another repeater's item
template. Two levels of row coordinate is a different problem, and a
nested row's
422comes back keyedouter.0.inner.1.x, which the client has no field to render it against. - A repeater whose item template holds a child that would not
round-trip — a
Hidden, an unmapped component type, adisabled()or never-dehydrated field, or a relation-write child whose->dehydrated(true)puts it back into the row's stored state. See below; this is the one that would otherwise lose data.
A relationship repeater (->relationship()) is editable — its rows
write through Filament's own saveRelationships(), below — with one refusal
carried over from when it was read-only: a relationship gate that cannot
answer (getRelationship() throwing, or a component with no such accessor at
all) still publishes readOnly: true with a warning, never an editable
control the write path would silently drop. A gate that cannot answer never
admits — same shape as an upload field's accept/maxSize gates.
A child that cannot round-trip refuses the whole repeater. At top level,
withholding a field's rule protects its column: the key never enters the
validated payload, so update() never touches it. Inside a repeater the
whole array is one attribute, and Laravel's validated() rebuilds it from
the expanded paths its rules name — so a row key with no rule is deleted
from every row that gets written. Same mechanism, opposite outcome:
Hidden::make('id') in a row template, or
TextInput::make('rate')->disabled(fn () => ! $user->can('rates.manage')),
had its stored value destroyed on every save, behind a 200.
A relation-write child reaches the same outcome by a different route and
is the shape to watch for, because it is the one whose rule is withheld
somewhere other than the descent. CheckboxList::relationship() saves through
saveRelationships(), so no rule ever names it — normally harmless, since
relationship() sets dehydrated(false) as a literal and Filament never puts
the key in the row's state at all. ->dehydrated(true) overrides that literal:
the key IS stored and still has no rule, so
[{"title": "A", "tags": [1, 2]}] saved as [{"title": "A"}]. A child whose
dehydration gate cannot be evaluated is refused the same way — a gate that
cannot answer never admits.
There is no row identity on the wire — no keys, no reorder — so the only
merge available is by index, and an index-merge pairs row 2's id with row
3's data the moment a row is added or removed. Corrupting an identifier is
worse than refusing the control, so the field fails closed: no rule, no
writable name, config.readOnly: true, and the stored rows stay readable on
GET. filament-mobile:doctor names the offending child, which is the only
place a panel author can learn which one cost them the control.
A relationship repeater writes through Filament's own machinery.
Repeater::relationship() registers its own saveRelationshipsUsing()
(Repeater::saveToRelationship()), and the write path's relation pass
(Write\RecordForm::saveRelations()) reaches it unchanged — the same call
Filament's own CreateRecord/EditRecord make after the attribute save,
not new code. The caveat is row identity: a repeater's state on the wire is
keyless, so every save is delete-all-then-recreate — Filament deletes
the existing child rows and re-creates them from the submitted state. That
is pinned in RepeaterWriteTest, and it matters for a panel whose child
rows carry ids other tables point at, or timestamps anyone reads. The field
still has no column of its own, so nothing reads one — but its rows are in
the record payload, published off the relationship and projected onto the
item template's declared fields, so the edit form prefills and a save that
touched another field round-trips them unchanged. A child's id, timestamps
and pivot columns stay off the wire, exactly as an undeclared column does
anywhere else, and zero rows publish [] rather than nothing. The write path
also refuses to read a present null as a clear; only an explicit []
clears. Both halves matter: while the rows were withheld from a field the
schema published writable, a client had no value to send, submitted null,
and every child row was deleted behind a 200.
filament-mobile:doctor no longer reports a resolvable
relationship repeater at all; it still reports the shapes this slice
legitimately does not support: a repeater containing a live() field (the
item template is static — see below), a nested repeater (published
readOnly: true; two levels of row coordinate is a different problem), a
repeater with a child that would not round-trip (named child and all), and
a relationship repeater whose gate cannot answer.
Per-item rules are published and enforced, not merely published. A child
component's own rules travel under items.*.field — line_items.*.sku,
line_items.*.qty — so a PUT that violates one comes back 422 with a key
shaped exactly line_items.0.sku, the row index Laravel's own validator
fills in. The repeater's own rule bounds the array itself: array, required
when the field is, list, and min/max derived from
minItems/maxItems — so a submission with too few or too many rows is
refused server-side, not merely discouraged by the client's
addable/deletable gates.
list, not merely array. PHP's array admits a string-keyed map, and
the per-item wildcard rules match a literal * key perfectly happily, so
{"line_items": {"*": {"sku": "A"}}} used to validate cleanly and store
verbatim behind a 200 — a shape neither the contract nor Filament's own web
Repeater can read back, which the mobile client then rendered as zero rows
and overwrote on the first Add. list turns that silent corruption into a
422. An empty repeater is unaffected: [] is a list.
Remote options work inside a row. A searchable relationship select in an
item template publishes its config.optionsUrl like any other over-cap
select, and POST /{resource}/options descends through a repeater into
its item template to find the field (OptionsController::findSelect()) —
the client renders a row's select off the template and asks for it by its
bare child name, so a lookup that stopped at the repeater's border would 422
a node the schema itself published.
#The name-space split — read this before touching RuleExtractor
RuleExtractor used to double as two things at once: it produced the
validation rules, and WritableNames::of() was literally
array_keys(RuleExtractor::fromComponents(...)) — the mass-assignment
whitelist and the settle's allow-set were the same set of names. For a flat
form that identity is a feature. A repeater breaks it, and getting this
wrong corrupts state:
- Validation wants per-item paths —
items.*.name— which is what Laravel natively understands and what a client needs to pre-validate a row. - The settle (
Write\SettledSchema::reset()) must never see them. It callsArr::set($state, $path, …), andArr::sethas no wildcard support — givenitems.*.nameit creates a literal*key rather than touching every row, corrupting the very state it exists to protect.Arr::has()has the same gap in the other direction: it answersfalsefor a starred path against a real array, so a naive "trust every rule name" reset would never copy a submitted row at all.
So the two are deliberately separate outputs of the same descent:
RuleExtractor::fromComponents() emits items and items.*.field;
RuleExtractor::writableComponents() — what WritableNames::of() actually
reads — emits items only. The repeater is one writable name whose whole
array is trusted or reset as a unit; the starred per-item names exist for
validation alone and must never reach the settle. If a future change makes
WritableNames::of() read array_keys(RuleExtractor::fromComponents(...))
again — restoring the old identity because it looks like a simplification —
it silently reintroduces this corruption, because items.*.field would
re-enter the allow-set as a literal, wildcard-shaped key Arr::has/Arr::set
cannot resolve against real submitted data.
The settle treats items as one name. A crafted row cannot open a gate a
trusted row would not, because the whole array is reset together whenever
items itself is not writable — a disabled repeater, or one inside a disabled
container, refuses exactly as any other field does: its name is withheld, so
no row reaches the database.
#Known weaknesses, stated now
- No reordering. Published as
config.reorderablefor an interested host; this package's own widget does not offer it. - The item template is static. A
live()field inside a row does not re-settle that row —/statesettles a flat form, and giving it a row coordinate is its own problem.doctornames a repeater containing alive()field so a panel author is not surprised. - A relationship repeater's save is delete-all-then-recreate. Keyless
state leaves Filament's
saveToRelationship()no row to diff against, so every save deletes the existing child rows and re-creates them from the submitted state — pinned inRepeaterWriteTest. Fine for rows nothing else references; a real consideration for child rows other tables point at by id, or forcreated_attimestamps anyone reads. - No nested repeaters. A repeater inside a repeater's item template is
published (the walker recurses into it like any other child) with
config.readOnly: true, so the client renders it inert, anddoctorreports it. Its rows still round-trip — they are part of the outer array — they simply cannot be edited from a phone. Two levels of row coordinate is a different problem. - A repeater with a non-round-tripping child is refused wholesale. One
Hidden, one unmapped component, onedisabled()field or onerelationship()->dehydrated(true)child in the item template costs the entire field its editability, because the array is written whole and there is no row identity to merge the stored value back by. Honest, and it protects the data, but a panel that stamps every row with aHiddenid gets a read-only repeater on mobile until row identity is on the wire. addable/deletableare client hints, never server rules. A crafted payload can add or remove rows regardless. UseminItems/maxItemsfor a bound the server enforces.Repeater::setUp()'s own default is deliberately withheld everywhere. It unconditionally callsdefaultItems(1), whosedefault()override keys its one blank item under a freshly generated random UUID on every evaluation — verified empirically, two successive/schemacalls for the same user produced two different UUIDs for the same field. Publishing that through/schema'sdefaultwould move the document's ETag on every request for no real change, and feeding it intoFormDefaultswould write{"<uuid>": []}— a dict, not the list-of-maps shape the design spec documents — into the column on any create that never mentions the field. BothSchemaWalkerandFormDefaultswithhold it by type, so an ordinary create leaves the column untouched rather than corrupting it.
#Radio
Radio::make('plan')->options([...]) is a working, editable field on the
phone, rendered as real radio buttons rather than a dropdown.
Radio::make('plan')->options([
'monthly' => 'Monthly',
'yearly' => 'Yearly',
]);
The server side of this is nearly free, because Radio shares Select's
option machinery. Both use Concerns\HasOptions — the same trait, the same
getOptions() — measured against filament/filament in vendor/, not
assumed. The walker's existing option reader and flatOptions() apply to a
radio node unchanged; only the Flutter-side rendering is new.
One hazard, found and closed: a radio can never use the search-endpoint
fallback, so it must never be offered one. select/multiselect degrade
past options_inline_max options to config.optionsUrl, publishing an async
search affordance instead of the full list. A radio has no
Concerns\CanBeSearchable and nothing to post a query to — so before the
fix, an over-cap radio hit the same branch and published an optionsUrl a
client could never call, silently dropping every option past the cap with no
way to reach the rest. The inline-cap branch in SchemaWalker::config() is
now guarded $type !== 'radio', so an over-cap radio always inlines its full
option list instead.
No new RuleExtractor rule. An earlier draft of the design spec assumed
Radio would get the same in: constraint select already has; select
produces no such rule for any option-bearing field today, so there was
nothing to give Radio parity with. Left as-is.
#Known weaknesses, stated now
Radio::isInline()is ignored. Options always stack one per row — the right treatment on a phone regardless of what the panel configured.
#Toggle buttons
ToggleButtons::make('status')->options([...]) is a working, editable field
on the phone — the button-row sibling of a radio group.
ToggleButtons::make('status')->options([
'draft' => 'Draft',
'live' => 'Live',
]);
ToggleButtons::make('flags')->multiple()->options([...]);
ToggleButtons::make('active')->boolean();
The node shares the option machinery outright — ToggleButtons uses the same
Concerns\HasOptions trait and getOptions() as Select and Radio
(measured in vendor), so the walker's option branch reads it unchanged,
widened rather than copied:
{ "type": "toggle_buttons", "name": "status",
"rules": { "required": true },
"config": { "multiple": false,
"options": [ { "value": "draft", "label": "Draft" } ] } }
config.multiple is always present, a stated gate like a repeater's
readOnly: a single field's value is a scalar, a ->multiple() field's a
List — the select/multiselect split, through the ordinary write paths.
optionsUrl never appears on this type, however long the list: like a
radio it has no CanBeSearchable and nothing to post a query to, so an
over-cap field inlines every option. The boolean() preset needs no
special-casing — it publishes options 1/0 and the value travels as
declared.
#Known weaknesses, stated now
- Per-option colors, icons, tooltips and disabled state are not on the
wire, and neither are
inline/grouped/hiddenButtonLabels— all presentation. A disabled option is still enforced server-side by thein:rule Filament builds from the enabled option keys.
#Slider
Slider::make('rating')->range(0, 10)->step(1) is a working, editable field
on the phone — single thumb, or two thumbs when the field is a range.
Slider::make('rating')->range(0, 10)->step(1);
Slider::make('price_range')->range(0, 100)->step(5)->default([20, 40]);
{ "type": "slider", "name": "rating",
"rules": { "required": true, "numeric": true, "min": 0, "max": 10 },
"config": { "min": 0, "max": 10, "step": 1, "multiple": false } }
The bounds are enforced, not just hinted. Slider::setUp() force-registers
required, numeric + min:/max:, and integer/multiple_of:{step} on
the component itself (measured in vendor) — behind rule closures keyed off raw
state, which the ordinary accessor reads cannot see — so RuleExtractor
re-derives the single-slider bounds from getMinValue()/getMaxValue()/
getStep() (the WithPadding variants first, so rangePadding folds into the
enforced bound rather than double-counting), and array/list for a range.
A range's per-element rules ride the existing name.* nested-recursive
machinery. The walker's published rules hints read the same accessors, so
hint and gate cannot drift.
Range mode is detected from state, and /schema has none. Filament's
isMultiple() is is_array($this->getRawState()) — there is no multiple()
method — and the /schema walk is deliberately unseeded, so the walker falls
back to is_array(getDefaultState()): declare the range with an array
->default([20, 40]), as above, or /schema publishes multiple: false
while the rules still say array. /state re-answers from real state, and a
client never blocks a submission on the hint. A string step publishes no
step key at all — absence means "any step".
#Known weaknesses, stated now
- A range slider with no array default publishes
multiple: falseon/schema— the detection weakness above, documented rather than fixed, because seeding the schema host's state to fix it would cost more than the hint is worth. - Pips, tooltips, behavior, fillTrack, vertical, rtl, nonLinearPoints, minDifference/maxDifference and decimalPlaces are not on the wire. The first several are presentation; the difference constraints are JS-side behaviour even in Filament.
#Tags
TagsInput::make('labels') is a working, validated, editable field on the
phone — a free-form list of strings with optional suggestions.
TagsInput::make('labels')
->suggestions(['urgent', 'billing'])
->nestedRecursiveRules(['max:20']);
publishes a tags node:
{
"type": "tags",
"name": "labels",
"config": { "separator": null, "suggestions": ["urgent", "billing"] }
}
The value is a List<String> on the wire in every case — separator or
not. splitKeys, tagPrefix and tagSuffix are deliberately withheld:
a tag commits on submit only, and prefixes/suffixes are presentation this
slice does not reproduce.
#The separator mirror — the one place this package reproduces Filament's dehydration
TagsInput::make('labels')->separator(',') changes what the panel
stores: Filament's own dehydrateStateUsing() joins the submitted array
into "a,b,c" before it reaches the column, and hydrateTags() explodes it
back on read. This package's write path deliberately never runs Filament's
dehydration for anything else — it writes validated() straight to the
model — so without a deliberate exception, a client sending an array to a
separator-configured field would store an array where the panel writes a
delimited string: two surfaces, two shapes, one column.
The fix is a narrow, stated exception rather than a new general capability.
TagSeparators::dehydrate() joins a separator-configured field's submitted
array with that separator, run on the final attribute array — after
fillMissingPaths(), not on validated() alone, because TagsInput ships a
[] default through setUp() that reaches every create via FormDefaults,
and joining before that default is filled in throws on every create that
never mentions the field. Both store() and update() call the same
function; there is one transform, not two copies that could drift.
The inverse — un-joining a stored delimited string back into an array for a
client to read — lives in RecordSerializer::hydrate(), the single place
every serialised record passes through. That is deliberate: six read
seams share this one answer — index(), show(), the store() 201 body,
the update() 200 body, RelationController's relation rows, and any
future endpoint that serialises a record, because the un-join is baked into
RecordSerializer::serialize() itself rather than wired per call site. A
related row's owning resource — needed to know whether that row's own
tags fields are separator-configured — is resolved through
ResourceRegistry::findByModel(), which returns null unless exactly
one opted-in resource maps to the model class: zero matches or an
ambiguous match degrades to the raw stored value rather than guessing which
resource's configuration applies.
Say the consequence plainly, because "the raw stored value" reads more
conservative than it is: for a separator-configured field that value is
the delimited String, so this is the one case where the published
"List<String> in every case" is false, and a client parsing that field off
a relation row gets a type it was told it would never see. It is not fixable
by splitting anyway — the separator is a property of the resource, which is
exactly what could not be resolved, and splitting on a guessed one publishes
one wrong tag instead of two right ones. Several resources over one model is
an ordinary panel shape (this package's own fixtures put five over
Company), so the honest degradation is kept and the consequence documented
here and in contract/README.md, rather than traded for a guess.
This mirror is a reproduction of Filament's behaviour, not a general
capability this package now has — a future Filament release changing
dehydrateStateUsing() would silently diverge from it. The test that would
catch that asserts the stored column, not the response code: a 200
was never the question this feature had to answer.
#Per-tag rules, and the name-space split this package already established
TagsInput implements HasNestedRecursiveValidationRules —
getNestedRecursiveValidationRules() — and this package had never handled
that interface before this slice; a ->nestedRecursiveRules(['max:20']) on
the field was silently unenforced by the mobile API, in violation of this
package's own standing rule that the rules a client is shown and the rules
the server enforces cannot drift apart.
The fix reuses the split P6c's repeater already established, for the same
reason: RuleExtractor mints both labels and labels.* — the second
for validation, keyed by index, so a ->nestedRecursiveRules(['max:20'])
violation on the phone's second tag comes back 422 keyed labels.1, not a
whole-field error. WritableNames — the settle's allow-set — contributes
only labels: Write\SettledSchema::reset() calls Arr::set()/
Arr::has(), neither of which has wildcard support, so a starred name in
the allow-set cannot be expressed at all, not merely mishandled —
Arr::has($state, 'labels.*') is always false and Arr::set would write a
literal * key. Unlike a repeater's per-item names, the starred entry here
is inert rather than destructive if it were ever mistakenly admitted:
labels is independently in the allow-set and persists on its own, so a
tags field has no analogue to the repeater's row-corruption failure mode —
the split is still mandatory for expressibility, just not for that reason.
A real bug was found and fixed here: a starred rule name reaching
/schema was silently never enforced. MobilePanelController's
isRuleNameAllowed() admitted only the repeater's name.*.child shape
(str_starts_with($name, $allowed . '.*.')) — which matches
line_items.*.sku but never labels.*, because a tags field's per-tag rule
has no trailing dot or child segment. So labels.* was extracted, published
on /schema, and then silently dropped before $request->validate() ran —
a 21-character tag under ->nestedRecursiveRules(['max:20']) saved with a
200. The check now also admits $name === $allowed . '.*' exactly.
A tags field whose nested-rule closure throws is refused entirely, not
defaulted. Every other guarded read in this package degrades a throwing
closure to a safe default and keeps the field usable — but a nested-rule
closure guards a constraint, not a hint, so treating a throw as "no nested
rules" would silently widen what the field accepts. nestedRulesFor()
returns null on a throw, distinct from [], and the caller reads null as
"refuse the whole field": no rule, no writable name — the one place on the
tags side where degrading like everything else in this package would make
mobile looser than web.
#Known weaknesses, stated now
splitKeys,tagPrefixandtagSuffixare ignored. A tag commits on submit only, and prefixes/suffixes are presentation this slice does not reproduce.- The separator mirror reproduces Filament's
dehydrateStateUsing(), rather than reading it generically. A future Filament release changing that method's behaviour would silently diverge from this package's copy — see "The separator mirror" above for the test that would catch it.
#Spatie tags
filament/spatie-laravel-tags-plugin's SpatieTagsInput — over
spatie/laravel-tags' HasTags — works on mobile exactly like a plain
TagsInput above: the same tags node, the same List<String> wire value,
prefilled with the record's tag names and saved wholesale on write. No new
wire shape, no new type: a panel migrating a plain tags column onto
HasTags changes nothing on the client.
SpatieTagsInput::make('tags') // any type
SpatieTagsInput::make('topics')->type('topics') // scoped to one type
both publish an ordinary tags node. separator never appears in its
config, even if declared — a Spatie field has no column of its own (it
saves through Filament's own relationship closure, below), so there is
nothing to implode a delimited string into, and publishing
getSeparator()'s answer anyway would be a hint that lies about what the
write path does with the value. suggestions still publishes, from the tag
table as ever.
The write is wholesale sync, not this package's own column write.
RecordForm::saveRelations() gates the field (HasTags present, type()
resolvable — both fail-closed; the walker already dropped either shape from
/schema, see "doctor diagnostics" below), then falls through to
$component->saveRelationships() — Filament's own syncTagsWithType() /
any-type sync, unchanged. That means the submitted list is the whole new
set for that type: [] clears every tag, a field the request never
mentions (or mentions as null) is left untouched, and a mix of kept/new
tag names is reconciled in one call, the same "wholesale replacement" model
Medialibrary uses for a collection.
An any-type field's sync is wholesale over the ENTIRE relation, typed tags
included — stated plainly, not left implicit. SpatieTagsInput::make('tags')
(no ->type()) calls syncTagsWithAnyType(), which is Spatie's own
tags()->sync() under the hood — there is no type scoping to the any-type
field's own tags, because an any-type field has no type to scope by. An
any-type [] therefore clears every tag on the record, topics-scoped ones
included, and an any-type field's published/synced value is every tag name
regardless of type — a resource declaring both SpatieTagsInput::make('tags')
and SpatieTagsInput::make('topics')->type('topics') has the any-type field
silently absorb and clear the typed one on every write that touches it.
Enforced before the sync, with a field-keyed 422:
- every element of the submitted list must be a non-empty, non-whitespace
string — a non-string element would otherwise reach Filament's own
findOrCreate()and fail with a500, and a whitespace-only one (" ") would mint a tag indistinguishable from "no tag" once trimmed for display; ->required()refuses an empty list, on update AND (final review) an absent field on create — relation-write names carry no Laravel validation rule of their own, so nothing else on mobile enforces it, the same reasoningMedialibrary'sisRequired()gate follows.
Per-tag nestedRecursiveRules (e.g. max:20) are not enforced for a
Spatie-backed field — unlike the plain TagsInput case above, which does
enforce them. Stated now, not silently: this is the one thing a panel
migrating from TagsInput to SpatieTagsInput loses.
getSuggestions() queries the entire tag table, per field, per /schema
request — unbounded, and this matches the web panel's own cost. A Spatie
tags field's suggestions (like the plain TagsInput case) is read straight
off getSuggestions(), which the plugin overrides to query every row of the
tag table when no explicit suggestion list is set. A panel with a large tag
table pays that query on every /schema build, once per Spatie tags field —
noted here beside Medialibrary's equivalent caveats, not a regression this
slice introduces.
#doctor diagnostics
Three Spatie-tags findings, none fatal to the CI exit code on their own — informational, the same as the medialibrary trio:
- a
SpatieTagsInputon a model withoutHasTags— the field always reads empty (also actionable under "Unsupported components", since the walker drops the field from/schemaentirely rather than publishing it — no client honoursreadOnlyon atagsnode); - a
SpatieTagsInputwhose name collides with a real column on the model's table — the tags pass overwrites the column's raw value at that same key in the payload; - a card slot bound to a Spatie tags path on a model without
HasTags— the slot always publishes[](also actionable under "Unsupported components", for the same reason: the underlying field is the same dropped-from-/schemashape as the first diagnostic above).
#Sluggable
spatie/laravel-sluggable's HasSlug needs no special handling from this
package at all — a slug field is published and written exactly like any
other plain TextInput, because that is exactly what it is: HasSlug hooks
the model's own creating/updating events, not the form layer, so nothing
here has anything to gate, disable, or hint.
The vendor's generation rule (unchanged by this package, and identical to what the web panel already does with the same form) is:
- a changed slug value is kept verbatim — spatie may still suffix it
(
-2,-3, …) on a collision with an existing row, the same as web; - an empty (or absent) slug generates a fresh one from the configured
source field (e.g.
title); - an unchanged slug resubmitted while the source field changed is
regenerated from the new source value — the subtle case, because a
mobile edit screen that round-trips the whole record (this package's own
write model, same as
Medialibrary's andSpatie tags' "wholesale" sections above) resubmits the stored slug alongside the changed title, and spatie does not treat that resubmission as a deliberate custom value.
A panel using ->preventOverwrite(), ->generateSlugsOnUpdate(false), or
any other SlugOptions combination behaves the same way on mobile as it
does on web — none of those options change where the custom-value check
runs, only when generation is attempted at all. disabled: true publishing
was considered and rejected for this reason: it would remove the ability to
submit a custom slug that the web panel's own form still has, in every
SlugOptions shape. See
docs/superpowers/specs/2026-08-21-p16-sluggable-design.md for the full
refutation, and tests/Feature/SluggableTest.php for the pinning test that
fails first if a vendor upgrade ever changes this.
#Translatable
spatie/laravel-translatable's manual convention — a TextInput::make
per locale, dotted onto the same attribute (caption.ar beside
caption.en) — was already fully supported before this feature: two
ordinary dotted TextInput nodes are indistinguishable from any other
dotted path as far as the walker is concerned. What P17 adds is: the merge
that is supposed to protect an unsubmitted locale now actually holds on a
real HasTranslations model, and the schema carries enough metadata for a
client to render one field with locale chips instead of N fields stacked
under labels like "Ar" and "En".
#The merge guarantee is now real
RecordForm::storedPaths() preserves every dotted attribute a submission
does not mention, so a PUT naming only caption.en must not wipe
caption.ar. On a genuine HasTranslations model that guarantee was
not holding: $record->getAttribute('caption') returns the current
locale's string, never the locale map, so the method's is_array() guard
silently skipped the attribute and preserved nothing. Only Spatie's own
model-layer merge (setTranslations() re-reading getTranslations()
before writing) was masking the gap — invisible on every fixture this
package's own tests used, because none was built on the real trait.
storedPaths() now reads a translatable attribute through
getTranslations($attribute) instead — the same method_exists pair
RecordSerializer::read() already gates on, never a
spatie/laravel-translatable import — and gets the real per-locale map
back. Pinned on a trait-backed fixture: the merge is asserted directly
against storedPaths()'s own output, the endpoint round-trip is asserted
separately, and a crafted caption: [] is shown not to wipe stored
locales.
#panel.locales
/schema's panel block gains an optional locales key — a flat
list<string>, e.g. ["en", "ar"]. Two sources, tried in order, never
merged:
- the official
filament/spatie-laravel-translatable-plugin, when registered on the resolved panel (getDefaultLocales()); - else
config('filament-mobile.locales')— the host declaration for a panel built on the manual dotted-field convention, with no such plugin registered; - else the key is absent, never
[]. A dotted field name is never read as evidence of a locale — guessing one fromcaption.arwould publish a fact the panel never declared.
A client treats this list as chip ordering only. The chips themselves
still come from the form's own translatable fields, which stay the
source of truth for which locales actually exist.
#The translatable annotation
A leaf whose name is dotted, and whose head segment (everything before the
first dot) is one of the model's own getTranslatableAttributes() — the
same trait-detection gate the merge fix above uses — publishes
translatable: true:
{ "type": "text", "name": "caption.ar", "translatable": true }
Only-when-true, the writable/placeholder precedent: never false,
never on an undotted field, and never on a scalar sibling a collision
fixture might declare (caption beside caption.ar stays untouched — the
same collision shape the write path already refuses to conflate). A client
derives {attribute, locale} by splitting the published name at its
last dot; this key publishes only the one fact the name does not already
carry — whether that split means anything.
#The official plugin's divergence — and what doctor says about it
filament/spatie-laravel-translatable-plugin takes a different shape
entirely: one undotted TextInput::make('caption'), with the plugin
itself swapping which locale's value that single field reads and writes
as the panel's own locale changes. This package does not special-case
that shape: mobile edits whichever locale app()->getLocale() resolves to
at request time, for that field, and nothing on the wire lets a phone
switch locale for it independently the way the chip group does for a
dotted field. filament-mobile:doctor names every such field:
CompanyResource.caption: undotted field on a translatable attribute — mobile edits the panel's current locale only for this field
Informational only — it does not change doctor's exit code. To let a
phone edit every locale of that attribute independently, declare it the
manual dotted way (caption.ar / caption.en side by side) instead.
#Configuration
// config/filament-mobile.php
'locales' => ['en', 'ar'], // only for the manual convention, no plugin registered
Leave it null (the default) when the official plugin is registered —
its own getDefaultLocales() wins — or when no client-side chip ordering
is needed at all.
#Known weaknesses, stated now
- The official plugin's undotted fields stay single-locale on mobile.
Editing every locale of one of those fields from a phone needs the
manual dotted convention instead;
doctornames every field this affects. panel.localesis ordering only. A locale in that list with no matching dotted field contributes no chip, and a dotted field the list does not name still gets a chip, ordered after the ones it does name.
#Key/value
KeyValue::make('meta') is a working, editable field on the phone — a
free-form set of string key/value pairs.
KeyValue::make('meta')
->keyLabel('Key')
->valueLabel('Value');
publishes a keyvalue node:
{
"type": "keyvalue",
"name": "meta",
"config": {
"addable": true, "deletable": true,
"editableKeys": true, "editableValues": true,
"keyLabel": "Key", "valueLabel": "Value",
"keyPlaceholder": null, "valuePlaceholder": null
}
}
The value is a Map<String, String> on the wire, by construction. The
field's own rule is array and nothing narrower — keys and values are
strings by construction, and this package validates neither key uniqueness
nor row count for this type.
The getters are canEditKeys() / canEditValues(), not the setter
names. KeyValue's setters are editableKeys() / editableValues(); its
getters are canEditKeys() / canEditValues(), alongside isAddable() /
isDeletable() — measured against vendor/, not guessed. Reading the setter
name through this package's guarded reader would return null and fail
open, publishing every field as editable regardless of what the panel
configured. SchemaWalker::config() reads the correct four accessors, all
defaulting to true to match Filament's own defaults.
The four gates are client hints, not enforced by the write path — say this
plainly, because it is easy to assume otherwise. RuleExtractor constrains
meta to array and nothing more: a crafted request can add, remove or
rename a key an editableKeys: false gate says it should not be able to,
and the write path persists it verbatim. This matches Filament itself —
the web panel's own dehydration never re-checks these flags either, so
mobile is no looser than web — but it is a real gap from disabled, which
this package does enforce (WritableNames refuses the whole field). It
is left as a hint rather than built out: enforcing it needs the record's
previously-stored keys at validation time to diff against (which keys were
added, removed, or renamed), a different shape of rule than anything else
this package validates, for a field with no reported misuse. An
all-four-gates-false KeyValue is effectively read-only today and could
join WritableNames using the same machinery disabled already uses, if a
panel author ever reports relying on the gates as authorization.
#Known weaknesses, stated now
- No reordering, matching the repeater — this package's own widget has never offered one for either array-valued field.
- No key-uniqueness validation. A duplicate key entered on the phone collapses in the submitted map, exactly as it does on the web.
- The four gates are advisory, per the paragraph above.
#Colour
ColorPicker::make('accent') is a working, editable field on the phone.
ColorPicker::make('accent')->rgba(),
The node publishes one thing — the format the panel declared:
{ "type": "color", "name": "accent", "config": { "format": "rgba" } }
format is a closed set — hex (Filament's default), hsl, rgb,
rgba. Anything else normalises to hex, because a client cannot act on a
fifth value, and a throwing format() closure degrades to hex rather than
failing the document.
The value is never converted. A field declared rgb gets rgb back, byte
for byte wherever the user did not edit it. The client parses all four formats
and emits the one it was given; it never offers to switch representation, and
it never "helpfully" normalises rgb(51, 102, 153) into #336699.
The phone renders a text field with a live swatch, not a colour wheel — this package takes no colour dependency, and a hand-rolled picker's colour maths is easy to get subtly wrong and hard to test. A malformed value blocks submission, but only once the user has edited that field: the client must not invent a constraint the server does not have, and it must not block a save over a value that was already in the database when the form opened.
#Known weaknesses, stated now
- No graphical picking. The field is typed, not picked.
- No format conversion, deliberately — see above.
- The
hslpattern rejects a fractional hue while accepting fractional saturation and lightness. That is faithful to Filament's own documented regex rather than a decision this package made.
#Time and date bounds
TimePicker::make('opens_at') is a working, editable field, and date and
datetime now publish the bounds they always declared.
TimePicker::make('opens_at')->minDate('09:00')->maxDate('17:00'),
DateTimePicker::make('published_at')->minDate('2026-01-01')->maxDate('2026-12-31'),
{ "type": "time", "name": "opens_at",
"config": { "minDate": "09:00", "maxDate": "17:00", "seconds": false,
"minutesStep": 15 } }
TimePicker costs almost nothing on the server, because it is a five-line
class: extends DateTimePicker, overriding only hasDate(). It inherits every
accessor the date branch already reads, so time widens that branch rather
than copying it.
A bound has two wire shapes, and both are published verbatim rather than
normalised: ->minDate('09:00') publishes "09:00", while a Carbon publishes
"2026-01-01 09:00:00". Normalising a bare time into a full datetime would
invent a date the panel never chose, so the client parses both instead.
Steps publish. hoursStep / minutesStep / secondsStep appear in
config on datetime and time nodes only when the evaluated value is
greater than 1 — absent means 1, Filament's default — and never on a date
node, which has no time grid. A throwing step closure degrades that one key,
as every closure-backed read does. The keys are advisory, the repeater
reorderable precedent: they state what the field was configured with, for a
host rendering its own picker. The server enforces no step, and nothing should
snap a picked value to one — that would make mobile stricter than the panel.
Until this release no bound reached the client at all — the walker never
published config for a date node, while the Flutter client had parsed
minDate/maxDate and passed them to its picker since the day it was written.
The code was wired and dead.
#Known weaknesses, stated now
- Bounds are hints, not rules — final ruling, by web parity. Filament's
own web panel does not enforce
minDate/maxDateserver-side either (its JS picker restricts choice; no validation rule), so a mobile client enforcing them would be stricter than the panel it mirrors. The server refuses an out-of-range value only if the panel declared a validation rule saying so; publishing a bound does not create one. disabledDatesis not published — finally, not yet-to-build. It is closure-evaluated, which on this contract means schema-generation time, and/schemais ETag-cached: a dynamic list would freeze at build time and keep answering, silently stale, until the panel code changed. A hint that goes silently stale is worse than no hint. The day a host asks, the answer is per-record evaluation on/state— no host has asked.firstDayOfWeek,timezoneanddisplayFormatare not published. The stock Material date picker derives the first day of week from the device locale and takes no parameter, so publishing it would state a capability no client can honour; timezone and display format stay client-local by the standing ruling.
#Relations

A resource's getRelations() — the same relation managers a Filament panel
already declares — becomes paginated child lists on mobile, writable when
the related model resolves to exactly one registered resource (see Writes
below), and searchable/sortable where the host declares it per relation
(see Search and sort below). The manager's own table is never introspected
for any of it: its filters are ignored permanently, and its
isSearchable()/isSortable() columns are never read — an undeclared
relation is served in relation order. Nothing is declared to opt the list
itself in; every relation manager a resource's getRelations() returns is
introspected.
GET /api/mobile-panel/{resource}/{record}/relations/{relation}
mirrors index(): the same ?page= handling, the same
{data, meta: {current_page, last_page, per_page, total}} envelope, the
same RecordSerializer, and the same eager loading — the card's dotted
fields drive ->with(), so subtitle('company.name') costs one query for
the page rather than one per row. A client that can render a resource list
can render this with no new parsing.
Neither endpoint honours a client-supplied perPage; both use
config('filament-mobile.per_page'). The manager's filters remain the
deliberate non-mirror, above; ?search=/?sort=/?direction= are answered
where the host declares them — see Search and sort below.
/schema gains a relations array per resource — always present, []
for a resource with none, never an absent key:
{
"key": "banners",
"label": "Banners",
"card": { "title": { "field": "name" }, "subtitle": { "field": "status" } },
"recordKey": "id",
"resource": "banners",
"search": { "enabled": false },
"sorts": []
}
keyisgetRelationshipName()— what the endpoint above is addressed by, never the manager's own class name, which is not URL-safe and leaks the host's namespace.labelcomes from the manager's own title — an explicitprotected static ?string $title,getRelationshipTitle(), or a related resource's plural label, in that order — falling back to a humanisedkeyonly when reading the title needs an owner record the discovery step doesn't have.cardis derived from the manager's owntable()columns: the first becomes the title, the second the subtitle, the rest are dropped this slice (see Known weaknesses below). Override withMobileResource::relationCard($key, fn (MobileCard $card) => ...)— the same escape hatchcard()already is — when the derived card is wrong or empty.recordKeyis the related model's owngetRouteKeyName(), not the parent's — routinely a different model with a different key.resourceis the child resource's key, present only when exactly one registered mobile resource owns the related model — zero owners or several and the key is absent, and the relation is read-only. One resolution drives both the key and the write endpoints' answer, so the published schema and a404can never disagree about whether a relation is writable. Absent means unavailable, the standing rule: a client must not invent a write target the server did not declare.search/sortsare the same shapes the resource block publishes —{ "enabled": bool }and a list of{ "key", "label", "default", "direction" }— and are always present on a current server: an undeclared relation publishessearch: { "enabled": false }andsorts: []. An absent key means a server predating P11 — a client reads absentsearchas disabled and absentsortsas[], never as an error. See Search and sort below.
A relation the package refuses (see below) is absent from relations,
not published disabled — the package's standing rule: absence means
unavailable, never a disabled corpse.
#Three gates, each failing closed
- The parent's own
show()authorization — both halves. The resource's class-levelviewAnyand the record's ownview. With only the first, an ownership policy that refusesGET /companies/1would still hand over/companies/1/relations/banners, rows and all — and the200would confirm the row exists into the bargain. RelationManager::canViewForRecord($ownerRecord, $manager), passed the manager's own class-string. That is not a guess: it's Filament's own no-page fallback (CanAuthorizeAccess:$this->pageClass ?? static::class), and it fails in the safe direction — an override that branches on a specific page class never matches it and falls to itselse.- The child model's own
viewAny, through the sameAuthorizerevery other gate in this package uses.
Gate 3 is inside gate 2 by default, not independent of it. Filament's
default canViewForRecord implementation is itself
authorize('viewAny', $model, ...), so an ordinary manager is checked
twice by the same rule. Gate 3 only earns its keep against a panel that
overrides canViewForRecord, which replaces that check rather than
adding to it. Both are still run, unconditionally.
Gate 2 runs under guard impersonation, and here is why it must.
Filament's default canViewForRecord resolves its user through
Filament::auth()->user() — the panel's guard, not the request's.
Nothing on this route rewrites that: auth:{guard} middleware moves the
default guard (so the bare Gate facade does follow the request's user),
but the panel keeps its own, separately configured authGuard. Left
ambient, this gate answers about whoever happens to hold a panel session —
measured both ways in this package's own tests: a 403 for a user their
own policy would allow, and a 200 serving rows to a caller because an
unrelated admin's browser session rode along. The second is privilege
escalation. So the request's user is impersonated onto the panel's guard
for exactly the duration of the gate call, and the previous occupant is
restored in a finally — in-memory only, no session written, undone even
when the gate throws. A null request user refuses outright, before the
guard is touched at all: binding null only clears the guard's cache, and a
session guard then re-reads its own cookie and answers for whoever that
session belongs to, not "nobody" — impersonation cannot enforce a refusal
here, so the controller refuses first.
A refused or non-existent relation is a 404, not a 403. A 403 would suggest it might appear for someone else; a relation this package will never publish for anyone does not exist as far as this API is concerned.
#The refusal — a narrowed relation is not published at all
A relation whose table adds query scopes (->modifyQueryUsing(...)) is
refused entirely: absent from /schema, its endpoint 404s, and
filament-mobile:doctor names it with the reason.
This was verified, not assumed, against Filament in vendor/. Outside
Livewire — with a bare, unbooted relation manager, and separately with the
package's own HeadlessTableHost — Table::getQuery() returns a Builder
whose model is NULL and whose SQL is select * where "status" = ?: the
author's modifyQueryUsing closure survived, but the relation binding to
the owner record did not. Two different hosts, the identical unusable
result. The narrowing is detectable — Table::$queryScopes, read by
reflection, is non-empty exactly when a table declares one — but not
reproducible: there is no means this package already has to rebuild that
query correctly outside Livewire. Publishing the unnarrowed relation would
list rows the web panel deliberately hides — a data-exposure failure, not a
cosmetic one — so the relation is refused wholesale rather than served
without its scope. A future maintainer tempted to "just fetch the rows
anyway" should re-read this paragraph first.
Also refused, each named by doctor:
- a
getRelations()entry that is not a plainRelationManagersubclass —RelationGroupandRelationManagerConfigurationare both legal Filament entries and neither is handled this slice; - a relation whose
getRelationshipName()cannot be read; - a relation whose relationship does not resolve on the resource's model —
protected static string $relationship = 'ghosts'where nothing on the model is namedghosts. Checked by building the relationship on an unsaved instance (building never queries). Unchecked, such a relation was published by/schemaand its endpoint then answered 403, because Filament's defaultcanViewForRecordcannot resolve the related model either — a control that cannot work, filed under "not for you"; - a relation whose columns yield no usable card and for which the host declared none;
- a relation whose
relationCard()fills no slot —relationCard('banners', fn ($card) => $card). The rule is "no card ⇒ no relation", and the test is emptiness, not nullness: an unconfigured card is not null, and it serves rows carrying nothing but their record key; - a
relationCard()key naming no relation this resource declares — a typo likerelationCard('bannerz', …). The declaration is inert and the derived card is used instead, so the card you wrote was simply never read.
A closure that returns something other than the MobileCard it was given —
usually a block body missing its return — is refused by relationCard()
itself, at declaration time.
#Search and sort — host-declared per relation
MobileResource::make()
->relationSearchable('tags', ['name', 'slug'])
->relationSorts('tags', ['name' => 'Name', 'created_at' => 'Created'])
->relationDefaultSort('tags', 'name');
The declarations are host declarations, keyed by relationship name —
the same ruling the resource level already made, and for its reasons: the
mobile surface is an explicit opt-in, sorts carry host-worded labels, and
the one table feature that could be introspected (filters) is deliberately
never read. A relation is a smaller list, not a different philosophy, so the
manager's own isSearchable()/isSortable() columns are never consulted.
Semantics are the resource level's exactly: relationDefaultSort() throws
on an undeclared key and normalises/rejects the direction at declaration
time, and a relationSorts() call after relationDefaultSort() drops a
dangling default. Plain columns only — a dotted path would need a whereHas
and is deferred, the index's own ruling, inherited.
A stray declaration — a relationSorts('tgos', …) typo naming no relation
the resource declares — is refused, not stored silently:
RelationDiscovery::strayDeclarationKeys() checks the three declaration
maps exactly as it already checks relationCard() keys, and
filament-mobile:doctor names each one with the method that declared it.
The endpoint answers the three parameters with the index's exact contract:
?search=— LIKE over the declared columns, the same!-escaping and one-where-group scoping as the index'sapplySearch(). The group stays inside the relationship's own constraint.?sort=+?direction=— validated against the relation's declared sorts; an unknown key is a422, never a silently ignored parameter. A declared default applies when?sort=is absent;?direction=defaults to the default sort's direction when the default key is in play, elseasc.- A non-string parameter (
?sort[]=x) is the same422the index'sstringQuery()promises. - Validation runs after the full gate sequence — resource 404 →
viewAny403 → relation 404 → record 404 → recordview403 → relation gate — so a403/404always wins over a422: a validation error must never leak whether a relation exists for a record the caller cannot see. - Against a relation that declares nothing, search/sort are inert —
enabled: falsemeans there is nothing to apply — except an undeclared sort key, which still 422s: the sort parameter claimed a capability, the search parameter did not. Filters stay out, permanently.
The machinery is one mechanism, not two: stringQuery(), applySearch()
and applySort() moved verbatim from MobilePanelController to
src/Http/ListQuery.php, which both controllers now share — applySort
takes the resolved (sorts, defaultKey, defaultDirection) triple, so the
resource and relation call sites differ only in where the triple comes from.
#Writes — a child row is created, updated and deleted through the parent
POST /api/mobile-panel/{resource}/{record}/relations/{relation}
PUT /api/mobile-panel/{resource}/{record}/relations/{relation}/{child}
DELETE /api/mobile-panel/{resource}/{record}/relations/{relation}/{child}
A relation offers these only where /schema published a resource key —
one ambiguity answer (ResourceRegistry::ownersOf(): zero owners or several
for the related model) drives both, so the schema and the endpoints cannot
disagree. A write against a relation with no single owner, or one this
package does not publish at all, is a 404, not a 403 — the same ruling
the read path makes, for the same reason: a relation this API will never
serve writes for does not exist as far as a client is concerned.
- The form is the child resource's own, reused whole, and the write runs
the identical machinery
store()/update()run —SettledSchema, the rules as the mass-assignment whitelist, the panel's defaults under the payload, theTagSeparatorsmirror, the relation pass — throughsrc/Write/RecordForm.php, the one home both controllers now share (extracted fromMobilePanelController; nothing about the resource endpoints' behaviour changed). - The gates are the parent's, then the child's. Resolution applies every
gate the read endpoint applies (class
viewAny, recordview, the relation gate under guard impersonation), then the child model's owncreate(class-level — there is no child record yet),updateordelete(against the loaded child — authorization, not capability). - Create goes through the relationship
(
$record->{$relation}()->create(...)), so the foreign key is the parent's by construction — a row is never created floating and checked for membership after the fact. {child}is the related model's own route key — the publishedrecordKey— resolved through the relationship: a child id that exists but belongs to a different parent is a 404, never a cross-parent write.- Status codes are
201/200/200. Delete returns the deleted row's serialized form, deliberately not the resourcedestroy()'s 204: the relation client holds a list it must reconcile, and an empty answer would force a re-fetch to learn what it just removed. The row is serialized before the delete — afterwards, soft-deleted or gone attributes cannot be trusted to read back the same. - A validation failure is a
422keyed by the child's own field names — the shape a top-levelPUTalready returns, so a client renders it with no new parsing. - Attach and detach are deliberately not exposed. Pivot operations are a
different gesture with a different authorization question (which side's
policy answers
attach?), and a relation list is not the UI for it. ABelongsToManyrelation here creates and deletes real child rows.
filament-mobile:doctor's Relations section names a published relation
whose writes are off, distinguishing the two causes — no registered resource
owns the child model, or several do — because the fixes differ (opt one in;
the model is genuinely ambiguous). The relation reads fine either way, so
this is reported informationally, not as a refusal.
#Known weaknesses, stated now
-
A relation manager that narrows its query is invisible on mobile. For some panels that may be most of them —
doctornames each one, but this is the sharpest limitation in this slice and the most likely reason a panel gains nothing from it. -
The
Table::$queryScopesreflection is a compatibility risk across^4.0|^5.0, and it fails closed. If the property is renamed on a future Filament release, a naive implementation would silently return "no scopes" and stop refusing — failing open, in the one place this design cannot afford to. Aproperty_exists()tripwire refuses every relation loudly instead: a test asserts the property exists on both majors and fails if it does not, and the production path throws — caught and turned into a refusal — rather than guessing when the property is gone. Measured by renaming it invendor/: 26 tests red, zero relations published.The residual risk is the narrower one: Filament keeping the name and moving the mechanism, which the tripwire cannot see. That is caught behaviourally instead — the narrowing-refusal tests red — which is why both kinds of test exist.
-
The relation manager's own filters stay ignored, and its table's search/sort columns are never read. Search and sort exist only where the host declares them per relation (Search and sort above); undeclared, the list is in relation order, unfiltered. Filters are out permanently — the resource level's
'filters' => []ruling, inherited. A panel whose relation manager is only usable with its filters gets a list that is technically correct and practically wrong. -
Only the first two columns become a card. A relation whose meaning lives in its third column looks empty of information.
-
Attach and detach are not exposed. Create, update and delete through the relationship are; pivot operations are not — see the Writes section above. A relation whose child model has no single owning resource stays read-only, and
doctorsays which of the two causes applies.
#Rich text
A RichEditor-backed infolist entry stops publishing raw markup and starts
publishing a document. Read only, this slice: the form field is still a
textarea over the raw HTML string, exactly as it always was — editing a
rich column is out of scope, so nothing about the write path changes and no
existing form test is affected.
GET /api/mobile-panel/{resource}/{record}
data.<path> keeps the raw string, byte-for-byte, exactly as before — the
form prefill still reads it. The two derived shapes travel together on a
flat sibling key:
{
"id": 1,
"body": "<p>Hello <strong>world</strong></p>",
"body.__rich": {
"doc": { "type": "doc", "content": [/* … */] },
"text": "Hello world"
}
}
An undotted column name cannot carry two shapes at once — the form wants the
raw string it edits, the infolist wants the document, the card wants plain
text, and that is genuinely three consumers wanting three things from one
column. withInfolistPaths() plus withFormPaths() on the same name was
measured to collapse to one key (the form pass writes flat and runs last),
so the two derived shapes ride together on <path>.__rich instead — the
same flat-sibling convention caption.ar already established for
translatables, for the same collision reason. Absence means unavailable:
a column with nothing to convert (null, empty, or a conversion that throws)
gets no sibling at all, never an empty one, and every consumer falls back to
the raw string it already has.
An infolist entry becomes rich_entry — joining ComponentTypeMap::REFINED,
exactly as badge_entry does — when either holds:
- the entry called
->prose(); or - the resource's model implements
HasRichContentandhasRichContentAttribute($name)is true for this column.
Neither answering leaves the entry text_entry, which is today's behaviour,
not a broken one — a gate that cannot answer refuses rather than guesses.
#Why it must be Filament's own renderer
Conversion runs the stored column through Filament's own
RichContentRenderer, never a bare new Tiptap\Editor. This is not a style
preference. Measured on identical input, a bare editor silently drops marks
it has no extension registered for:
new Tiptap\Editor → {"type":"text","text":" and link"} ← link mark GONE
RichContentRenderer → {"type":"text","text":"link",
"marks":[{"type":"link",
"attrs":{"href":"https://x.test"}}]}
Both outputs parse. Only one of them is the document the column actually holds.
It is a default-configured RichContentRenderer::make(), not the panel
component's own RichEditor::getTipTapEditor() — which adds
->plugins(...)->linkProtocols(...). The difference is measurable, and it is
the same silent-content-loss failure one level up:
RichContentRenderer::make() → tel:+15551234, https://ok.test
RichContentRenderer::make()->linkProtocols(...) → tel:+15551234, myapp://x
A panel that registered myapp for deep links has those links' marks
dropped by this conversion. It is not fixable at this seam:
linkProtocols() is configured on the RichEditor form component and
lives nowhere else — the model's own RichContentAttribute::getRenderer()
carries plugins but no link protocols at all — so honouring it would mean
building a resource's form schema for every record on index(), the exact
per-request cost the narrowed ->prose() promise below exists to avoid, and
it would still leave the ->prose() half unconfigured. Listed under Known
weaknesses.
The vocabulary is small and closed, measured through RichContentRenderer
over every construct a RichEditor toolbar offers:
NODES: doc, paragraph, text, heading, bulletList, orderedList,
listItem, blockquote, horizontalRule, image
MARKS: bold, italic, link, strike, underline, code
No tables, no custom TipTap blocks. textAlign is published on paragraphs
and headings but not yet honoured by the client — it belongs with the
RTL/i18n slice.
#A security property, stated because it is easy to lose
Conversion runs the column through TipTap's own extension whitelist, and
anything outside it — <script>, <style>, inline event handlers, an
unknown tag, a javascript:/data:/vbscript: href — is dropped. Neither
half of the __rich sibling can carry executable markup regardless of what
is stored in the column, a stronger guarantee than today's behaviour, where
the raw string reaches the client untouched.
Two things make that true of both halves rather than only the document, and both were defects first:
textis flattened from the converted document, not from the column.strip_tagsremoves tags but keeps a<script>body, so flattening the raw string publishedokalert("pwned")beside a correctly-sanitised document — on the card, which is the most-seen surface in the app. One conversion now produces both shapes, so the whitelist governs them by construction rather than by two code paths agreeing.- A JSON-valued column re-enters through the whitelist. TipTap treats
any string that
json_decodes cleanly as JSON and loads it unvalidated (itsSchema::apply()strips marks and never filters node types), so a->json()column stored as a string publishedevilNode,onclickand ajavascript:href verbatim. The JSON path is now re-serialised to HTML and reparsed, which is a measured no-op for a legitimate document.
This is a consequence of the design, not a feature that was built — which is exactly why it has its own test, asserted on the whole envelope rather than on the document alone.
#Cards
RecordSerializer — not either controller — produces the __rich sibling,
so index() and show() never disagree about it. A card slot reads
<path>.__rich.text: the converted document flattened, then tags stripped,
entities decoded and whitespace collapsed via PlainText::of() — the same
routine allowHtml() option labels already use, extracted rather than
reimplemented so the two paths cannot drift apart.
The promise is narrower than "cards never disagree", and stated precisely
because it is easy to overstate: it holds for a model-declared rich
column and not for a ->prose()-only one.
HasRichContent+registerRichContent('body')is a fact about the column.RecordSerializerresolves it from the record itself, so every endpoint publishes the sibling with nothing wired, and a card bound to that column renders clean text on the list and the detail screen alike.TextEntry::make('note')->prose()is a declaration about one infolist entry. It governs that entry, not the card, which is a different surface — andindex()builds no infolist and would have to build and walk one on every list request to learn otherwise, a cost it exists to avoid.
So a card slot bound to a ->prose()-only column gets a document on
show() and no sibling on index() — the list renders raw markup next
to a detail screen that renders clean. filament-mobile:doctor names
exactly this combination under "Rich text on cards" and says which one-line
model change fixes it:
Banner: card fieldnoteis rich only because the infolist calls->prose(), so the list endpoint publishes nonote.__richand the card renders raw markup — register it onBannerwithHasRichContentto fix it
#Known weaknesses, stated now
- No editing. The form field stays a
textareaover the raw string, so a user reads formatted text and edits markup. A real editor is a much larger build with two runtime dependencies on the client, and today's behaviour loses nothing by staying as it is. - An unwired host loses link visibility on the client. See the Dart README's Rich text section.
- No tables, no custom TipTap blocks. Both fall outside the measured vocabulary.
- Panel-registered plugins and link protocols are not inherited. The
conversion uses a default-configured
RichContentRenderer::make(), so a->linkProtocols(['myapp'])panel loses those links' marks — see "Why it must be Filament's own renderer" for why the component is out of reach from this seam. textAlignis published and not honoured. It belongs to the RTL/i18n slice.- The conversion is memoised per request, keyed by the raw string on the
RecordSerializerinstance — one serializer per request, so the memo's lifetime is the request's. Nulls are memoised too: a value whose conversion degrades does not pay for its failure twice. TheRelationDiscovery::for()half of the caching pass this bullet used to promise was measured and deliberately not done — the split already runs exactly once per resource per request at every HTTP entry point, so there was no redundancy left to remove.
#Dashboard
Opt named StatsOverviewWidget and ChartWidget subclasses into one
read-only endpoint — the same "invisible until named" safety property every
other feature in this package has. A dashboard widget runs arbitrary
queries, so auto-discovery would serve them to phones the moment someone
adds a widget to the web dashboard; this package never does that.
// config/filament-mobile.php
'widgets' => [
App\Filament\Widgets\OrdersOverview::class,
App\Filament\Widgets\RevenueChart::class,
],
Order is the array's order, and it is the publication order. Only
StatsOverviewWidget and ChartWidget subclasses are supported.
TableWidgets (a table widget is a resource list with extra steps — the
list endpoint already serves that shape better), custom Widget subclasses
with hand-written Blade views (no data contract to read), widget
filters/forms, and per-widget polling are not — filament-mobile:doctor
reports a configured class that is any of those.
GET /api/mobile-panel/dashboard
{
"widgets": [
{
"type": "stats",
"heading": "Store overview",
"description": "Orders at a glance",
"stats": [
{
"label": "Orders this week",
"value": "1,340",
"description": "12% increase",
"descriptionIcon": "heroicon-m-arrow-trending-up",
"color": "success",
"chart": [7, 12, 9, 15, 22]
}
]
},
{
"type": "chart",
"heading": "Revenue",
"description": "Last 12 months",
"chartType": "line",
"labels": ["Jan", "Feb", "Mar"],
"datasets": [{ "label": "Revenue", "data": [120, 340, 210] }]
}
]
}
Deliberately not part of /schema: a widget's values are computed per
request — every read runs its queries — so they do not belong in a
document whose whole value is being static and cacheable. A host with no
dashboard pays nothing for this endpoint's existence, and pull-to-refresh
re-runs the queries, which is what a dashboard wants anyway.
- Authorization is the widget's own
Widget::canView(), called per request per user — the same static the web dashboard consults. There is no resource-level gate here, because there is no resource. - A broken widget degrades; it never 500s the dashboard. A
canView()that throws, a widget that cannot be constructed, amount()that throws, and a data method that throws are all indistinguishable on the wire — the widget is simply absent fromwidgets. Reasons surface only in_warnings(non-production) andfilament-mobile:doctor, never to the client. One bad query must never take every user's dashboard down. mount()is invoked, not just the constructor.ChartWidget::mount()is a real Livewire lifecycle hook a real widget can depend on (it seeds$filterand similar state), and plainnew $class()never calls it — construction alone is not the whole lifecycle.valueis always a string.Stat::getValue()ismixed— a panel returns ints, floats,Number::abbreviate()output, money strings. The phone cannot know the panel's formatting intent, so the server stringifies once; a value object's__toString()is honoured, and something genuinely unrenderable becomesnulland warns, never a silent"".datasetsis normalised, not Chart.js passthrough. Onlylabeland a numericdatalist are published; a dataset without numericdatais dropped with a warning rather than shipped as "whatever Chart.js accepts". Numeric strings count — MySQL PDO returns strings forDECIMALaggregates, and they publish as floats. An empty series (zero rows this period) publishes as[], a normal state, without a warning.widgets: []is a valid, ordinary answer — not a 404 — for a panel with no opted-in widgets, or a request where every configured widget was denied or broke.
Known weakness: one endpoint means one slow widget slows the whole response. Every configured widget's queries run inside the same request; there is no per-widget fetch. A per-widget endpoint was considered and rejected for N-requests-per-open — if a real panel's dashboard shows this hurting, the fix is a second endpoint added beside this one, not a replacement for it.
#Locale and direction
/schema's panel block carries two more keys:
{
"version": 1,
"panel": {
"id": "mobile",
"title": "Acme Admin",
"locale": "ar",
"direction": "rtl"
},
"resources": []
}
GET /dashboard publishes the same direction key beside widgets, for
the same reason /dashboard exists as its own endpoint at all: it carries
no schema for a client to read a direction off of.
direction is Filament's own answer, not a locale table this package
maintains. It is __('filament-panels::layout.direction') — the exact
key the web panel itself lays out with — normalised to exactly ltr or
rtl. The phone and the panel agree by construction: there is no mapping
from locale codes to direction for this package to keep current as
Filament ships more locales.
Read the namespace carefully — this is the one part of this feature that
fails with no error. The key lives under the filament-panels
namespace, not filament::. Measured against filament/filament 5.7.5:
filament-panels::layout.direction resolves to 'ltr'/'rtl' for every
one of its 62 shipped locales; filament::layout.direction does not
resolve at all and returns the raw key string back. Get the namespace
wrong and every panel silently renders ltr forever — no exception, no log
line, nothing that flags it as broken. It is one character class away from
correct.
The fallback chain is deliberately generous, because a translation call is
not something /schema can afford to let fail the whole document:
- A locale Filament does not ship falls back to
ltrthrough Filament's own translation chain —__()never returns the bare key for this one, so no guard is needed on this package's side either. - A panel that overrode the key with something other than
'ltr'or'rtl'— a typo, a placeholder string — normalises toltr. The contract is a closed set; a value no client can act on is treated the same as no value at all. - A translator that throws (a custom
Translatorbinding, a broken language file) degrades toltrrather than 500ing/schema. This is the same per-property degradation the rest of the document already applies to a broken accessor — one bad translation must not take the whole panel down.
locale is app()->getLocale(), published for a client that wants it for
its own formatting; this package does not use it to decide direction —
that is read from its own key, independently.
The /schema ETag needed no change for any of this, and that is worth
stating so nobody "fixes" it later. The ETag is a sha1 hash of the
content actually built (see Schema caching, below) — once locale and
direction are in the document, they are already inside the hash. A panel
whose locale changes gets a new ETag for free, the same way a changed label
already does.
#Schema caching
GET /api/mobile-panel/schema sends an ETag on every response — a weak
validator, W/"<sha1 of the built document>". A request that sends a
matching If-None-Match gets back a 304 with an empty body, still
carrying the ETag.
GET /api/mobile-panel/schema
If-None-Match: W/"a1b2c3..."
HTTP/1.1 304 Not Modified
ETag: W/"a1b2c3..."
The hash is taken before _warnings is attached, and that is
deliberate. _warnings is a dev-only, environment-dependent field —
present outside production, absent inside it — and no part of the
contract. Hashing it in would move the ETag between environments for a
document that is otherwise identical, so a client's cache built against
local would revalidate to a full 200 in production for no real
change. Hashing what actually defines the document, and nothing else, is
what makes the ETag stable across environments and unstable exactly when
the document itself changes.
A content hash, not anything derived from identity. /schema is
filtered by policy, so two signed-in users legitimately see different
documents — hashing what was actually built gets that right without this
endpoint knowing anything about who is asking: two users who happen to see
the same resources get the same ETag and both cache correctly; a policy
change that alters one user's document changes their hash and leaves
another's alone.
If-None-Match is read tolerantly, matching a comma-separated list, and
either the weak (W/"…") or strong ("…") form of the ETag — a proxy along
the way may rewrite either. * matches unconditionally, per RFC 7232 §3.2.
Known cost, stated plainly: a 304 saves bandwidth, not server CPU. The
document is still built in full to compute its hash — there is no
server-side cache of the built document itself (that is the separate,
deliberately-deferred win; see the design spec's Scope section for why it
was ruled out this slice: it saves CPU but adds invalidation questions —
deploys, policy changes — that ETag/If-None-Match does not carry). A
client that persists the document and revalidates with If-None-Match
saves the ~200 KB transfer on an unchanged panel; it does not make /schema
itself cheaper to serve.
An unencodable document fails loudly, not silently. json_encode()
returns false on invalid UTF-8 (a realistic trigger: a translated label
with a bad byte sequence), and hashing (string) false — '' — would give
every failing document the same ETag, so two genuinely different broken
documents would collide and a client could keep a stale panel forever with
no way to notice. This endpoint throws instead.
See dart/filament_mobile/README.md's Schema caching section for the
client half: the host-supplied FilamentSchemaCache and
FilamentConditionalTransport ports, cold-start-render-then-revalidate, and
the cache key's per-user scoping obligation.
#Authorization
The panel's existing policies are the only permission model — there is no
second one. A resource the user cannot viewAny is absent from /schema,
not merely flagged. Filament's semantics are followed exactly, including the
no-policy case (permitted, but Gate::before still applies), so mobile is
never looser than the web panel.
Resource-level permissions report capability; the per-record truth travels
with each record on the detail endpoint, evaluated against the real model.
#php artisan filament-mobile:doctor
Reports which resources are exposed, which components could not be walked,
drift between mobile() and table(), card paths that resolve to nothing,
relations it refuses (with the reason), stray per-relation declarations (a
relationCard()/relationSorts() key naming no relation the resource
declares), published relations whose rows
are read-only because no single resource owns the child model —
distinguishing zero owners from several, because the fixes differ — and
Medialibrary-specific findings: a media component on a
model without HasMedia, one whose name collides with a real column, and a
card slot bound to a media path with no HasMedia to read it from.
Exits non-zero on anything actionable, so CI can gate on it.
In a policy-guarded panel, pass --user. By default doctor builds the
panel document as an anonymous user, so viewAny denies everything, every
section reads (none) because nothing was inspected — not because nothing is
wrong — and the run can never go green:
php artisan filament-mobile:doctor --user=1
php artisan filament-mobile:doctor --user=admin@example.com
The id or email is resolved through the provider behind the guard in
filament-mobile.guard, so doctor sees exactly what that user's phone would.
This is the form to run in CI. Without it, resources are listed under "Not
inspected" and the command still exits non-zero.
#Configuration
return [
'prefix' => 'api/mobile-panel',
'per_page' => 20, // not client-controllable, by design
'guard' => null, // null = application default; see the warning above
'resources' => null, // null = read the registered panel
'widgets' => [], // dashboard widgets, by class name, in publication order
];
resources is an explicit opt-out of panel discovery — useful where no panel is
booted, or to serve a deliberate subset.
#Known limitations in this release
Measured against a real 35-resource production panel.
- 91.7% of components walk cleanly. The rest emit a warning and are omitted
— never silently dropped.
Schemas\Components\Livewireis out of scope. (86.4% was the rate beforeCheckboxListwas mapped; see the pilot's §10.)FileUploadnow walks as an editable field, single or->multiple()— see the Upload section above.Repeaternow walks as an editable field too — see the Repeater section above;Repeater::relationship()writes through Filament's own relation pass, at a delete-all-then-recreate cost per save.RichEditornow walks too — atextareaon the form always (see Rich text above), andrich_entryon the infolist where->prose()or the model's ownHasRichContentsays the column is rich.Radio,TagsInputandKeyValuenow walk as editable fields too — see the Radio, Tags and Key/value sections above. So doToggleButtonsandSlider— see the Toggle buttons and Slider sections above. None of the percentages above has been re-measured against the pilot panel since these shipped. - Badges carry the raw value, not the formatted label. Supply the colour map
yourself via
badge($field, $colors); the label is the client's job. hiddenmeans different things on the two endpoints./schema's value is an empty-form snapshot and a first-paint hint only;/state's value is authoritative, evaluated against real submitted values. A client may use/schema'shiddento avoid rendering an obviously conditional field on first paint, but must not treat it as truth.DELETEruns the model's delete, not Filament'sDeleteAction. So a resource'sDeleteAction::before()/after()hooks and its "restrict if related records exist" guard do not run for a mobile delete — those live on the action, which is a Livewire construct this package does not host. Model observers, soft deletes and policies are unaffected: they hang off$record->delete()and run exactly as they do in the panel. The pilot measured 1 of 33 resources affected (abefore()hook with an external side effect, so a mobile delete skips it). If a resource depends on an action hook, move that logic to an observer and both panels get it.- Multi-valued relationship fields sync, with one deliberate asymmetry.
Select::multiple()->relationship()andCheckboxList->relationship()are saved through Filament's ownsaveRelationships()after the attribute write. A key absent from the payload leaves the pivot untouched; an explicit[]clears it — absence is not emptiness, so a partial PUT never wipes a relation it did not mention. A disabled relation field refuses both ways: crafted ids neither attach nor degrade into a clearing sync. Singular relationship containers (Section::make()->relationship()) are still not saved and stay publisheddisabled: true. - Dates are ISO-8601 UTC. The panel's display format does not travel.
filtersis always[]. Table filters are not introspected — at the resource level, and per relation (which inherits the ruling permanently; relation search and sort are host declarations instead — see Relations).- Tabs are flattened to sections — deliberately; tabs are a poor phone
control. A
Tabscontainer's own name may surface as a label. helperTextis alwaysnull. Filament exposes no public getter for it on either major, so the contract's field is emitted and never filled. A real contract gap, not a walker bug: helper text does not reach the phone.- An
IconEntrybecomes aboolean_entry. The contract vocabulary is frozen and has no icon type; a boolean icon entry additionally carries its true/false icons and colours inconfig. - A resource's validation messages travel, so a host does not translate
them. Every constrained field's
rulescarries amessagesmap in the panel's locale, generated through the same Laravel validator translation a422for the same submission would produce — seecontract/README.md. A host that translates nothing still shows an Arabic hint in front of an Arabic panel;FilamentStringsremains only as the client's per-rule fallback for a rule the server sent no message for. - A crafted payload that flips a gate closed can silently discard a
legitimate write.
store()/update()settle the schema against trusted state before extracting rules (Write\SettledSchema), and the allow-set only ever shrinks — so a storedkind='unlock'(aHidden, gate open) with a PUT of{"kind":"promo","gate_note":"..."}gets a200withgate_notediscarded rather than a clear rejection. Reachability is low: a client never learnskind, so it has to invent a contradicting value for a field it was never shown. See theponytail:note atMobilePanelController::allowedRules(). - A form with an unreasonable number of chained gates can 500 on
/state.SettledSchema's fixpoint is bounded at 32 passes and fails closed rather than write against state it cannot vouch for; that bound is not a class invariant but roughly the count of writable field names, so a form with more than 31 chained gates could hit it legitimately. See theponytail:note atWrite\SettledSchema::settle(). - A top-level
->hidden()->dehydratedWhenHidden()field is dropped by the write path, though it is genuinely writable./statereports it because it walks withgetComponents(withHidden: true);store()/update()do not, so the field never reachesSettledSchemaor the rule extractor and a submitted value for it is silently never written. Exotic shape; see theponytail:note atMobilePanelController::formComponents().
#Testing
vendor/bin/pest
CI runs the suite against Filament 4 and 5, and separately against MySQL — a
LIKE ... ESCAPE clause once passed on SQLite and failed on MySQL, so one
driver is not enough.
The author
GAIT (Ghanem Artificial Intelligence Technology) is a Cairo-based software house building production-grade software for startups and enterprises across Egypt, the Gulf, and MENA — Arabic-first, bilingual by default.
From the same author
GAIT Friendship
Adds admin panel resources for moderating friendships, pending requests and blocks between users of the ghanem/friendship package.
Author:
GAIT
GAIT Rating
Adds a star rating form input, a sortable average-rating table column, an infolist entry and a review moderation resource for models that use the ghanem/rating package.
Author:
GAIT
Featured Plugins
A selection of plugins curated by the Filament team
Blueprint
Filament Blueprint is a premium Laravel Boost extension that helps AI agents produce accurate, detailed implementation plans and security reports for Filament apps.
Filament
Spotlight Pro
Browse your Filament Panel with ease. Filament Spotlight Pro adds a Spotlight like Command Palette to your Filament Panel.
Dennis Koch
Custom Dashboards
Let your users build and share their own dashboards with a drag-and-drop interface. Define your data sources in PHP and let them do the rest.
Filament