Appearance
Dynamic Pages
FilamentCraft templates handle marketing pages. Every real storefront also has pages that are too dynamic to live in a visual builder — cart, checkout, account, lesson player, search results, blog comments, gated downloads — driven by user state or per-request data.
These pages aren't FilamentCraft templates, but they still need to look like part of the academy / tenant's site. FilamentCraft ships four doors — all backed by the same shell renderer — so you reach for the Laravel idiom you already use.
| Adopter style | The door | One-liner |
|---|---|---|
| Livewire-first | #[Storefront] attribute | #[Storefront] on the component class |
| Route-driven | Route::filamentCraftStorefront() macro | Route::filamentCraftStorefront(Academy::class, fn () => …) |
| File-based | Laravel Folio integration | drop a .blade.php in resources/views/storefront/ |
| Explicit / one-off | <x-filamentcraft::layout> Blade component | <x-filamentcraft::layout>…</x-filamentcraft::layout> |
All four render the same theme tokens + header region + footer region + fonts + stylesheets + correct lang/dir/data-fc-color-scheme on <html>. Pick one and never think about the others.
Door 1 — #[Storefront] Livewire attribute (recommended)
For any Livewire full-page component, one attribute is the whole integration. The Site is auto-resolved from the container, TenancyResolver, or the URL's tenant slug — no middleware on the route required when your route parameter matches the configured fallback.
php
use FilamentCraft\Attributes\Storefront;
use Illuminate\Contracts\View\View;
use Livewire\Component;
#[Storefront]
final class CartPage extends Component
{
public function render(): View
{
return view('livewire.cart');
}
}php
// routes/web.php — note: no middleware, no group, no layout wrapper.
Route::get('/{tenantSlug}/cart', CartPage::class);That's it. <x-filamentcraft::layout> does all the work under the hood; #[Storefront] is a self-documenting alias for the standard #[Layout('filamentcraft::layout')]. Anything you can do with Livewire's own #[Layout] — pass data, target different layouts per method — works identically with #[Storefront].
If you prefer staying with the Livewire-native attribute, write
#[Layout('filamentcraft::layout')]instead. Same behaviour, two more tokens of code.
Page titles
Use Livewire's own #[Title] attribute alongside #[Storefront]:
php
use Livewire\Attributes\Title;
#[Storefront]
#[Title('Your cart')]
final class CartPage extends Component { /* ... */ }Passing data to the layout
php
#[Storefront(['title' => 'Your cart'])]
final class CartPage extends Component { /* ... */ }The array is forwarded to the layout view, but the shipped layout consumes only title (everything else is dropped). To use extra params — canonical URLs, OG tags — publish the filamentcraft::components.layout view and read them there.
Door 2 — Route::filamentCraftStorefront() macro
For traditional Laravel controllers / Blade pages / Livewire components without #[Storefront], register a whole storefront group in one line. Tenancy + URL prefix + middleware are wired for you, the Site is bound to the container before each handler runs.
php
// routes/web.php — register BEFORE Route::filamentCraftTenant(...)
Route::filamentCraftStorefront(\App\Models\Academy::class, function (): void {
Route::get('cart', CartPage::class);
Route::get('checkout', CheckoutPage::class);
Route::get('account', AccountPage::class);
Route::get('courses/{course}/lessons/{lesson}', LessonPlayer::class);
});The macro registers the group with prefix {tenantSlug} and middleware web + filamentcraft.tenant:{tenantSlug},{Owner},{slug}.
With filamentcraft.tenancy.owner_model in config, even the first argument is optional:
php
Route::filamentCraftStorefront(routes: function (): void {
Route::get('cart', CartPage::class);
});Customising parameter / slug column / pattern
php
Route::filamentCraftStorefront(
\App\Models\Academy::class,
function (): void { /* routes */ },
tenantParameter: 'academySlug',
tenantSlugColumn: 'public_slug',
tenantPattern: '^[a-z0-9-]+$',
);Door 3 — Laravel Folio file-based routing (zero PHP)
For adopters using Laravel Folio: opt in once and each .blade.php file under resources/views/storefront/ can be routed under the tenant prefix and wrapped in the FilamentCraft shell.
bash
composer require laravel/folio
php artisan filamentcraft:install --folioThe --folio flag:
Warns (without crashing) if Folio isn't installed yet.
Scaffolds
resources/views/storefront/cart.blade.phpfrom a starter stub.Prints the FolioServiceProvider snippet you need to add manually:
php// app/Providers/FolioServiceProvider.php public function boot(): void { Folio::path(resource_path('views/storefront')) ->uri('/{tenantSlug}') ->middleware(['*' => ['filamentcraft.tenant']]); }
Now drop files:
resources/views/storefront/
cart.blade.php → /{tenantSlug}/cart
checkout.blade.php → /{tenantSlug}/checkout
account/index.blade.php → /{tenantSlug}/accountWrap each file in <x-filamentcraft::layout> (the --folio flag generates a starter cart.blade.php you can copy). Zero PHP boilerplate per page.
Door 4 — <x-filamentcraft::layout> Blade component
The escape hatch when none of the above fits — for example, a controller that needs to swap layouts based on some runtime check, or a one-off page in an admin panel.
blade
{{-- resources/views/cart.blade.php in the host app --}}
<x-filamentcraft::layout :title="'Your cart — '.$site->name">
<div class="container py-12">
<h1 class="fc-h1">Your cart</h1>
@livewire('cart-items')
</div>
</x-filamentcraft::layout>This is what Doors 1, 2, and 3 use under the hood.
The component wraps any content you give it in the same shell the FilamentCraft renderer uses for built templates:
- The site's theme tokens (CSS variables for colors, fonts, spacing)
- The site's header region (whatever sections the owner placed there)
- The site's footer region
- All registered stylesheets, fonts (Bunny preconnect included), scripts
- Correct
lang,dir(RTL), anddata-fc-color-schemeon<html>
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
:site | Site | app(Site::class) → TenancyResolver → URL fallback | The Site whose theme + regions to render. Auto-resolved from the container when middleware (or a {tenantSlug} URL param) is in play. |
:title | ?string | $site->name | Sets <title>. |
:color-scheme | ?string | the site's global scheme | Inherits Site.settings_json['color_scheme'] so a host page matches the rest of the site. Pass a slug to pin one page to a different scheme. |
:mode | string | 'published' | 'published' reads live region data; 'draft' reads in-progress edits (rare outside the editor preview). |
:locale | ?string | $site->default_locale | Forces a locale; otherwise inherits the site default. |
:livewire | bool | true | Emits @livewireStyles / @livewireScripts in the shell; set false for fully static pages. |
:indexable | bool | false | These host-rendered pages carry a noindex robots meta by default (they're usually utility pages — cart, checkout, account). Set :indexable="true" on a page you want search engines to index. |
Renaming the component
The package ships one class — the host picks the name. Alias in your service provider:
php
// app/Providers/AppServiceProvider.php
use FilamentCraft\View\Components\Layout as FilamentCraftLayout;
use Illuminate\Support\Facades\Blade;
public function boot(): void
{
Blade::component('academy-shell', FilamentCraftLayout::class);
}Then everywhere in your views:
blade
<x-academy-shell>
@livewire('cart-items')
</x-academy-shell>How Site resolution works
Every door eventually calls <x-filamentcraft::layout>, which resolves $site as explicit :site prop → container binding → TenancyResolver::resolve() → URL-parameter fallback, throwing a RuntimeException when all four fail. The full chain, modes, and middleware are covered in Tenancy.
Gotchas
Route order matters.
Route::filamentCraftTenant(...)(from Public Routing) is a wildcard{tenantSlug}/{path?}that catches everything. Register your storefront routes (cart, checkout, account, …) before it, or they're shadowed.Filament panel tenancy. Inside the admin panel,
<x-filamentcraft::layout>works automatically becauseFilament::getTenant()populates the resolution chain. On public storefront routes, you need either thefilamentcraft.tenantmiddleware, theRoute::filamentCraftStorefront()macro, or a URL slug that matchesfilamentcraft.tenancy.url_parameter(default:tenantSlug).Stylesheet ordering. The shell auto-injects the package's
filamentcraft-site.css(built-in section styles). When you ship your own Vite bundle, make sure your CSS doesn't fight the--fc-*variables. See Styling & Tailwind./admin/*coexistence.Route::filamentCraftTenant()defaults itstenantPatternto^(?!admin\b)[A-Za-z0-9-]+, which excludes any tenant slug starting withadminso a Filament panel mounted at/admin/{tenant}keeps working. If you change Filament's panelpathto something other thanadmin(e.g.->path('control-panel')), pass a matchingtenantPatternto the macro to exclude that prefix too — otherwise URLs like/control-panel/userswill be captured as a tenant slug.phpRoute::filamentCraftTenant( \App\Models\Academy::class, tenantPattern: '^(?!admin\b|control-panel\b)[A-Za-z0-9-]+', );Seeding templates: circular FK between
TemplateandTemplateRevision. The schema has a circular FK by design (templates reference their head/published revision, revisions reference their template). The supported path is a blueprint —BlueprintSeederdoes this three-step dance for you. If you must seed by hand: create theTemplaterow first, then theTemplateRevisionwith the parenttemplate_id, then update the template's revision pointers:phpuse FilamentCraft\Enums\TemplateStatus; use FilamentCraft\Enums\TemplateType; use FilamentCraft\Models\Template; use FilamentCraft\Models\TemplateRevision; $template = Template::query()->create([ 'site_id' => $site->id, 'slug' => 'home', 'name' => 'Home', 'status' => TemplateStatus::Published, 'type' => TemplateType::Page, ]); $revision = TemplateRevision::query()->create([ 'template_id' => $template->id, 'sections_json' => [ 'version' => 2, 'locales' => [ 'en' => [ 'order' => ['hero-abc123'], 'sections' => [ 'hero-abc123' => [ 'id' => 'hero-abc123', 'type' => 'hero', 'settings' => ['heading' => 'Welcome'], 'blocks' => [], ], ], ], ], ], ]); $template->headRevision()->associate($revision); $template->publishedRevision()->associate($revision); $template->save();localesholds one{order, sections}bucket per site language — see Localization. CreatingTemplateRevisionfirst hits aNOT NULL constraint failed: template_iderror — the schema doesn't allow orphan revisions.Header / footer regions need explicit seeding. A freshly-created
Sitehas noRegionrows.RegionRendererreturns an emptyHtmlStringfor missing regions, so your shell will render correctly but without a visible header bar or footer until the academy owner creates them in the editor (or you seed them).
