Appearance
Multi-Tenant SaaS
The classic FilamentCraft deployment: a SaaS where each customer (tenant) gets their own website. A tenant signs up, a starter site is provisioned automatically, the tenant edits it in their Filament panel, and visitors reach it at /{tenant-slug}.
This walkthrough follows the demo app's implementation end-to-end. The full loop:
- A
Teamis created → an observer provisions a live starter site from a blueprint. - The team's users log into the Filament panel (tenant-scoped) and open the visual editor.
- A public catch-all route resolves the team by slug and serves its published site.
Everything below is real code from the demo host application.
The tenant model needs nothing from the package
FilamentCraft stores site ownership as a polymorphic relation (sites.owner_type / sites.owner_id), so your tenant model stays a plain Eloquent model — no package trait, no interface, no base class required:
php
#[ObservedBy(TeamObserver::class)]
class Team extends Model
{
protected $fillable = ['name', 'slug'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withTimestamps();
}
}Any model can own sites: Team, Workspace, Academy, Restaurant — FilamentCraft only ever queries it through the morph. (Optionally, implementing FilamentCraft\Contracts\SiteOwner via the HasSite trait adds a primarySite() convenience and unlocks the owner tenancy mode — see Tenancy — but the demo gets by without it.)
Panel setup
The admin panel declares Team as its Filament tenant and registers the plugin. The publicUrlUsing() closure teaches the editor what the live URL of any template is, so the "open live" pill and the templates list "View" action point at your routes instead of the package defaults:
php
use App\Models\Team;
use FilamentCraft\FilamentCraftPlugin;
use FilamentCraft\Models\Template;
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->tenant(Team::class, slugAttribute: 'slug')
->plugin(
FilamentCraftPlugin::make()
->mirrorEditorStyles()
->publicUrlUsing(function (Template $template): ?string {
$owner = $template->site?->owner;
if (! $owner instanceof Team) {
return null;
}
return url($template->isHomepage()
? $owner->slug
: "{$owner->slug}/{$template->slug}");
})
)
// … resource/page/widget discovery and middleware trimmed
;
}Returning null from the closure falls through to the default resolver, so partial overrides are safe. If your public route is a named Laravel route, the publicUrlsViaNamedRoute() shortcut replaces the closure entirely — see Tenancy.
Because the panel has a tenant, Filament::getTenant() is populated inside it, and FilamentCraft's TenancyResolver picks the current team's site up automatically — the editor, templates list, and every FilamentCraft resource are scoped to the active tenant with zero extra wiring.
Authorizing tenant access
Standard Filament v4 multi-tenancy: the user model implements HasTenants, and canAccessTenant() is the defense against URL manipulation:
php
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasTenants;
class User extends Authenticatable implements FilamentUser, HasTenants
{
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)->withTimestamps();
}
public function getTenants(Panel $panel): Collection
{
// … demo-only shop-panel workspace grant trimmed
return $this->teams;
}
public function canAccessTenant(Model $tenant): bool
{
// … demo-only shop-panel workspace grant trimmed
return $this->teams()->whereKey($tenant->getKey())->exists();
}
}Auto-provisioning a site on signup
The demo provisions each new team's site from a model observer:
php
use App\Blueprints\TeamStarterSiteBlueprint;
use App\Models\Team;
final class TeamObserver
{
public function created(Team $team): void
{
TeamStarterSiteBlueprint::provision($team);
}
}provision($owner) is static sugar on every blueprint — it builds the site, its pages, its shared regions, publishes everything, and attaches the site to the owner via the polymorphic relation. The blueprint itself declares pages and regions:
php
use FilamentCraft\Blueprints\BlueprintRegion;
use FilamentCraft\Blueprints\BlueprintSection;
use FilamentCraft\Blueprints\SiteBlueprint;
use FilamentCraft\Enums\RegionName;
use FilamentCraft\Sections\Builtin\FooterSection;
use FilamentCraft\Sections\Builtin\HeaderSection;
final class TeamStarterSiteBlueprint extends SiteBlueprint
{
public function name(): string
{
return 'Team starter';
}
public function pages(): array
{
return [
StarterHomeBlueprint::class,
StarterAboutBlueprint::class,
];
}
public function regions(): array
{
return [
BlueprintRegion::make(RegionName::Header)->sections([
BlueprintSection::make(HeaderSection::class),
]),
BlueprintRegion::make(RegionName::Footer)->sections([
BlueprintSection::make(FooterSection::class),
]),
];
}
}Each page blueprint lists its sections with optional pre-filled settings. name() and slug() are abstract on AbstractBlueprint — the slug is stored as the template's stable blueprint_slug identifier, so treat it as permanent:
php
<?php
declare(strict_types=1);
namespace App\Blueprints;
use FilamentCraft\Blueprints\AbstractBlueprint;
use FilamentCraft\Blueprints\BlueprintSection;
use FilamentCraft\Sections\Builtin\CtaSection;
use FilamentCraft\Sections\Builtin\FeaturesSection;
use FilamentCraft\Sections\Builtin\HeroSection;
use FilamentCraft\Sections\Builtin\TestimonialsSection;
final class StarterHomeBlueprint extends AbstractBlueprint
{
public function name(): string
{
return 'Home';
}
public function slug(): string
{
return 'starter-home';
}
public function isHomepage(): bool
{
return true;
}
public function sections(): array
{
return [
BlueprintSection::make(HeroSection::class)->settings([
'eyebrow' => 'Just launched',
'heading' => 'Your new site is live',
'subheading' => 'This starter page was provisioned automatically the moment your team was created — open the editor and make it yours.',
]),
BlueprintSection::make(FeaturesSection::class),
BlueprintSection::make(TestimonialsSection::class),
BlueprintSection::make(CtaSection::class),
];
}
}StarterAboutBlueprint is the same shape, minus the homepage flag:
php
<?php
declare(strict_types=1);
namespace App\Blueprints;
use FilamentCraft\Blueprints\AbstractBlueprint;
use FilamentCraft\Blueprints\BlueprintSection;
use FilamentCraft\Sections\Builtin\FaqSection;
use FilamentCraft\Sections\Builtin\ImageTextSection;
use FilamentCraft\Sections\Builtin\StatsSection;
final class StarterAboutBlueprint extends AbstractBlueprint
{
public function name(): string
{
return 'About';
}
public function slug(): string
{
return 'starter-about';
}
public function sections(): array
{
return [
BlueprintSection::make(ImageTextSection::class),
BlueprintSection::make(StatsSection::class),
BlueprintSection::make(FaqSection::class),
];
}
}Expected result: the moment Team::create(['name' => 'Acme', 'slug' => 'acme']) runs, the observer provisions and publishes the site — visiting /acme serves the starter homepage with a header, hero, features, testimonials, CTA, and footer.
Observer provision() vs package auto-seeding
The observer is the manual path: the site blueprint decides when and for whom a whole site (pages + regions + theme) is built. The package also ships an automatic path for page blueprints: register them on the plugin (FilamentCraftPlugin::make()->registerBlueprint(StarterHomeBlueprint::class)) and leave blueprints.auto_seed enabled (the default in config/filamentcraft.php), and every newly created Site gets those pages seeded via the SiteCreated listener; with auto_seed off, php artisan filamentcraft:seed-blueprints seeds on demand.
The two coexist safely: provision() suspends auto-seed while it creates the Site (so pages listed in a site blueprint don't need to be registered at all), and the seeder skips any site already holding a template with the blueprint's blueprint_slug. The residual double-seeding risks are scope and identity, not mechanics — registering a page blueprint globally with auto_seed on seeds it into every new site, including ones other flows create, and renaming a blueprint's slug() defeats the already-seeded check, so the same page seeds again under the new slug.
See Seeding & Blueprints for the full blueprint API.
Serving the public sites
The demo owns its public routing (instead of the package's opt-in catch-all) so it can put tenants at the URL root. Two things matter here.
Reserved slugs. A root-level /{teamSlug} catch-all will happily swallow /admin, /livewire, /storage, and every other framework prefix — route registration order alone is not a safe guard, because panel routes may register after yours. A negative-lookahead constraint fixes it deterministically:
php
use App\Http\Controllers\PublicSiteController; // your own, not the package's
// Panel and framework prefixes must never be swallowed by the tenant
// catch-alls — Filament's panel routes register after these on newer
// framework versions, so route order alone is not a safe guard.
$reservedSlugs = '(?!(?:admin|studio|shop|store|site|launch|livewire|storage|vendor|up|filamentcraft)(?:/|$))[A-Za-z0-9-]+';
Route::get('/{teamSlug}/{locale}/{path?}', [PublicSiteController::class, 'tenant'])
->where('teamSlug', $reservedSlugs)
->where('locale', '[a-z]{2}(-[A-Z]{2})?')
->where('path', '.*')
->name('tenant.public');
Route::get('/{teamSlug}/{path?}', [PublicSiteController::class, 'tenantUnlocalised'])
->where('teamSlug', $reservedSlugs)
->where('path', '.*')
->name('tenant.public.unlocalised');(The two-routes-per-site split exists because of how optional parameters compile — see Multi-Locale Site for that story.)
The controller. Resolve the owner by slug, resolve its live site through the model scopes, bind the site into the container, and render:
php
use FilamentCraft\Models\Site;
use FilamentCraft\Rendering\TemplateRenderer;
use FilamentCraft\Resolvers\TemplateResolver;
final class PublicSiteController extends Controller
{
public function __construct(
private readonly TemplateResolver $resolver,
private readonly TemplateRenderer $renderer,
private readonly ViewFactory $views,
) {}
public function tenant(string $teamSlug, ?string $locale = null, string $path = ''): Response
{
$team = Team::query()->where('slug', $teamSlug)->firstOrFail();
$site = Site::query()->forOwner($team)->live()->firstOrFail();
return $this->renderSite($site, $teamSlug, $locale, $path);
}
private function renderSite(Site $site, string $baseSegment, ?string $locale, string $path): Response
{
app()->instance(Site::class, $site);
$resolvedLocale = ($locale !== null && $site->hasLocale($locale))
? $locale
: $site->default_locale;
app()->setLocale($resolvedLocale);
$template = $this->resolver->resolve($site, $path);
abort_if($template === null, 404);
$html = $this->renderer->render($template, 'published', $resolvedLocale);
// … hreflang + language-switcher injection trimmed (see the multi-locale example)
return response($html);
}
}Note the scopes: forOwner($team) encapsulates the polymorphic morph match and live() the status filter — never write the raw where('owner_type', …) chain yourself. app()->instance(Site::class, $site) is what tells the rest of the render pipeline (regions, theme, sections) which site is active.
If you'd rather not write a controller at all, the package ships lower-effort options for the same result — the filamentcraft.tenant middleware, the Route::filamentCraftStorefront() macro, and the #[Storefront] attribute — all built on the same TenantSiteResolver. See Dynamic Pages and Public Routing.
The loop, recapped
| Step | Mechanism |
|---|---|
| Tenant signs up | Team::create() fires TeamObserver::created() |
| Site provisioned | TeamStarterSiteBlueprint::provision($team) — pages, regions, published |
| Tenant edits | Filament panel with ->tenant(Team::class); FilamentCraft auto-scopes to the active tenant |
| Editor links out | publicUrlUsing() maps templates to /{slug} / /{slug}/{page} |
| Visitor arrives | Reserved-slug catch-all → owner by slug → Site::forOwner()->live() → render |
Related
- Tenancy — the resolver chain, tenancy modes, and middleware
- Seeding & Blueprints — the blueprint API in full
- Public Routing — package-owned vs host-owned routes
- Dynamic Pages — the four doors for serving pages
- Multi-Locale Site — add locale-prefixed URLs to this setup
