Skip to content

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 styleThe doorOne-liner
Livewire-first#[Storefront] attribute#[Storefront] on the component class
Route-drivenRoute::filamentCraftStorefront() macroRoute::filamentCraftStorefront(Academy::class, fn () => …)
File-basedLaravel Folio integrationdrop 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.


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 --folio

The --folio flag:

  1. Warns (without crashing) if Folio isn't installed yet.

  2. Scaffolds resources/views/storefront/cart.blade.php from a starter stub.

  3. 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}/account

Wrap 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), and data-fc-color-scheme on <html>

Props

PropTypeDefaultNotes
:siteSiteapp(Site::class)TenancyResolver → URL fallbackThe Site whose theme + regions to render. Auto-resolved from the container when middleware (or a {tenantSlug} URL param) is in play.
:title?string$site->nameSets <title>.
:color-scheme?stringthe site's global schemeInherits 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.
:modestring'published''published' reads live region data; 'draft' reads in-progress edits (rare outside the editor preview).
:locale?string$site->default_localeForces a locale; otherwise inherits the site default.
:livewirebooltrueEmits @livewireStyles / @livewireScripts in the shell; set false for fully static pages.
:indexableboolfalseThese 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

  1. 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.

  2. Filament panel tenancy. Inside the admin panel, <x-filamentcraft::layout> works automatically because Filament::getTenant() populates the resolution chain. On public storefront routes, you need either the filamentcraft.tenant middleware, the Route::filamentCraftStorefront() macro, or a URL slug that matches filamentcraft.tenancy.url_parameter (default: tenantSlug).

  3. 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.

  4. /admin/* coexistence. Route::filamentCraftTenant() defaults its tenantPattern to ^(?!admin\b)[A-Za-z0-9-]+, which excludes any tenant slug starting with admin so a Filament panel mounted at /admin/{tenant} keeps working. If you change Filament's panel path to something other than admin (e.g. ->path('control-panel')), pass a matching tenantPattern to the macro to exclude that prefix too — otherwise URLs like /control-panel/users will be captured as a tenant slug.

    php
    Route::filamentCraftTenant(
        \App\Models\Academy::class,
        tenantPattern: '^(?!admin\b|control-panel\b)[A-Za-z0-9-]+',
    );
  5. Seeding templates: circular FK between Template and TemplateRevision. The schema has a circular FK by design (templates reference their head/published revision, revisions reference their template). The supported path is a blueprintBlueprintSeeder does this three-step dance for you. If you must seed by hand: create the Template row first, then the TemplateRevision with the parent template_id, then update the template's revision pointers:

    php
    use 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();

    locales holds one {order, sections} bucket per site language — see Localization. Creating TemplateRevision first hits a NOT NULL constraint failed: template_id error — the schema doesn't allow orphan revisions.

  6. Header / footer regions need explicit seeding. A freshly-created Site has no Region rows. RegionRenderer returns an empty HtmlString for 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).