Appearance
A Laravel multi-tenant website builder without a tenancy package
If you want a Laravel multi-tenant website builder where each customer edits their own site inside a Filament panel, you do not need stancl/tenancy or spatie/laravel-multitenancy to get there. FilamentCraft stores site ownership as a polymorphic relation and resolves the current Site at runtime from whatever your app already knows: a Filament tenant, a route parameter, or the request host.
That was a deliberate constraint. The package never requires a tenancy package, so it works next to any of them, next to plain Filament tenancy, or in a single-site app with no tenancy at all. This post walks through how the pieces fit and why they are split the way they are.
The owner is just a morph
A site belongs to an owner through two columns on the sites table, owner_type and owner_id. Your tenant model can be a Team, a Workspace, an Academy or anything else, and it does not need a base class from us.
If you want the convenience methods, implement the SiteOwner contract with the HasSite trait:
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(). One detail catches people: primarySite() returns live sites only, so an owner whose sites are all drafts resolves to null and owner-based resolution 404s until one of them goes live.
On the query side, the Site model has two scopes that every lookup in the package goes through: live() filters on SiteStatus::Live, and forOwner(Model $owner) matches the morph type and key. When you write your own lookup, use them rather than a raw where('owner_type', ...) chain:
php
$site = Site::query()->forOwner($team)->live()->firstOrFail();The morph means tenancy is data, not infrastructure. There is no separate database per tenant and no connection switching. That keeps the editor, the revision history and the published pages in the same tables for every tenant, which is what most SaaS products building customer sites actually want.
Wiring the panel in one call
The shortest multi-tenant setup is a single plugin method:
php
use App\Models\Workspace;
use FilamentCraft\FilamentCraftPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(
FilamentCraftPlugin::make()
->tenantSites(Workspace::class)
);
}tenantSites() sets up the Filament panel tenant, FilamentCraft's tenant resolution and live-page URL generation together. Its defaults assume a public route named filamentcraft.tenant.public, a {tenantSlug} route parameter and a slug column on the owner. Each of those is a named argument (tenantSlugColumn, publicRouteName, tenantParameter, pathParameter, ownershipRelationship) for when your app differs.
Authorization stays with Filament. Your user model implements HasTenants, and canAccessTenant() remains the check that stops someone from typing another tenant's slug into the admin URL.
If each tenant should own exactly one site, add ->allowSiteCreation(false). A second site under the same owner has no URL that reaches it, because the public route maps /{tenantSlug} to the owner rather than to a site. The gate covers the editor's New store action, the dashboard's create-site header action and the SiteResource create page and route. It also accepts a closure, so a platform admin can keep the ability while tenants lose it.
Two resolvers with different jobs
FilamentCraft registers two singletons in FilamentCraftServiceProvider::packageBooted(), and the split between them is the core of the design.
TenancyResolver answers "which site is this request about" with no extra input. It tries the configured single_site_id, then Filament::getTenant(), then a container-bound owner model, then the first live site. The filamentcraft.tenancy.mode config key (auto, none, filament or owner) decides which of those steps run. Filament resources, the editor page and the layout component all lean on it.
TenantSiteResolver answers a narrower question: given an owner class and a slug, which live site belongs to that owner. Its resolve() method validates that the class is an Eloquent model, loads the owner by the slug column, calls Filament::setTenant($owner) inside a try/catch, and returns Site::query()->forOwner($owner)->live()->first().
php
$site = app(TenantSiteResolver::class)
->resolve('App\\Models\\Academy', 'acme', 'slug');The try/catch is there because the same method runs in two very different contexts. Inside a panel, setting the tenant makes Filament's scoped queries work. On a public storefront route or in a test there is no bootable panel and the call throws, but the site binding is all the page shell needs, so the exception is swallowed. Keeping that chain in one class means the filamentcraft.tenant middleware and the layout component's URL fallback share it instead of each carrying its own copy.
When <x-filamentcraft::layout> renders, the order is: an explicit :site prop, then a Site bound in the container, then TenancyResolver::resolve(), then the TenantSiteResolver URL fallback. If all four fail it throws a RuntimeException that names the ways to fix it. The fallback step is why a {tenantSlug} route parameter plus a configured owner_model is enough to render a tenant page without route middleware.
Scoping that fails safe
The admin side has one rule we treat as load-bearing: a misconfigured panel shows nothing rather than someone else's data. The Sites and Templates resources and the dashboard scope their queries through TenancyResolver::constrainSiteQuery().
With an active Filament tenant, the query is scoped 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 reach through Filament's user-to-tenant contract. 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 cannot hand an operator a site they have no access to.
The practical effect is that forgetting ->tenant() on a panel leaks nothing. Owned rows just disappear from the lists until tenancy resolves.
Serving tenants on paths or hosts
There are two ways to put a tenant's site in front of visitors, and you pick based on your URL scheme.
For path-based tenants, keep public_routes off and register one route:
php
use App\Models\Workspace;
use Illuminate\Support\Facades\Route;
Route::filamentCraftTenant(Workspace::class);That registers GET /{tenantSlug}/{path?} under the name filamentcraft.tenant.public, which is the name tenantSites() expects. Its default tenantPattern is ^(?!admin\b)[A-Za-z0-9-]+ so a panel at /admin stays reachable. If your panel uses another path, pass a pattern that excludes it too, or the tenant route will treat your panel prefix as a slug.
For host-based tenants, set 'public_routes' => true in config/filamentcraft.php. That registers a Laravel fallback route, so every route your app defines still wins. Each site has two nullable, unique columns: domain for a full custom hostname such as shop.acme.com, and subdomain for a prefix under a shared parent such as acme.myapp.com. Tenants set both in the site settings under Publishing, with no code.

