Populate Dynamic Filament Selects from Eloquent Without N+1 Queries or Data Leaks
Populate Dynamic Filament Selects from Eloquent Without N+1 Queries or Data Leaks
Product::pluck('name', 'id') is convenient until the table has 100,000 rows, labels depend on relationships, or one tenant can guess another tenant's ID. A production option source has three jobs: search and display, authorization, and final validation.
Solving only the first job makes a dropdown look correct while leaving the submission boundary slow or unsafe.
Choose a loading strategy by cardinality
| Strategy | Good for | Failure mode |
|---|---|---|
| Static options in the schema | Tiny, rarely changing lists | Stale duplicated data |
| Preloaded Eloquent options | Bounded reference data | Large HTML/Livewire payload |
| Async searchable select | Medium/large indexed tables | Poor search query or missing scope |
| Modal/table selector | Complex filters and rich records | More interaction and implementation |
Filament 5 supports scoped relationship queries, searchable columns, custom labels, and deliberate preloading. Its default searchable result limit is bounded for a reason. Review the Filament Select documentation before increasing it.
Store a source key, not a model class
For a database-driven form, avoid storing App\\Models\\Product, table names, columns, or closures in JSON. Store a reviewed identifier:
{
"type": "select",
"option_source": "active_products"
}
Application code owns the registry entry:
'active_products' => new OptionSource(
query: fn (Tenant $tenant) => Product::query()
->whereBelongsTo($tenant)
->where('is_active', true)
->with('category'),
searchColumns: ['name', 'sku'],
value: fn (Product $product) => $product->getKey(),
label: fn (Product $product) => "{$product->name} · {$product->category->name}",
),
Form authors select a capability. Developers own the query, scope, eager loads, value, label, and search columns.
Keep list and validation scopes identical
The browser can submit any ID, including one that never appeared in the list. Revalidate the selected value with the same conditions:
- correct tenant or owner;
- active/selectable state;
- expected model type;
- dependent-field constraint;
- soft-delete policy.
Do not use a plain global exists:products,id when the list only showed one tenant's active products. Build the rule from the registered source or a dedicated custom rule so the query cannot drift.
This is especially important outside a Filament tenant panel. Panel resource scoping does not magically attach to every public route or raw Laravel validation rule.
Prevent N+1 label queries
Custom option labels often read relationships:
fn (Product $record) => $record->name.' — '.$record->category->name
Without eager loading, each label can query its category. Select only needed columns, eager-load label dependencies, and assert query counts in a feature test. Be careful that selecting a subset retains foreign keys required for the relationship.
For remote search, index the searched columns, require a minimum query length where useful, cap results, and avoid leading-wildcard searches on unindexed large datasets.
Dependent selects are server rules too
For country → city, the city source uses the selected country to narrow search. Treat the controlling Livewire value as untrusted. The final rule must prove both that the city is selectable and that it belongs to the submitted country.
The UI relationship is covered in our conditional-fields guide; the database relationship must be enforced independently.
Cache with the entire security context
Option caching is useful for small shared references, but the cache key may need:
source + tenant + locale + search + dependencies + permission role
Leaving out tenant or locale can expose names across organizations or display labels in the wrong language. Short-lived caching does not make an unsafe key acceptable.
Preserve historical meaning
Store stable model keys, not labels, as submitted values. Then decide what an old submission should show if the record changes:
- current label for operational truth;
- label from its immutable form revision;
- label snapshot captured on submit.
Deleted or newly unauthorized records may remain readable in historical submissions without remaining selectable in new ones. That display path should never be reused as an active option query. Form versioning explains why the selected ID alone is not enough for audit fidelity.
Measure instead of guessing
Benchmark at 50, 5,000, and 100,000 records. Record initial queries, query time, Livewire payload bytes, rendered option count, search latency, and browser responsiveness for:
- full
pluck(); - deliberate preload;
- async indexed search;
- custom relationship labels.
The form performance guide gives a broader measurement model.
Failure tests worth keeping
- another tenant's ID is rejected even when guessed;
- an inactive ID that was previously valid cannot be newly selected;
- dependent IDs must belong to the controller value;
- label rendering has a bounded query count;
- a deleted option still renders according to the historical policy;
- caches cannot cross tenant or locale;
- huge datasets do not preload during initial render.
A dynamic select is not just a convenient query. Treat it as a server-owned data capability with separate display and validation paths, and it remains fast without becoming an authorization hole.