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 toresources/views/components/app.blade.php'layouts.app'resolves toresources/views/components/layouts/app.blade.php
Your layout must accept a title slot and optionally a meta slot:
<!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>
{{ $meta ?? '' }}
@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.
@layupScriptsregisters 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 /:
'frontend' => [
'prefix' => '',
],
Layup automatically excludes Filament panel paths, Livewire, and other framework routes. Add custom exclusions with excluded_paths:
'frontend' => [
'prefix' => '',
'excluded_paths' => ['blog', 'shop'],
],
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:
'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:
php artisan layup:make-controller PageController
Or create manually:
<?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:
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:
// 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
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
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:
// config/layup.php
'frontend' => ['include_scripts' => false],
// resources/js/app.js
import '../../vendor/crumbls/layup/resources/js/layup.js'
Or publish and customize:
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 |
Contributors
Thank you to everyone who has contributed to this package. Every pull request, bug report, and idea makes a difference.