Resolution runs in a fixed order. A live site whose domain matches the host exactly wins. Otherwise a live site whose subdomain matches the prefix under APP_URL's host wins. A host that matches no site row at all falls back to TenancyResolver::resolve(), which ends at the first live site.
The subdomain parent comes from APP_URL, not from filamentcraft.domain.primary. The second key only affects URLs FilamentCraft generates, such as sitemap entries, og:url and the editor's open-live pill. If APP_URL is https://admin.myapp.com while tenants live at *.myapp.com, subdomain matching quietly fails and every request lands on the first live site. Set APP_URL to the parent host.
The paused-domain bug
The host rules have one more line that came from a real bug. Before 1.18.0, when a non-live site claimed the exact domain of a request, DomainResolver fell through to the subdomain lookup. An unrelated live site with a matching subdomain could then be served under the paused site's host, which for a SaaS means one customer's pages appearing on another customer's domain.
The fix in 1.18.0 made the check fail closed. If the host matches a site row that is merely not live, the request 404s and never falls through. Pausing a site now takes its domain dark, which is the behaviour a tenant expects when they unpublish.
DNS records and TLS certificates remain your app's job. FilamentCraft maps an incoming host to a Site and nothing more, so you point the domain at your server and terminate HTTPS the way you would for any custom-domain Laravel app. After enabling public_routes, run php artisan filamentcraft:doctor: it checks that a live site exists and warns for each live site without a published homepage.
When you need your own route
If your URL scheme is something the macros do not cover, such as /shop/{tenant}/{slug} or locale prefixes, the escape hatch is small. Bind the resolved site into the container from your own middleware and hand the request to PublicSiteController::show(). The layout component picks the binding up at its second step and skips the rest of the chain.
One ordering rule applies. filamentcraft.tenant (ResolveSiteFromTenant) and filamentcraft.site (SiteContext) bind the site unconditionally, so a custom binding only sticks on routes without FilamentCraft middleware, or when your middleware runs after theirs.
The tenancy guide has the full resolver chain and config keys, public routing covers the host rules in more depth, and the multi-tenant SaaS example shows a tenant signup provisioning a live starter site end to end. You can try a tenant-scoped panel on the live demo.
