Back to blog

Multi-Tenant Forms in Filament 5: Isolate Every Boundary

Manuk Minasyan · · 5 min read
Multi-Tenant Forms in Filament 5: Isolate Every Boundary

Multi-tenancy is not a tenant_id column. A public form crosses the admin builder, anonymous routes, Livewire updates, option queries, submissions, files, exports, analytics, notifications, caches, and queued jobs. Tenant isolation is the invariant that must survive all of them.

Filament's own tenancy documentation warns that the feature is security-sensitive and does not guarantee application security. Treat that warning as the test plan.

Map the whole data flow

Start with a diagram:

tenant panel → form/revision → public route → Livewire submit
                                   ├→ options
                                   ├→ submission/files
                                   ├→ analytics/export
                                   └→ queued notifications/webhooks

For every arrow, identify where tenant context comes from, how it is verified, and what happens outside an HTTP request.

Migrate ownership deliberately

If an optional package tenancy flag adds columns only when its migration first runs, enabling the flag later will not modify an existing database. Create an explicit migration, backfill ownership, add foreign keys and indexes, and verify rows that cannot be assigned safely.

Decide whether submissions store a direct tenant ID or inherit it through an immutable form relationship. Inheritance can be sound, but every query must begin from an authorized form or enforce the join. A direct ID can simplify partitioned queries at the cost of another consistency invariant.

Panel scoping is not global scoping

Filament tenant-aware resources receive automatic scoping in the identified tenant panel. Queries outside that panel do not automatically know the current tenant.

Public form routes, signed downloads, console commands, queue workers, APIs, and scheduled exports need explicit context and policies. Audit every withoutGlobalScopes() call: it can remove the very boundary you expected to protect the record.

Public ULIDs are not authorization

An unpredictable form ULID reduces casual enumeration, but possession of an identifier is not proof that the current hostname, organization, or visitor may use it.

Resolve the tenant from a trusted domain/path configuration, then query the form inside that tenant. Verify active state and publishing window server-side. Livewire persistent middleware or action authorization must repeat the critical check on update requests, not only initial render.

Scope options and final validation

A select may show only Tenant A records while a forged request submits Tenant B's ID. Apply tenant scope to both search results and the exists/custom rule used on submit.

Plain Laravel exists and unique rules do not inherit arbitrary Eloquent global scopes. Use explicit tenant constraints or Filament's scoped helpers where appropriate. Our dynamic Eloquent options guide covers the complete source registry.

Files and exports need tenant-aware retrieval

Store uploads privately with tenant/form/submission dimensions in paths or metadata. Generate storage names and authorize every download. A signed URL proves that its parameters were not changed; it does not permanently prove the viewer remains a member of the tenant.

Exports are bulk disclosure operations. Authorize them separately, bind the query to one tenant, create unpredictable private artifacts, expire them, and record an audit event without logging answer data.

Queues cannot inherit a browser tenant

A worker has no active Filament panel. Put an immutable tenant ID and record ID in the job payload, then restore and verify context before querying. Serialize identifiers, not whole mutable authorization assumptions.

Notifications and webhooks need the same treatment. Resolve recipients and destinations inside the tenant boundary. If a user changes organizations after dispatch, the job must still act on the intended tenant without granting broader access.

Partition the invisible systems

Tenant scope belongs in:

A global cache key such as form:active:42 may collide when IDs are only unique inside a tenant. A global analytics query can leak counts even when individual submissions are protected.

Adversarial tests

Create Tenant A and Tenant B with identically named forms. As an A user or visitor, attempt to:

Test negative behavior at the service/query layer, not only hidden buttons. A cross-tenant identifier should return a safe not-found or forbidden result and should not reveal whether the record exists.

Operational caveats

Single-database row scoping is one tenancy model, not the only one. Database-per-tenant systems change connection restoration, migrations, queue payloads, and analytics. Whatever model you choose, document how central administrators cross boundaries and audit those exceptional actions.

Privacy obligations also remain after isolation; see the GDPR form checklist. Tenant isolation prevents the wrong customer from reading data. It does not decide why the right customer collects it, how long it is retained, or where integrations copy it.

The strongest multi-tenant design is one you can try to break automatically at every boundary. A scoped resource table alone is only the beginning.

Related posts