Tenancy
FilamentCraft never requires a tenancy package (stancl/tenancy, spatie/laravel-multitenancy, etc.) — it resolves the current Site at runtime from whatever signals your host app provides. It works with any of those packages, Filament-only setups, or single-site apps without modification.
The current Site is resolved by <x-filamentcraft::layout> in this order — first match wins:
:site propYou passed a Site to the component. End of chain.
app()->bound(Site::class) — set by middleware such as filamentcraft.tenant, filamentcraft.site, or your own.
TenancyResolver::resolve()The generic four-step chain: config single_site_id → Filament::getTenant() → container-bound owner_model → first live Site, with mode-specific skips.
TenantSiteResolver URL fallbackA {tenantSlug} route param plus a configured owner_model resolves the tenant and its live Site without any route middleware.
A RuntimeException is thrown, naming the options to fix it.
Tenancy modes
| Mode | What it does |
|---|---|
auto (default) | single_site_id if set, then Filament::getTenant(), then a container-bound owner_model, then the first live Site. |
none | Single-site mode — resolves single_site_id when set, otherwise the first live Site. |
filament | single_site_id if set, then the active Filament tenant, then the first live Site. |
owner | single_site_id if set, then a container-bound owner_model, then the first live Site. |
Single-app / non-tenant setup
use FilamentCraft\FilamentCraftPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
FilamentCraftPlugin::make()
->singleSite()
);
}If you already know the site id and want to pin resolution to it:
FilamentCraftPlugin::make()
->singleSite(1);Multi-tenant setup
use App\Models\Workspace;
use FilamentCraft\FilamentCraftPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
FilamentCraftPlugin::make()
->tenantSites(Workspace::class)
);
}tenantSites() configures the Filament panel tenant, FilamentCraft tenant resolution, and live-page URL generation together. Its default public URL strategy assumes your tenant route is named filamentcraft.tenant.public, uses a {tenantSlug} parameter, and reads the tenant slug from a slug column (see Public Routing).
The two resolvers
FilamentCraft ships two cooperating singletons, both registered in FilamentCraftServiceProvider::packageBooted():
| Resolver | Purpose | Used by |
|---|---|---|
TenancyResolver | Generic "find a Site for the current request". Tries single_site_id → Filament panel tenant → container-bound owner model → first live Site, with mode-specific skips. | Filament resources, the editor page, the Layout component's middle resolution step |
TenantSiteResolver | Given an explicit (ownerModel, slug) pair, loads the owner, sets it as the Filament tenant when possible, and returns the matching live Site. | The filamentcraft.tenant middleware, the Layout component's URL fallback step |
You'll usually never call them directly — they're the substrate under the middleware and the <x-filamentcraft::layout> component.
Resolution order inside <x-filamentcraft::layout>
When you use any of the four dynamic-page doors, the Layout component resolves $site in this order (first match wins):
- Explicit
:siteprop — you passed one. End of chain. - Container binding —
app()->bound(Site::class)returns true (set by middleware:filamentcraft.tenant,filamentcraft.site, or any of your own). TenancyResolver::resolve()— runs the mode-aware generic chain (below). Works inside the admin panel becauseFilament::getTenant()populates it.TenantSiteResolverURL-parameter fallback — when the current request has a{tenantSlug}route param andfilamentcraft.tenancy.owner_modelis configured, looks up the tenant and its live Site without any middleware on the route.
If all four fail, a RuntimeException is thrown with a message that names the options to fix it.
What TenancyResolver::resolve() checks
In order:
config('filamentcraft.tenancy.single_site_id')— single-site mode for hosts that don't want any multi-tenancy. Returns the named Site directly.Filament::getTenant()— works inside a Filament panel context. Loads the Site for the current panel tenant.- Configured owner model —
config('filamentcraft.tenancy.owner_model')set + an instance of that model already bound in the container → resolves via the owner'sprimarySite()method. This path is for host apps that bind their current workspace, academy, team, etc. in middleware. - First live Site — last-resort fallback. Returns the earliest live Site by id. Useful for staging / single-site setups where you don't care about strict isolation.
What TenantSiteResolver::resolve() does
$site = app(TenantSiteResolver::class)
->resolve('App\\Models\\Academy', 'acme', 'slug');- Validates the owner model class exists and is an
Eloquent\Modelsubclass. ThrowsInvalidArgumentExceptionotherwise. - Loads the owner via
Academy::query()->where('slug', 'acme')->first(). Returnsnullif no match (caller decides whether to 404). - Calls
Filament::setTenant($owner)inside a try/catch. Panel contexts use this for scoped queries; non-panel contexts (public routes, tests) swallow the throw — the Site binding is what the shell actually needs. - Loads the Site via
Site::query()->forOwner($owner)->live()->first().forOwner()is the polymorphic-morph scope on theSitemodel — always use it instead of rawwhere('owner_type', …)->where('owner_id', …).
The middleware and the Layout component's URL fallback both route through this method — they don't reimplement the chain. If you find yourself writing owner-by-slug logic in your own code, call TenantSiteResolver instead.
Configuration
return [
'tenancy' => [
// One of: auto, none, filament, owner.
'mode' => 'auto',
// Forces every request to use this Site id. Skips the chain entirely.
'single_site_id' => null,
// The Eloquent class that owns Sites (Academy, Team, Workspace, etc.).
// For TenancyResolver owner mode, this model should implement SiteOwner
// (using FilamentCraft\Concerns\HasSite is the usual path).
'owner_model' => null,
// Optional: add this if your tenant slug column is not 'slug'.
'slug_column' => 'slug',
// Optional: add this if your route param is not '{tenantSlug}'.
'url_parameter' => 'tenantSlug',
],
];The published config ships with mode, owner_model, and single_site_id. The slug_column and url_parameter keys are optional overrides read by the tenant middleware and Layout URL fallback; add them only when your routes differ from the defaults.
The middleware: filamentcraft.tenant
Registered automatically. Use it to bind a Site eagerly before your handlers run:
// routes/web.php
Route::middleware(['web', 'filamentcraft.tenant'])
->prefix('{tenantSlug}')
->group(function (): void {
Route::get('cart', CartPage::class);
});With three optional middleware arguments to override config:
'filamentcraft.tenant:academySlug,App\\Models\\Academy,public_slug'The Route::filamentCraftStorefront() macro wraps this for you — see Dynamic Pages → Door 2.
/admin coexistence
Route::filamentCraftTenant() and Route::filamentCraftStorefront() both default their tenantPattern to ^(?!admin\b)[A-Za-z0-9-]+ so a Filament admin panel at /admin/{tenant} stays reachable. If your panel uses a non-default ->path('something-else'), pass a tenantPattern argument that excludes that prefix too — otherwise the FC tenant route will capture /something-else/whatever as a tenant slug.
Common scenarios
Single-site mode
FilamentCraftPlugin::make()
->singleSite(1);TenancyResolver short-circuits to that Site for every request. The URL fallback is never invoked because step 3 succeeds first.
Multi-tenant, Filament panel only
Use tenantSites(Workspace::class) or set owner_model and tenancy('filament'). Inside the admin panel, Filament::getTenant() resolves the tenant from the panel's tenancy config; TenancyResolver finds the matching Site. The Layout component (if used inside a custom admin page) gets it from step 3 — no public-route middleware needed.
Multi-tenant, public storefront
Set owner_model + url_parameter (if your routes use a non-default param name). Add the filamentcraft.tenant middleware (or the Route::filamentCraftStorefront() macro) to your storefront routes. The middleware binds the Site to the container; the Layout component picks it up from step 2.
If you forget the middleware but your URL has a {tenantSlug} param matching url_parameter, the Layout component's URL fallback (step 4) saves you. This is what makes Door 1 (#[Storefront]) work without per-route middleware.
Custom resolution logic
Override the binding in your own middleware before any FilamentCraft middleware runs:
app()->instance(\FilamentCraft\Models\Site::class, $yourCustomSiteLookup);The Layout component finds it at step 2 and skips the rest of the chain.
If you prefer tenancy('owner'), bind the current owner instance instead:
app()->instance(\App\Models\Workspace::class, $workspace);Then set filamentcraft.tenancy.owner_model to the same class. The owner model should implement FilamentCraft\Contracts\SiteOwner, usually by using FilamentCraft\Concerns\HasSite.
