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 againstAPP_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.
- 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 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, so the panel rejects a host already claimed by another site.
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 APP_URL:
bash
# .env
APP_URL=https://myapp.comAPP_URL is what matches subdomains — not filamentcraft.domain.primary
These are two different knobs and it is easy to reach for the wrong one:
APP_URLdecides which requests resolve to which site.acme.myapp.comonly finds Acme's site whenAPP_URL's host ismyapp.com.filamentcraft.domain.primaryonly affects URLs FilamentCraft generates — sitemap entries,og:url, and the editor's "open live" pill. It defaults toAPP_URL's host, and is there for the case where the two legitimately differ.
Setting primary alone does nothing for inbound routing. If APP_URL is https://admin.myapp.com while your tenants live at *.myapp.com, subdomain matching fails silently and every request falls through to the first live site — the wrong tenant, with no error. Set APP_URL to the parent host.
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 underAPP_URL's 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.
- 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. 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 uniqueness 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)