Skip to content

Example: dynamic Livewire page in the theme shell

Not every page belongs in the visual editor. Carts, checkouts, account areas, and contact forms are stateful application pages — but they still need to look like the rest of the site: same header, footer, fonts, and color tokens. The demo's contact page shows the pattern: a plain Livewire component that renders inside FilamentCraft's storefront layout via the #[Storefront] attribute.

Three files, all from the demo app:

  • routes/web.php — a standard Livewire full-page route
  • app/Livewire/Storefront/ContactPage.php — the component
  • resources/views/livewire/storefront/contact-page.blade.php — the view

The route

Nothing FilamentCraft-specific — no middleware, no group:

php
Route::get('/lumen/contact', ContactPage::class)->name('lumen.contact');

One ordering caveat: register this route before any tenant catch-alls. A root-level /{tenant}/{path?} catch-all registered first would swallow /lumen/contact and try to render it as a template — see the reserved-slug pattern in Multi-Tenant SaaS.

The component

php
<?php

declare(strict_types=1);

namespace App\Livewire\Storefront;

use App\Models\Team;
use FilamentCraft\Attributes\Storefront;
use FilamentCraft\Models\Site;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;

#[Storefront(['title' => 'Contact — Lumen'])]
final class ContactPage extends Component
{
    #[Validate('required|string|min:2|max:120')]
    public string $name = '';

    #[Validate('required|email|max:160')]
    public string $email = '';

    #[Validate('required|string|min:10|max:2000')]
    public string $message = '';

    public bool $sent = false;

    public function mount(): void
    {
        $this->bindSite();
    }

    public function submit(): void
    {
        $this->validate();

        // Demo sandbox: nothing is actually emailed — we only prove the
        // stateful round-trip renders back inside the themed shell.
        $this->sent = true;

        $this->reset(['name', 'email', 'message']);
    }

    public function render(): View
    {
        $this->bindSite();

        return view('livewire.storefront.contact-page');
    }

    private function bindSite(): void
    {
        if (app()->bound(Site::class)) {
            return;
        }

        $team = Team::query()->where('slug', 'lumen')->firstOrFail();

        app()->instance(Site::class, Site::query()->forOwner($team)->live()->firstOrFail());
    }
}

#[Storefront]

FilamentCraft\Attributes\Storefront is a final subclass of Livewire's own #[Layout] attribute, hard-wired to the package layout:

php
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class Storefront extends Layout
{
    public const LAYOUT_NAME = 'filamentcraft::layout';

    /**
     * @param array<string, mixed> $params
     */
    public function __construct(array $params = [])
    {
        parent::__construct(self::LAYOUT_NAME, $params);
    }
}

So #[Storefront(['title' => 'Contact — Lumen'])] is exactly #[Layout('filamentcraft::layout', ['title' => …])] — anything Livewire's #[Layout] supports works identically. The layout renders the site's header and footer regions and emits the active theme's token stylesheet around your component's output. See dynamic pages for the other integration doors (route macro, Blade component).

Binding the Site

The storefront layout resolves the current Site through the tenancy resolution chain; a container binding of Site::class is the second step in that chain and beats any resolver guessing. This route runs no tenant middleware, so the component binds the site itself: look up the owner (Team with slug lumen), then find its live site with the model's forOwner() and live() scopes.

If you'd rather skip the whole bindSite() dance, register the route inside Route::filamentCraftStorefront() — the macro's tenant middleware binds the Site into the container before the component boots (dynamic pages).

Two details are load-bearing:

  • bindSite() runs in both mount() and render(). mount() only fires on the first request; subsequent Livewire updates (the submit round-trip) hydrate the component and go straight to render(), and the container binding does not survive between requests. Without the second call, the re-rendered page would fall through to the resolver chain — or fail on hosts with no fallback signal.
  • The app()->bound() guard makes the helper a no-op wherever middleware (or another component) already bound a site, so the same component works on tenant-middleware routes unchanged.

The round-trip

Standard Livewire: #[Validate] rules on the public properties, $this->validate() in submit(), then flip $sent and reset() the fields. The point of the example is that the full cycle — validation errors, loading states, the success re-render — happens inside the themed shell with no extra wiring.

Expected result: visiting /lumen/contact renders the form between the site's header and footer regions, wrapped in the active theme's token stylesheet — the page looks like any editor-built page of the same site.

The view

The component styles itself from theme tokens with fallbacks, so it follows whatever theme the site owner picked in the editor. Trimmed to the instructive parts, with one deliberate change from the demo source: the demo aliases brand tokens (--fc-color-brand-primary, --fc-font-heading, …) that exist only because its seed data populates Theme.tokens_json — on a stock install those variables are undefined and the page would render its hard-coded fallbacks. The version below reads the scheme tokens every FilamentCraft theme emits, so it works everywhere:

blade
<div class="fc-contact" style="--accent: var(--fc-color-primary, #4f46e5); --ink: var(--fc-color-on-surface, #0f172a); --surface: var(--fc-color-surface, #f8fafc);">
    <style>
        .fc-contact { padding: clamp(2.5rem, 6vw, 5rem) 1.25rem; background: var(--surface); }
        .fc-contact__inner { max-width: 42rem; margin: 0 auto; }
        .fc-contact__eyebrow { display: inline-block; font-size: .75rem; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; color: var(--accent); margin-bottom: .75rem; }
        .fc-contact__title { font-family: var(--font-heading, inherit); font-size: clamp(1.75rem, 4vw, 2.5rem); line-height: 1.1; font-weight: 800; color: var(--ink); margin: 0 0 .75rem; }
        {{--remaining component styles trimmed --}}
    </style>

    <div class="fc-contact__inner">
        <span class="fc-contact__eyebrow">{{ __('Get in touch') }}</span>
        <h1 class="fc-contact__title">{{ __('Talk to the Lumen team') }}</h1>

        <div class="fc-contact__card">
            @if ($sent)
                {{-- … success state + "send another" button trimmed --}}
            @else
                <form wire:submit="submit" novalidate>
                    <div class="fc-contact__field">
                        <label class="fc-contact__label" for="contact-name">{{ __('Name') }}</label>
                        <input id="contact-name" type="text" class="fc-contact__control" wire:model="name" autocomplete="name">
                        @error('name') <span class="fc-contact__error">{{ $message }}</span> @enderror
                    </div>

                    {{-- … email and message fields (same shape) trimmed --}}

                    <button type="submit" class="fc-contact__button" wire:loading.attr="disabled" wire:target="submit">
                        <span wire:loading.remove wire:target="submit">{{ __('Send message') }}</span>
                        <span wire:loading wire:target="submit">{{ __('Sending…') }}</span>
                    </button>
                </form>
            @endif
        </div>
    </div>
</div>

The root element aliases the theme's scheme tokens (--fc-color-primary, --fc-color-on-surface, --fc-color-surface) into local custom properties with hard-coded fallbacks, and the heading reads --font-heading — so the page stays usable even if rendered outside the shell, but blends in perfectly inside it. (If your app populates Theme.tokens_json, its entries are flattened to --fc-color-* / --fc-font-* variables too, and you can alias those instead — that is what the demo's original does with its seeded brand tokens.) Because the component is a single root <div> with scoped class names, its <style> block travels with it and needs no build step in the host app.