Layup includes an optional frontend controller that serves published pages with SEO metadata. This page covers the layout requirements, routing options, custom controllers, and Alpine.js integration.

## Layout requirements

The `layout` config value is a Blade component name passed to `<x-dynamic-component>`:

- `'app'` resolves to `resources/views/components/app.blade.php`
- `'layouts.app'` resolves to `resources/views/components/layouts/app.blade.php`
- `'layouts::app'` resolves to `resources/views/layouts/app.blade.php` (Livewire anonymous namespace)

> **Using the Livewire starter kit?** Livewire v4 registers `layouts` as an anonymous component namespace pointing at `resources/views/layouts/`, and the starter kit ships its layout at `resources/views/layouts/app.blade.php`. Set `'layout' => 'layouts::app'` — the dot-notation form (`'layouts.app'`) resolves to a different path that does not exist in Livewire starter-kit projects.

Your layout must accept a `title` slot. SEO meta is rendered through a separate drop-in component, [`<x-layup-seo />`](seo-meta.md):

```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title ?? config('app.name') }}</title>
    <x-layup-seo />
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="antialiased">
    {{ $slot }}

    @layupScripts
</body>
</html>
```

Two things are required for interactive widgets to work:

- **Alpine.js** must be loaded. If your JS entry point does not import Alpine, interactive widgets (accordion, tabs, countdown, etc.) will render but will not respond to clicks.
- **`@layupScripts`** registers Layup's Alpine components. Without it, widgets that depend on custom Alpine data will not function.

## Serving pages at the site root

Set the prefix to an empty string to serve pages directly at `/`:

```php
'frontend' => [
    'prefix' => '',
],
```

Layup automatically excludes Filament panel paths, Livewire, and other framework routes. Add custom exclusions with `excluded_paths`:

```php
'frontend' => [
    'prefix' => '',
    'excluded_paths' => ['blog', 'shop'],
],
```

> **`prefix` values:** use `''` or `'/'` to mount at the site root — these are the only values that activate the Filament / framework path auto-exclusion. Any other string (e.g. `'pages'`) becomes a literal URL prefix. Setting `prefix` to `null` skips the auto-exclusion logic, so the `{slug}` catch-all can shadow unmatched admin URLs (for example, a Filament resource that has not been created yet). Prefer `''` over `null` when running at the root.

## Nested slugs

Pages support nested slugs via wildcard routing:

```
/pages/about          -> slug: about
/pages/about/team     -> slug: about/team
```

## Default page (homepage)

Set `default_slug` to serve a specific page at the index route:

```php
'pages' => [
    'default_slug' => 'home',
],
```

| URL | Resolves |
|-----|----------|
| `/pages` (or `/` with empty prefix) | Page with slug `home` |
| `/pages/about` | Page with slug `about` |

## Custom controllers

Layup provides `AbstractController` as a base for custom frontend controllers:

```
AbstractController      -> Base (returns any Eloquent Model)
  PageController        -> Built-in slug-based lookup
```

Scaffold one:

```bash
php artisan layup:make-controller PageController
```

Or create manually:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Crumbls\Layup\Http\Controllers\AbstractController;
use Crumbls\Layup\Models\Page;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;

class PageController extends AbstractController
{
    protected function getRecord(Request $request): Model
    {
        return Page::published()
            ->where('slug', $request->route('slug'))
            ->firstOrFail();
    }
}
```

Works with any model that uses `HasLayupContent`:

```php
use App\Models\Post;

class PostController extends AbstractController
{
    protected function getRecord(Request $request): Model
    {
        return Post::where('slug', $request->route('slug'))
            ->firstOrFail();
    }
}
```

Register the route and disable built-in routes:

```php
// routes/web.php
Route::get('/{slug}', PageController::class)->where('slug', '.*');

// config/layup.php
'frontend' => ['enabled' => false],
```

### Override methods

| Method | Purpose | Default |
|--------|---------|---------|
| `getRecord(Request $request): Model` | **Required.** Resolve the model. | (abstract) |
| `authorize(Request $request, Model $record): void` | Gate access. Throw/abort to deny. | No-op |
| `getLayout(Request $request, Model $record): string` | Blade layout component name. | `config('layup.frontend.layout')` |
| `getView(Request $request, Model $record): string` | Blade view to render. | `config('layup.frontend.view')` |
| `getViewData(Request $request, Model $record, array $sections): array` | Extra variables merged into view data. | `[]` |
| `getCacheTtl(Request $request, Model $record): ?int` | Seconds for `Cache-Control` header. | `null` |

### View variables

| Variable | Type | Description |
|----------|------|-------------|
| `$page` | `Model` | The resolved record (also available as `$record`) |
| `$record` | `Model` | Same as `$page` |
| `$sections` | `array` | Section tree with hydrated Row/Column/Widget objects |
| `$tree` | `array` | Flat list of Row objects (all sections merged) |
| `$layout` | `string` | Layout component name |

### Example: authorized pages

```php
class MemberPageController extends AbstractController
{
    protected function getRecord(Request $request): Model
    {
        return Page::published()
            ->where('slug', $request->route('slug'))
            ->firstOrFail();
    }

    protected function authorize(Request $request, Model $record): void
    {
        abort_unless($request->user(), 403);
    }

    protected function getLayout(Request $request, Model $record): string
    {
        return 'layouts.member-area';
    }
}
```

### Example: cached pages

```php
class CachedPageController extends AbstractController
{
    protected function getRecord(Request $request): Model
    {
        return Page::published()
            ->where('slug', $request->route('slug'))
            ->firstOrFail();
    }

