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(['canonical' => 'https://example.com/cart'])]
final class CartPage extends Component { /* ... */ }

The array is forwarded to the layout view's $slot scope. Useful for SEO metadata, OG tags, etc.


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. All it provides over the others is explicitness — useful when you want the dependency visible at the top of the file.

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-schemestring'light'Mirrors a template-level color 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.

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>

Pick whatever reads best for your team — storefront, site-frame, mediano-page, etc. The component class is the same; the tag is yours.


How Site resolution works

Every door eventually calls <x-filamentcraft::layout>, which resolves $site in this order (first match wins):

  1. Explicit :site prop — you passed one.
  2. Container bindingapp(Site::class) is set by middleware (filamentcraft.tenant, filamentcraft.site, or your own).
  3. TenancyResolver::resolve() — config single-site id → Filament::getTenant() → container-bound owner model → first live site, with mode-specific skips. This is what makes the component work inside the admin panel.
  4. URL parameter fallback — when the current request has a {tenantSlug} route param and filamentcraft.tenancy.owner_model is configured, looks up the tenant and its live Site on its own. This is what makes Door 1 work without middleware on the route.

If all four fail, a RuntimeException is thrown with a message naming the three options to fix it. See Tenancy for the deep dive.


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). When seeding programmatically, create the Template row first, then the TemplateRevision with the parent template_id, then update the template's head_revision_id / published_revision_id — three steps:

    php
    $template = Template::query()->create([
        'site_id' => $site->id,
        'slug' => 'home',
        'name' => 'Home',
        'status' => TemplateStatus::Published,
        'type' => 'page',
    ]);
    
    $revision = TemplateRevision::query()->create([
        'template_id' => $template->id,
        'sections_json' => ['version' => 2, 'locales' => [/* ... */]],
    ]);
    
    $template->headRevision()->associate($revision);
    $template->publishedRevision()->associate($revision);
    $template->save();

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

Proprietary — distributed via Anystack.