Skip to content

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:

1Explicit :site prop

You passed a Site to the component. End of chain.

2Container binding

app()->bound(Site::class) — set by middleware such as filamentcraft.tenant, filamentcraft.site, or your own.

3TenancyResolver::resolve()

The generic four-step chain: config single_site_idFilament::getTenant() → container-bound owner_model → first live Site, with mode-specific skips.

4TenantSiteResolver URL fallback

A {tenantSlug} route param plus a configured owner_model resolves the tenant and its live Site without any route middleware.

!All four fail

A RuntimeException is thrown, naming the options to fix it.

Tenancy modes

ModeWhat it does
auto (default)single_site_id if set, then Filament::getTenant(), then a container-bound owner_model, then the first live Site.
noneSingle-site mode — resolves single_site_id when set, otherwise the first live Site.
filamentsingle_site_id if set, then the active Filament tenant, then the first live Site.
ownersingle_site_id if set, then a container-bound owner_model, then the first live Site.

Single-app / non-tenant setup

php
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:

php
FilamentCraftPlugin::make()
    ->singleSite(1);

->singleSite(false) clears a previously pinned site id (the tenancy mode is left as-is) — useful when the plugin instance is built conditionally.

Multi-tenant setup

php
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).

Every default is a named argument:

php
FilamentCraftPlugin::make()
    ->tenantSites(
        Workspace::class,
        tenantSlugColumn: 'slug',                          // owner attribute holding the public slug
        publicRouteName: 'filamentcraft.tenant.public',    // named route used for live-page URLs
        tenantParameter: 'tenantSlug',                     // route placeholder for the owner slug
        pathParameter: 'path',                             // route placeholder for the page path
        ownershipRelationship: null,                       // Filament tenant ownership relationship name
    );

The owner model

The owner class should implement SiteOwnerHasSite provides the whole contract:

php
use FilamentCraft\Concerns\HasSite;
use FilamentCraft\Contracts\SiteOwner;
use Illuminate\Database\Eloquent\Model;

class Workspace extends Model implements SiteOwner
{
    use HasSite;
}

HasSite adds a sites() morph relation and primarySite().

primarySite() returns live sites only

An owner whose sites are all drafts resolves to null, so owner-based resolution (and the tenant middleware) 404s until one of its sites goes live.


The two resolvers

FilamentCraft ships two cooperating singletons, both registered in FilamentCraftServiceProvider::packageBooted():

ResolverPurposeUsed by
TenancyResolverGeneric "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
TenantSiteResolverGiven 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.

The exact resolution order — for the Layout component and for TenancyResolver::resolve() itself — is the flow diagram at the top of this page; the modes table lists which steps each mode skips.

What TenantSiteResolver::resolve() does

php
$site = app(TenantSiteResolver::class)
    ->resolve('App\\Models\\Academy', 'acme', 'slug');
  1. Validates the owner model class exists and is an Eloquent\Model subclass. Throws InvalidArgumentException otherwise.
  2. Loads the owner via Academy::query()->where('slug', 'acme')->first(). Returns null if no match (caller decides whether to 404).
  3. 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.
  4. Loads the Site via Site::query()->forOwner($owner)->live()->first(). forOwner() is the polymorphic-morph scope on the Site model — always use it instead of raw where('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.

Fail-safe resource scoping

The Sites and Templates resources and the dashboard scope every query through TenancyResolver::constrainSiteQuery(), which fails safe:

  • an active Filament tenant scopes to that tenant's sites;
  • an explicit single_site_id, or mode => 'none', opts into single-tenant behaviour;
  • otherwise the query is restricted to unowned sites plus the owners the authenticated user can actually reach via Filament's user→tenant contract.

When no tenant resolves at all, owned rows simply don't appear — the panel shows no sites rather than another owner's. The dashboard's single-site pick goes through resolveAccessibleSite(), which re-checks the resolved site against the same constraint, so the first-live-site fallback can never surface a site the operator can't access. This is a load-bearing security guarantee: a panel without ->tenant() (or a misconfigured one) leaks nothing.

Configuration

php
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:

php
// 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:

php
'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

php
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

Bind the Site yourself in your own middleware:

php
app()->instance(\FilamentCraft\Models\Site::class, $yourCustomSiteLookup);

The Layout component finds it at step 2 and skips the rest of the chain.

FilamentCraft middleware overwrites earlier bindings

filamentcraft.tenant (ResolveSiteFromTenant) and filamentcraft.site (SiteContext) bind the Site unconditionally — they don't check for an existing binding. A custom binding therefore only sticks on routes without FilamentCraft middleware, or when your middleware runs after the FilamentCraft one in the stack.

If you prefer tenancy('owner'), bind the current owner instance instead:

php
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.

Next steps

  • Seeding & Blueprints — provision a complete, live starter site the moment a tenant is created (EventStarterSiteBlueprint::provision($tenant)).
  • Public Routing — how the resolved site is served.