Back to blog

Populate Dynamic Filament Selects from Eloquent Without N+1 Queries or Data Leaks

Manuk Minasyan · · 4 min read

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

StrategyGood forFailure mode
Static options in the schemaTiny, rarely changing listsStale duplicated data
Preloaded Eloquent optionsBounded reference dataLarge HTML/Livewire payload
Async searchable selectMedium/large indexed tablesPoor search query or missing scope
Modal/table selectorComplex filters and rich recordsMore 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:

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:

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:

The form performance guide gives a broader measurement model.

Failure tests worth keeping

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.

Related posts