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);

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


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.

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

  1. Explicit :site prop — you passed one. End of chain.
  2. Container bindingapp()->bound(Site::class) returns true (set by middleware: filamentcraft.tenant, filamentcraft.site, or any of your own).
  3. TenancyResolver::resolve() — runs the mode-aware generic chain (below). Works inside the admin panel because Filament::getTenant() populates it.
  4. TenantSiteResolver URL-parameter fallback — when the current request has a {tenantSlug} route param and filamentcraft.tenancy.owner_model is 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:

  1. config('filamentcraft.tenancy.single_site_id') — single-site mode for hosts that don't want any multi-tenancy. Returns the named Site directly.
  2. Filament::getTenant() — works inside a Filament panel context. Loads the Site for the current panel tenant.
  3. Configured owner modelconfig('filamentcraft.tenancy.owner_model') set + an instance of that model already bound in the container → resolves via the owner's primarySite() method. This path is for host apps that bind their current workspace, academy, team, etc. in middleware.
  4. 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

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.

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

Override the binding in your own middleware before any FilamentCraft middleware runs:

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

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.

Proprietary — distributed via Anystack.