Appearance
Public Routing
Off by default — most hosts already own their front-end and only want the editor. Flip on when you want FilamentCraft to serve published templates directly.

Enable the fallback route
php
// config/filamentcraft.php
'public_routes' => true,This registers a fallback route that resolves the host (via Site::domain / subdomain) and renders the matching template's published revision. The homepage assignment in the editor topbar drives /. Slugs like /pricing resolve to the published template with that slug.
Because it's a fallback, every route your application (or another package) defines wins over FilamentCraft — Livewire's script route, Filament panels, and your own pages are never shadowed. One consequence: the Route::get('/', ...) welcome route that ships with a fresh Laravel app also wins, so remove it from routes/web.php if you want FilamentCraft to serve your homepage at /.
How the host resolves to a site:
- A live site claiming the exact
domainwins; otherwise a livesubdomainmatch against the parent host (filamentcraft.domain.primary, elseAPP_URL's host). - Fail-closed: a host whose only matches are non-live Site rows 404s — pausing a site takes its domain dark instead of falling through to another site. Once any site is served by subdomain, an unknown
{label}.{parent}host 404s too, so a mistyped tenant address never shows another tenant's pages. - A host matching no Site row at all falls back to
TenancyResolver::resolve(), which bottoms out at the first live site — so any unmatched host (bare IP, stray DNS entry) serves the first live site.
Run php artisan filamentcraft:doctor after enabling public_routes — it verifies a live site exists and warns for each live site without a published homepage (its / would 404).
Set APP_URL
The editor topbar's host label, the open live page button, and generated public URLs resolve from Site::domain, then Site::subdomain + filamentcraft.domain.primary, then fall back to APP_URL. A fresh Laravel .env ships with APP_URL=http://localhost — set it to your real host (e.g. http://my-app.test under Herd/Valet) or the editor will link to localhost.
Custom domains & subdomains
Every site can answer on its own host — one tenant, one domain. Two nullable, unique columns on the sites table drive it:
| Column | Serves | Example | DNS you point at the app |
|---|---|---|---|
domain | A whole custom hostname | shop.acme.com, www.acme.com | An A / AAAA / CNAME record for that exact host |
subdomain | A prefix under one shared parent host | acme → acme.myapp.com | A wildcard record — *.myapp.com |
Both are unique across all sites, so a host can only ever map to one site — there is no ambiguity about which tenant a request belongs to. domain is matched first; subdomain is composed against the parent host — filamentcraft.domain.primary, falling back to APP_URL's host.
Setting a site's host
Domain and subdomain are plain site settings — no code. In the editor, open the site settings modal; in the admin panel, edit the site in the Sites resource. Both expose two fields under Publishing:
- Custom domain — "A fully-qualified hostname this site responds to (e.g. www.example.com)."
- Subdomain — "Subdomain prefix when domain is shared with other sites."
Both are validated unique, and each is also checked against the host the other column produces: a custom domain of acme.myapp.com is rejected while another site owns the acme subdomain (and vice versa), so one tenant can never shadow another's address. The parent host itself, its www. twin, and the www subdomain are reserved for the platform.
Both fields hide when public_routes is off
Host matching lives entirely behind the fallback route: with filamentcraft.public_routes set to false, nothing in the package ever reads domain or subdomain, so from v1.39.0 the two fields are hidden and the slug field drops its "fallback when no domain is set" helper text. Turn them back on when your app applies the SiteContext middleware to routes it owns:
php
$plugin = FilamentCraftPlugin::make()->siteRoutingFields();It takes the same shapes as allowSiteCreation() — siteRoutingFields(false) forces them off even with the fallback route on, and a closure receives the authenticated user. php artisan filamentcraft:doctor warns when sites still carry a host nothing will serve.
Give a tenant its own domain
Say tenant Acme should live at shop.acme.com:
- Set the host. Put
shop.acme.comin Acme's site's Custom domain field and set the site Live (a non-live site's domain fails closed — see below). - Point DNS at your app. Add a record for
shop.acme.com(anA/AAAAto your server's IP, or aCNAMEto your app's hostname) at Acme's DNS provider. - Issue a TLS certificate for
shop.acme.comon your server (Forge, Caddy on-demand TLS, Cloudflare, or your load balancer). FilamentCraft does not manage certificates. - Done. A request to
https://shop.acme.com/pricinghits the fallback route, thefilamentcraft.sitemiddleware (SiteContext) resolves the host to Acme's site, and the publishedpricingtemplate renders.
For the subdomain variant — acme.myapp.com, globex.myapp.com, … all under one parent you control — set each site's Subdomain (acme, globex), then point a single wildcard record + wildcard cert at the app.
The parent host comes from filamentcraft.domain.primary, falling back to APP_URL's host when it is unset:
bash
# .env
APP_URL=https://myapp.comOne parent host for both directions
The same value decides which inbound requests resolve to which site (acme.myapp.com finds Acme's site only when the parent host is myapp.com) and the URLs FilamentCraft generates — sitemap entries, og:url, and the editor's "open live" pill — so the two can never drift apart. If your admin runs on https://admin.myapp.com while tenants live at *.myapp.com, set filamentcraft.domain.primary to myapp.com; otherwise subdomain matching misses and unmatched requests fall through to the first live site.
How a host resolves to a site
This is the same algorithm the fallback route runs (repeated here for the domain angle):
- A live site whose
domainexactly matches the request host wins. - Otherwise, a live site whose
subdomainmatches the host's prefix under the parent host wins. - Fail-closed: if the host matches a Site row that is merely not live, the request 404s — it never falls through to another site. Pausing a site takes its domain dark. The same goes for an unknown
{label}.{parent}host once any site uses a subdomain. - A host that matches no Site row at all falls back to
TenancyResolver::resolve(), which bottoms out at the first live site — so bare IPs and stray DNS entries still serve something rather than erroring.
DNS and TLS are the host app's job
FilamentCraft only maps an incoming host to a Site. Pointing the domain at your server and terminating HTTPS for it are ordinary Laravel-hosting concerns — handle them the way you host any custom-domain app (Forge, Cloudflare, Caddy on-demand TLS, etc.).
A host belongs to exactly one site
domain and subdomain are unique columns, and each is checked against the address the other produces. To move shop.acme.com from one site to another, clear it on the first site before setting it on the second — the panel's validation blocks a duplicate.
Tenant URL scheme
If you want a tenant URL scheme — /{tenant}/{slug} — keep public_routes => false and add one route:
php
use App\Models\Workspace;
use Illuminate\Support\Facades\Route;
Route::filamentCraftTenant(Workspace::class);That registers GET /{tenantSlug}/{path?}, resolves the Workspace by slug, binds the workspace's live FilamentCraft site, and renders the published page. The default route name is filamentcraft.tenant.public.
Use named arguments only when your app differs from the defaults:
php
Route::filamentCraftTenant(
ownerModel: Workspace::class,
tenantParameter: 'workspace',
tenantSlugColumn: 'domain_slug',
pathParameter: 'path',
name: 'tenant.public',
tenantPattern: '^(?!admin\b)[A-Za-z0-9-]+',
);For completely custom URL schemes — /shop/{tenant}/{slug}, locale prefixes, channel paths, etc. — register your own route, bind the resolved Site into the container from middleware, then point the route at PublicSiteController::show(Request $request, ?string $path = null):
php
use App\Http\Middleware\ResolveShopSite; // app()->instance(Site::class, $site)
use FilamentCraft\Http\Controllers\PublicSiteController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', ResolveShopSite::class])
->get('/shop/{tenant}/{path?}', function (Request $request, string $tenant, ?string $path = null) {
return app(PublicSiteController::class)->show($request, $path ?? '');
})
->where('path', '.*');The controller reads the bound Site from the container and renders the published template matching $path (the site's homepage when empty — pass '', not null: a null path makes the controller derive it from the full request URI, prefix included). For tenant-slug routes, prefer Route::filamentCraftTenant() unless your URL shape is truly custom.
Custom URL strategy — publicUrlsViaNamedRoute()
Hosts that own their routing (multi-tenant slugs, locale prefixes, channel paths) tell FilamentCraft how to build the public URL of a Template. This drives the editor topbar's "open live" pill, the URL bar, and the View live action on the Templates list — every place that asks "where does this page live?".
For the 90% case — a named Laravel route with a path parameter and an optional tenant parameter — use the declarative helper:
php
use FilamentCraft\FilamentCraftPlugin;
// Single-tenant: route('page.show', ['path' => $template->slug])
$plugin = FilamentCraftPlugin::make()
->publicUrlsViaNamedRoute('page.show');
// Multi-tenant with Route::filamentCraftTenant(...):
$plugin = FilamentCraftPlugin::make()
->publicUrlsViaNamedRoute(
'filamentcraft.tenant.public',
tenantParameter: 'tenantSlug',
);
// Locale-prefixed extras
$plugin = FilamentCraftPlugin::make()
->publicUrlsViaNamedRoute(
'tenant.public',
tenantParameter: 'tenantSlug',
extraParameters: [
'locale' => fn (\FilamentCraft\Models\Template $t) => $t->site?->default_locale,
],
);Pass the configured $plugin to $panel->plugin($plugin) in your panel provider; do not create a separate throwaway plugin instance.
Parameters: pathParameter (defaults to 'path'), tenantParameter (the route placeholder for the owner slug — null for single-tenant), tenantSlugColumn (the attribute on Site::owner holding the slug, defaults to 'slug'), homepagePath (the literal value for the homepage — '' by default; set to 'home' etc. if your route demands it), and extraParameters (scalar literals or closures resolved against the template at call time).
The result is memoised per-request on the Template instance, so the editor topbar and the templates list hit the resolver once — not once per call. The cache is cleared automatically on save() / refresh() so a status flip or slug rename invalidates it.
Or a raw closure — publicUrlUsing()
For exotic schemes (channel paths, custom domains per template, on-the-fly redirects), drop down to the closure form:
php
use FilamentCraft\Models\Template;
$plugin = FilamentCraftPlugin::make()
->publicUrlUsing(function (Template $template): ?string {
$owner = $template->site?->owner;
if (! $owner instanceof \App\Models\Team) {
return null; // fall through to default resolver
}
return route('tenant.public', [
'tenantSlug' => $owner->slug,
'path' => $template->isHomepage() ? '' : $template->slug,
]);
});Return null from either form to fall through to the default behaviour (catch-all route when public_routes is on, otherwise the auth'd preview URL). Partial overrides — "only when the site has an owner", "only when the locale is set" — degrade gracefully.
Parameterised paths — dynamicTemplates()
One published template can serve a whole family of URLs — /shop/laptop-pro, /shop/desk-lamp, … — with the matched parameters handed to its sections. Register a DynamicTemplateDefinition on the plugin:
php
use App\Models\Product;
use FilamentCraft\FilamentCraftPlugin;
use FilamentCraft\Models\Site;
use FilamentCraft\Routing\DynamicTemplateDefinition;
FilamentCraftPlugin::make()
->dynamicTemplates([
DynamicTemplateDefinition::make('product.show')
->path('shop/{product}')
->templateSlug('product')
->bind('product', fn (Site $site, string $slug): ?Product => Product::query()
->where('slug', $slug)
->first()),
]);How it resolves — on both the catch-all route and Route::filamentCraftTenant():
- A request path that matches no published template's slug directly is checked against each definition's
path()pattern ({param}segments match one path segment each). - On a match, the published template with
templateSlug()is rendered. No published template with that slug (or abind()closure returningnull— an unknown product slug, say) means 404, not a fall-through. - The matched parameters and resolved models travel to every section as the render context: inside a section,
$data->context('product')returns the bound model and$data->routeContext()?->parameter('product')the raw segment. The built-in Product detail section reads its product this way.
Per-locale fragment caches key on the render context automatically, so /shop/laptop-pro and /shop/desk-lamp never share cached section output. pathFor(['product' => $model]) on the definition builds the public path back from parameters.
Linking to FilamentCraft pages — TemplateUrlPicker

TemplateUrlPicker — its dropdown lists the site's published templates to pick from.Need to let an admin pick a published page from a CTA button, a banner link, a footer menu item, or any other "open this page" field? Drop the picker into any Filament schema:
php
use FilamentCraft\Filament\Forms\Components\TemplateUrlPicker;
TemplateUrlPicker::make('cta_url')
->label('Destination page')
->required();Where do the options come from? The picker queries Template::query()->published() — every template whose status === Published and whose publicUrl() resolves to a non-empty string. The URL itself is whatever your publicUrlsViaNamedRoute() / publicUrlUsing() resolver returns (or the default catch-all / preview URL when no resolver is wired). Drafts are excluded — they have no public URL by design.
Defaults: options preload on open (no typing required), searchable filter by template name, scoped to the active Filament tenant, stores the resolved URL string so existing <a href> rendering keeps working without extra resolution code.
Configurable:
php
TemplateUrlPicker::make('cta_url')
->forSite($site->id) // pin to a specific site, ignoring the active tenant
->forAllSites() // list templates across every site (admin tools)
->searchLimit(20); // cap the dropdown size (defaults to 50)