    protected function getCacheTtl(Request $request, Model $record): ?int
    {
        return 300; // 5 minutes
    }
}
```

## Frontend scripts

Layup's interactive widgets use Alpine.js components. By default, they are inlined via `@layupScripts`.

### Bundle yourself

Disable auto-include and import in your build:

```php
// config/layup.php
'frontend' => ['include_scripts' => false],
```

```js
// resources/js/app.js
import '../../vendor/crumbls/layup/resources/js/layup.js'
```

Or publish and customize:

```bash
php artisan vendor:publish --tag=layup-scripts
```

### Available Alpine components

| Component | Widget | Parameters |
|-----------|--------|------------|
| `layupAccordion` | Accordion | `(openFirst = true)` |
| `layupToggle` | Toggle | `(open = false)` |
| `layupTabs` | Tabs | none |
| `layupCountdown` | Countdown | `(targetDate)` |
| `layupSlider` | Slider | `(total, autoplay, speed)` |
| `layupCounter` | Number Counter | `(target, animate)` |
| `layupBarCounter` | Bar Counter | `(percent, animate)` |
| `layupLightbox` | Gallery | none |

---

## Documentation

- [Introduction](https://documentation.crumbls.com/layup/-/index.md)
- [Introduction](https://documentation.crumbls.com/layup/-/introduction.md)
- [v1](https://documentation.crumbls.com/layup/-/_index.md)
- [Installation](https://documentation.crumbls.com/layup/-/installation.md)
- [Contributing to Layup](https://documentation.crumbls.com/layup/-/CONTRIBUTING.md)
- [Usage](https://documentation.crumbls.com/layup/-/usage.md)
- [Configuration](https://documentation.crumbls.com/layup/-/configuration.md)
- [Examples](https://documentation.crumbls.com/layup/-/examples.md)
- [Grid System](https://documentation.crumbls.com/layup/-/grid-system.md)
- [Field-only Installation](https://documentation.crumbls.com/layup/-/field-only-installation.md)
- [Embedding the Field](https://documentation.crumbls.com/layup/-/embedding-the-field.md)
- [Widgets](https://documentation.crumbls.com/layup/-/widgets/_index.md)
- [Getting Started](https://documentation.crumbls.com/layup/-/getting-started/_index.md)
- [Customization](https://documentation.crumbls.com/layup/-/customization/_index.md)
- [API Reference](https://documentation.crumbls.com/layup/-/api-reference/_index.md)
- [Upgrade guide](https://documentation.crumbls.com/layup/-/upgrade-guide.md)
- [Troubleshooting](https://documentation.crumbls.com/layup/-/troubleshooting.md)
- [Changelog](https://documentation.crumbls.com/layup/-/CHANGELOG.md)
- [🔐 Security Policy](https://documentation.crumbls.com/layup/-/SECURITY.md)
- [The MIT License (MIT)](https://documentation.crumbls.com/layup/-/LICENSE.md)
- [Layup Widget Development Guide for AI Agents](https://documentation.crumbls.com/layup/-/AGENTS.md)
- [Layup — Sprint TODO (overnight Feb 23-24)](https://documentation.crumbls.com/layup/-/TODO.md)
- [Vision](https://documentation.crumbls.com/layup/-/VISION.md)
- [Content Widgets](https://documentation.crumbls.com/layup/-/widgets/content.md)
- [Media Widgets](https://documentation.crumbls.com/layup/-/widgets/media.md)
- [Interactive Widgets](https://documentation.crumbls.com/layup/-/widgets/interactive.md)
- [Layout Widgets](https://documentation.crumbls.com/layup/-/widgets/layout.md)
- [Advanced Widgets](https://documentation.crumbls.com/layup/-/widgets/advanced.md)
- [Creating Pages](https://documentation.crumbls.com/layup/-/getting-started/creating-pages.md)
- [Adding Widgets](https://documentation.crumbls.com/layup/-/getting-started/adding-widgets.md)
- [Saving and Publishing](https://documentation.crumbls.com/layup/-/getting-started/publishing.md)
- [Disable the Pages Resource](https://documentation.crumbls.com/layup/-/customization/disable-pages-resource.md)
- [Rendering Content](https://documentation.crumbls.com/layup/-/customization/rendering-content.md)
- [Creating Custom Widgets](https://documentation.crumbls.com/layup/-/customization/custom-widgets.md)
- [Extending Existing Widgets](https://documentation.crumbls.com/layup/-/customization/extending-widgets.md)
- [Livewire-rendered widgets](https://documentation.crumbls.com/layup/-/customization/livewire-widgets.md)
- [Filament Plugin API](https://documentation.crumbls.com/layup/-/customization/filament-plugin-api.md)
- [Theme System](https://documentation.crumbls.com/layup/-/customization/theme-system.md)
- [Tailwind Safelist](https://documentation.crumbls.com/layup/-/customization/tailwind-safelist.md)
- [SEO Meta](https://documentation.crumbls.com/layup/-/customization/seo-meta.md)
- [Page Templates](https://documentation.crumbls.com/layup/-/customization/page-templates.md)
- [Revision History](https://documentation.crumbls.com/layup/-/customization/revision-history.md)
- [Swapping the Page Model](https://documentation.crumbls.com/layup/-/customization/swapping-the-page-model.md)
- [Testing](https://documentation.crumbls.com/layup/-/customization/testing.md)
- [Widget Contract](https://documentation.crumbls.com/layup/-/api-reference/widget-contract.md)
- [Models](https://documentation.crumbls.com/layup/-/api-reference/models.md)
- [Service Provider](https://documentation.crumbls.com/layup/-/api-reference/service-provider.md)
- [Support Classes](https://documentation.crumbls.com/layup/-/api-reference/support-classes.md)
- [Commands](https://documentation.crumbls.com/layup/-/api-reference/commands.md)
