crumbls / layup
A visual page builder plugin for Filament 5 — Divi-style grid layouts with extensible widgets.
Fund package maintenance!
Requires
- php: ^8.2
- filament/filament: ^5.0
- laravel/framework: ^12.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- rector/rector: ^2.3
This package is auto-updated.
Last update: 2026-03-16 06:59:44 UTC
README
A visual page builder plugin for Filament. Divi-style editor with rows, columns, and 95 extensible widgets — all using native Filament form components.
Features
- Flex-based 12-column grid with responsive breakpoints (sm/md/lg/xl)
- Visual span picker — click-to-set column widths per breakpoint
- Drag & drop — reorder widgets and rows
- Undo/Redo — Ctrl+Z / Ctrl+Shift+Z with full history stack
- Widget picker modal — searchable, categorized, grid layout
- Three-tab form schema — Content / Design / Advanced on every component
- Full Design tab — text color, alignment, font size, border, border radius, box shadow, opacity, background color, padding, margin
- Responsive visibility — show/hide per breakpoint on any element
- Entrance animations — fade in, slide up/down/left/right, zoom in (via Alpine x-intersect)
- Frontend rendering — configurable routes, layouts, and SEO meta (OG, Twitter Cards, canonical, JSON-LD)
- Tailwind safelist — automatic class collection for dynamic content
- Page templates — 5 built-in templates (blank, landing, about, contact, pricing) + save your own
- Content revisions — auto-save on content change, configurable max, restore from history
- Export / Import — pages as JSON files
- Widget lifecycle hooks --
onSave,onCreate,onDelete,onDuplicatewith optional context - Content validation -- structural + widget type validation
- Form Field Packs -- reusable field groups for image, link, and color patterns
prepareForRender()hook -- transform widget data before rendering- Widget validation rules -- self-declaring validation via
getValidationRules() - Widget deprecation -- graceful sunset path with
isDeprecated() WidgetDatavalue object -- typed accessors for Blade views- Widget debug command --
layup:debug-widgetfor instant widget introspection - Render isolation -- broken widgets no longer crash entire pages
- Widget search tags -- additional terms for the builder's widget picker
- Widget asset declaration -- declare JS/CSS dependencies per widget
- Widget auto-discovery — scans
App\Layup\Widgetsfor custom widgets - Configurable model — swap the Page model per dashboard
HasLayupContenttrait -- add Layup rendering to any Eloquent model<x-layup-widget>component -- render individual widgets in any Blade template- Testing helpers -- factory states and assertions for custom widget development
- Developer tooling --
layup:doctor,layup:list-widgets,layup:searchcommands - Publishable stubs -- customize
make-widgetscaffolding templates - 1,086 tests, 3,448 assertions
Built-in Widgets (75)
| Category | Widgets |
|---|---|
| Content | Text, Heading, Blurb, Icon, Accordion, Toggle, Tabs, Person, Testimonial, Number Counter, Bar Counter, Alert, Table, Progress Circle, Blockquote, Feature List, Timeline, Stat Card, Star Rating, Logo Grid, Menu, Testimonial Carousel, Comparison Table, Team Grid, Notification Bar, FAQ (with JSON-LD), Hero Section, Breadcrumbs |
| Media | Image (with hover effects), Gallery (with lightbox + captions), Video, Audio, Slider, Map, Before/After |
| Interactive | Button (hover colors), Call to Action, Countdown, Pricing Table, Social Follow, Search, Contact Form, Login, Newsletter Signup |
| Layout | Spacer, Divider, Marquee, Section (bg image/video/gradient/parallax) |
| Advanced | HTML, Code Block, Embed |
Requirements
- PHP 8.2+
- Laravel 12+
- Filament 5
- Livewire 4
Installation
Prerequisites
Layup requires a working Filament installation. If you haven't set up Filament yet, install it first:
composer require filament/filament php artisan filament:install --panels
This creates a panel provider at app/Providers/Filament/AdminPanelProvider.php. If you already have a Filament panel set up, skip this step.
See the Filament installation docs for details.
Install Layup
1. Require the package:
composer require crumbls/layup
2. Run migrations:
php artisan migrate
This creates the layup_pages and layup_page_revisions tables.
3. Register the plugin in your Filament panel provider:
Open your panel provider (e.g. app/Providers/Filament/AdminPanelProvider.php) and add the Layup plugin:
use Crumbls\Layup\LayupPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugins([ LayupPlugin::make(), ]); }
4. Publish assets:
php artisan vendor:publish --tag=layup-assets
5. (Optional) Publish the config:
php artisan vendor:publish --tag=layup-config
That's it. Head to your Filament panel — you'll see a Pages resource in the sidebar.
Quick Verification
After installation, visit /admin/pages (or your panel path) and create a new page. You should see the visual builder with rows, columns, and the widget picker.
Frontend Rendering
Layup includes an optional frontend controller that serves pages at a configurable URL prefix.
Enable Frontend Routes
In config/layup.php:
'frontend' => [ 'enabled' => true, 'prefix' => 'pages', // → yoursite.com/pages/{slug} 'middleware' => ['web'], 'domain' => null, // Restrict to a specific domain 'layout' => 'layouts.app', // Blade component layout 'view' => 'layup::frontend.page', ],
The layout value is passed to <x-dynamic-component>, so it should be a Blade component name. For example:
'layouts.app'→resources/views/components/layouts/app.blade.php'app-layout'→App\View\Components\AppLayout
Your layout must accept a title slot and optionally a meta slot for SEO tags:
{{-- resources/views/components/layouts/app.blade.php --}} <!DOCTYPE html> <html> <head> <title>{{ $title ?? '' }}</title> {{ $meta ?? '' }} @layupScripts @vite(['resources/css/app.css']) </head> <body> {{ $slot }} </body> </html>
Nested Slugs
Pages support nested slugs via wildcard routing:
/pages/about → slug: about
/pages/about/team → slug: about/team
Custom Controller
Layup provides a base controller for frontend rendering:
AbstractController → Base (returns any Eloquent Model)
└─ PageController → Built-in slug-based lookup (ships with Layup)
Extend AbstractController to render any model that implements getSectionTree() and getContentTree().
Scaffold a controller:
php artisan layup:make-controller PageController
Or create one 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, not just Page:
use App\Models\Post; class PostController extends AbstractController { protected function getRecord(Request $request): Model { return Post::where('slug', $request->route('slug')) ->firstOrFail(); } }
After creating your controller:
-
Register the route in
routes/web.php:use App\Http\Controllers\PageController; Route::get('/{slug}', PageController::class)->where('slug', '.*');
-
Disable the built-in routes in
config/layup.php:'frontend' => [ 'enabled' => false, ],
-
Set your layout component in
config/layup.php:'frontend' => [ 'layout' => 'layouts.app', ],
Override Methods
AbstractController provides these methods your IDE will autocomplete. Override any of them to customize behavior:
| 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 to skip. |
null |
Your getRecord() returns Model, so you can return Page, a custom subclass, or any model with the right methods.
View Variables
The following variables are available in the rendered Blade view:
| Variable | Type | Description |
|---|---|---|
$page |
Model |
The resolved record (also available as $record) |
$record |
Model |
Same as $page (alias for non-Page models) |
$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 |
Plus any additional variables returned by getViewData().
Example: Authorized Pages with Custom Layout
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'; } protected function getViewData(Request $request, Model $record, array $sections): array { return [ 'user' => $request->user(), ]; } }
Example: Cached Public 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 } }
Example: View Fallback Chain
class ThemePageController extends AbstractController { protected function getRecord(Request $request): Model { return Page::published() ->where('slug', $request->route('slug')) ->firstOrFail(); } protected function getView(Request $request, Model $record): string { $slugView = 'pages.' . str_replace('/', '.', $record->slug); if (view()->exists($slugView)) { return $slugView; } return parent::getView($request, $record); } }
Tailwind CSS Integration
Layup generates Tailwind utility classes dynamically — column widths like w-6/12, md:w-3/12, gap values, and any custom classes users add via the Advanced tab. Since Tailwind scans source files (not databases), these classes need to be safelisted.
How It Works
Layup provides two layers of class collection:
- Static classes — Every possible Tailwind utility the plugin can generate (column widths × 4 breakpoints, flex utilities, gap values). These are finite and ship with the package.
- Dynamic classes — Custom classes users type into the "CSS Classes" field on any row, column, or widget's Advanced tab.
Both are merged into a single safelist file.
Quick Setup
1. Generate the safelist file:
php artisan layup:safelist
This writes storage/layup-safelist.txt with all classes (static + from published pages).
2. Add to your CSS (Tailwind v4):
/* resources/css/app.css */ @import "tailwindcss"; @source "../../storage/layup-safelist.txt";
3. Build:
npm run build
That's it. All Layup classes will be included in your compiled CSS.
Tailwind v3
If you're on Tailwind v3, add the safelist file to your tailwind.config.js:
module.exports = { content: [ './resources/**/*.blade.php', './storage/layup-safelist.txt', ], // ... }
Build Pipeline Integration
Add the safelist command to your build script so it always runs before Tailwind compiles:
{
"scripts": {
"build": "php artisan layup:safelist && vite build"
}
}
Or in a deploy script:
php artisan layup:safelist npm run build
Auto-Sync on Save
By default, Layup regenerates the safelist file every time a page is saved. If new classes are detected, it dispatches a SafelistChanged event.
'safelist' => [ 'enabled' => true, // Enable safelist generation 'auto_sync' => true, // Regenerate on page save 'path' => 'storage/layup-safelist.txt', ],
Listening for Changes
Use the SafelistChanged event to trigger a rebuild, send a notification, or log the change:
use Crumbls\Layup\Events\SafelistChanged; class HandleSafelistChange { public function handle(SafelistChanged $event): void { // $event->added — array of new classes // $event->removed — array of removed classes // $event->path — path to the safelist file logger()->info('Layup safelist changed', [ 'added' => $event->added, 'removed' => $event->removed, ]); // Trigger a rebuild, notify the team, etc. } }
Register in your EventServiceProvider:
protected $listen = [ \Crumbls\Layup\Events\SafelistChanged::class => [ \App\Listeners\HandleSafelistChange::class, ], ];
How Change Detection Works
Layup uses Laravel's cache (any driver — file, Redis, database, array) to store a hash of the last known class list. On page save, it regenerates the list, compares the hash, and only dispatches the event if something actually changed.
The safelist file write is best-effort — if the filesystem is read-only (serverless, containerized deploys), the write silently fails but the event still fires. You can listen for the event and handle the rebuild however your infrastructure requires.
Disabling Auto-Sync
If you don't want safelist regeneration on every save (e.g., in production where you build once at deploy time):
'safelist' => [ 'auto_sync' => false, ],
You'll need to run php artisan layup:safelist manually or as part of your deploy pipeline.
Command Options
# Default: write to storage/layup-safelist.txt php artisan layup:safelist # Custom output path php artisan layup:safelist --output=resources/css/layup-classes.txt # Print to stdout (pipe to another tool) php artisan layup:safelist --stdout # Static classes only (no database query — useful in CI) php artisan layup:safelist --static-only
What Gets Safelisted
| Source | Classes | Example |
|---|---|---|
| Column widths | w-{n}/12 × 4 breakpoints |
w-6/12, md:w-4/12, lg:w-8/12 |
| Flex utilities | flex, flex-wrap |
Always included |
| Gap values | gap-{0-12} |
gap-4, gap-8 |
| User classes | Anything in the "CSS Classes" field | my-hero, bg-blue-500 |
Widget-specific classes (like layup-widget-text, layup-accordion-item) are not Tailwind utilities — they're styled by Layup's own CSS and don't need safelisting.
Frontend Scripts
Layup's interactive widgets (accordion, tabs, toggle, countdown, slider, counters) use Alpine.js components. By default, the required JavaScript is inlined automatically via the @layupScripts directive.
Auto-Include (default)
No setup needed. The scripts are injected inline on any page that uses @layupScripts (included in the default page view).
// config/layup.php 'frontend' => [ 'include_scripts' => true, // default ],
Bundle Yourself
If you'd rather include the scripts in your own Vite build (for caching, minification, etc.), disable auto-include and import the file:
// config/layup.php 'frontend' => [ 'include_scripts' => false, ],
// resources/js/app.js import '../../vendor/crumbls/layup/resources/js/layup.js'
Publish and Customize
php artisan vendor:publish --tag=layup-scripts
This copies layup.js to resources/js/vendor/layup.js where you can modify it.
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) |
Rendering content from a model field
The simplest way is the @layup Blade directive:
@layup($model->field)
You can also use the LayupContent helper class directly. It implements Htmlable:
use Crumbls\Layup\Support\LayupContent; {{ new LayupContent($model->field) }}
WidgetData in Blade Views
Use the WidgetData value object for typed, null-safe access to widget data in Blade views:
@php $d = \Crumbls\Layup\Support\WidgetData::from($data); @endphp <h1>{{ $d->string('heading') }}</h1> <img src="{{ $d->storageUrl('background_image') }}" alt="{{ $d->string('alt') }}" /> @if($d->bool('show_overlay')) <div style="opacity: {{ $d->float('overlay_opacity', 0.5) }}"></div> @endif
Available methods: string(), bool(), int(), float(), array(), has(), storageUrl(), url(), toArray(). Implements ArrayAccess for backward compatibility.
Using Layup Content on Any Model
Add the HasLayupContent trait to any Eloquent model with a JSON content column:
use Crumbls\Layup\Concerns\HasLayupContent; class Post extends Model { use HasLayupContent; protected string $layupContentColumn = 'body'; // default: 'content' protected function casts(): array { return ['body' => 'array']; } }
The trait provides:
| Method | Returns | Description |
|---|---|---|
toHtml() |
string |
Render content to HTML |
getSectionTree() |
array |
Sections with hydrated Row/Column/Widget objects |
getContentTree() |
array |
Flat list of Row objects |
getUsedClasses() |
array |
Tailwind classes used in content |
getUsedInlineStyles() |
array |
Inline styles used in content |
Render in Blade:
{!! $post->toHtml() !!}
Works with the custom controller pattern -- your AbstractController subclass can return any model that uses this trait.
Rendering Individual Widgets
Use the <x-layup-widget> Blade component to render a single widget outside the page builder:
<x-layup-widget type="button" :data="['label' => 'Sign Up', 'url' => '/register']" /> <x-layup-widget type="testimonial" :data="$testimonialData" />
This resolves the widget from the registry, applies Design/Advanced tab defaults, and renders the widget's Blade view. Unknown types render nothing and log a warning.
Custom Widgets
For AI agents and detailed reference: see agents.md -- a complete, zero-ambiguity guide for creating widgets without error. Covers every method, Blade integration point, common mistakes, and a full checklist.
Create a widget by extending Crumbls\Layup\View\BaseWidget. A widget is two files: a PHP class and a Blade view.
php artisan layup:make-widget ProductCard --with-test # Creates: # app/Layup/Widgets/ProductCardWidget.php # resources/views/components/layup/product-card.blade.php # tests/Unit/Layup/ProductCardWidgetTest.php
Or create manually:
<?php declare(strict_types=1); namespace App\Layup\Widgets; use Crumbls\Layup\View\BaseWidget; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\RichEditor; class ProductCardWidget extends BaseWidget { public static function getType(): string { return 'product-card'; } public static function getLabel(): string { return 'Product Card'; } public static function getIcon(): string { return 'heroicon-o-cube'; } public static function getCategory(): string { return 'content'; } public static function getContentFormSchema(): array { return [ TextInput::make('title')->label('Title')->required(), RichEditor::make('description')->label('Description'), ]; } public static function getDefaultData(): array { return [ 'title' => '', 'description' => '', ]; } public static function getPreview(array $data): string { return $data['title'] ?: '(empty product card)'; } }
The Blade view at resources/views/components/layup/product-card.blade.php:
@php $vis = \Crumbls\Layup\View\BaseView::visibilityClasses($data['hide_on'] ?? []); @endphp <div @if(!empty($data['id']))id="{{ $data['id'] }}"@endif class="{{ $vis }} {{ $data['class'] ?? '' }}" style="{{ \Crumbls\Layup\View\BaseView::buildInlineStyles($data) }}" {!! \Crumbls\Layup\View\BaseView::animationAttributes($data) !!} > <h3>{{ $data['title'] ?? '' }}</h3> <div class="prose">{!! $data['description'] ?? '' !!}</div> </div>
Key rules:
getType()must be kebab-case and match the Blade view filename- Every key in
getDefaultData()must match a field name ingetContentFormSchema() - Blade views must include all four integration points:
id, visibility classes, inline styles, animation attributes - Design (colors, spacing) and Advanced (id, classes, CSS, animations) tabs are inherited -- do not re-declare them
The form schema automatically inherits Design (spacing, background) and Advanced (id, class, inline CSS) tabs from BaseWidget. You only define the Content tab.
Form Field Packs
Common field patterns are available as reusable packs:
use Crumbls\Layup\Support\FieldPacks; public static function getContentFormSchema(): array { return [ TextInput::make('heading')->required(), ...FieldPacks::image('hero_image'), // FileUpload + alt TextInput ...FieldPacks::link('cta'), // url TextInput + new_tab Toggle ...FieldPacks::colorPair('text', 'bg'), // two ColorPicker fields ...FieldPacks::hoverColors('btn'), // bg, hover_bg, text, hover_text colors ]; }
Transforming Data Before Render
Override prepareForRender() to transform stored data before it reaches the Blade view:
class CountdownWidget extends BaseWidget { public static function prepareForRender(array $data): array { $data['target_timestamp'] = strtotime($data['target_date'] ?? 'now'); $data['is_expired'] = $data['target_timestamp'] < time(); return $data; } }
This is called automatically in the render pipeline. The default implementation is a passthrough.
Widget Validation Rules
Widgets can self-declare validation rules via getValidationRules(). The ContentValidator will query these before falling back to hardcoded rules:
class ButtonWidget extends BaseWidget { public static function getValidationRules(): array { return [ 'label' => 'required|string', 'url' => 'required|string', ]; } }
Widget Search Tags
Add extra terms so users can find your widget in the picker:
public static function getSearchTerms(): array { return ['cta', 'action', 'conversion', 'signup']; }
Deprecating Widgets
Mark widgets as deprecated to provide a transition period:
public static function isDeprecated(): bool { return true; } public static function getDeprecationMessage(): string { return 'Use GalleryWidget instead. Removal planned for v2.0.'; }
Deprecated widgets are flagged in layup:doctor and layup:audit output.
Widget Asset Declaration
Widgets can declare external JS/CSS dependencies:
public static function getAssets(): array { return [ 'js' => ['https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js'], ]; }
Collect assets from a content structure with WidgetAssetCollector:
use Crumbls\Layup\Support\WidgetAssetCollector; $assets = WidgetAssetCollector::fromContent($page->content); // $assets = ['js' => [...], 'css' => [...]]
onDuplicate Hook
Handle resource cloning when a widget is duplicated:
public static function onDuplicate(array $data, ?WidgetContext $context = null): array { if (! empty($data['src'])) { $newPath = 'layup/images/' . Str::uuid() . '.' . pathinfo($data['src'], PATHINFO_EXTENSION); Storage::disk(config('layup.uploads.disk', 'public'))->copy($data['src'], $newPath); $data['src'] = $newPath; } return $data; }
Registration
There are three ways to register custom widgets. Whichever you choose, the widget must be registered for both the Filament admin panel and the frontend renderer.
Option 1: Auto-Discovery (recommended)
Place widget classes in app/Layup/Widgets/ and they will be auto-discovered on frontend routes automatically. However, you must also register them with the Filament plugin for the admin panel editor to recognize them:
// app/Providers/Filament/AdminPanelProvider.php use App\Layup\Widgets\MyWidget; LayupPlugin::make() ->widgets([ MyWidget::class, ])
Without this step, the widget will render correctly on the frontend but the admin edit form will appear empty (no form fields, just a save button) because the admin panel's WidgetRegistry doesn't know about the widget type.
The auto-discovery namespace and directory are configurable:
// config/layup.php 'widget_discovery' => [ 'namespace' => 'App\\Layup\\Widgets', 'directory' => null, // defaults to app_path('Layup/Widgets') ],
Option 2: Config file only
Add the widget class to the config. This registers it for both admin and frontend:
// config/layup.php 'widgets' => [ // ... built-in widgets ... \App\Layup\Widgets\MyWidget::class, ],
Option 3: Plugin only
Register via the plugin. This also covers both admin and frontend since the plugin populates the shared WidgetRegistry:
LayupPlugin::make() ->widgets([MyWidget::class])
Customizing the Widget Scaffold
To customize the templates used by layup:make-widget:
php artisan vendor:publish --tag=layup-stubs
This publishes stubs/layup-widget.php.stub and stubs/layup-widget-view.blade.php.stub to your project root, where you can modify them to match your team's conventions.
Remove built-in widgets
LayupPlugin::make() ->withoutWidgets([ \Crumbls\Layup\View\HtmlWidget::class, \Crumbls\Layup\View\SpacerWidget::class, ])
Testing
Layup ships testing helpers for verifying custom widgets and page content.
Factory States
use Crumbls\Layup\Models\Page; // Page with specific widgets $page = Page::factory()->withWidgets(['text', 'button'])->create(); // Page with explicit content structure $page = Page::factory()->withContent([...])->create();
Assertions
Add the LayupAssertions trait to your test case:
use Crumbls\Layup\Testing\LayupAssertions; test('homepage has expected widgets', function () { $page = Page::factory()->withWidgets(['heading', 'text', 'button'])->create(); $this->assertPageContainsWidget($page, 'heading'); $this->assertPageContainsWidget($page, 'button', expectedCount: 1); $this->assertPageDoesNotContainWidget($page, 'html'); $this->assertPageRenders($page); }); test('custom widget renders without errors', function () { $this->assertWidgetRenders('my-widget', ['title' => 'Hello']); });
Widget Contract Assertions
Validate that your custom widget follows all conventions:
test('widget satisfies the contract', function () { $this->assertWidgetContractValid(MyWidget::class); }); test('defaults cover all form fields', function () { $this->assertDefaultsCoverFormFields(MyWidget::class); }); test('renders with default data', function () { $this->assertWidgetRendersWithDefaults(MyWidget::class); });
Generate these tests automatically with php artisan layup:make-widget MyWidget --with-test.
Artisan Commands
| Command | Description |
|---|---|
layup:make-controller {name} |
Scaffold a frontend controller extending AbstractController |
layup:make-widget {name} |
Scaffold a custom widget (PHP class + Blade view). Use --with-test to generate a Pest test. Remember to register it with the plugin. |
layup:debug-widget {type} |
Dump the full resolved state of a widget (class, fields, defaults, validation, assets, rendered HTML). Use --data='{...}' to pass custom data. |
layup:safelist |
Generate the Tailwind safelist file |
layup:audit |
Audit page content for structural issues |
layup:doctor |
Diagnose common setup issues (config, migrations, widgets, safelist) |
layup:list-widgets |
List all registered widgets with type, label, category, and source |
layup:search {type} |
Find pages containing a widget type. Use --unused to find unregistered widgets |
layup:export |
Export pages as JSON files |
layup:import |
Import pages from JSON files |
layup:install |
Run the initial setup |
Configuration Reference
// config/layup.php return [ // Widget classes available in the page builder (registered for both admin and frontend) 'widgets' => [ /* ... */ ], // Auto-discover widgets from this namespace/directory (frontend only — // for admin panel access, also register via LayupPlugin::make()->widgets([...])) 'widget_discovery' => [ 'namespace' => 'App\\Layup\\Widgets', 'directory' => null, // defaults to app_path('Layup/Widgets') ], // Page model and table name (swap per dashboard) 'pages' => [ 'table' => 'layup_pages', 'model' => \Crumbls\Layup\Models\Page::class, ], // Frontend rendering 'frontend' => [ 'enabled' => true, 'prefix' => 'pages', 'middleware' => ['web'], 'domain' => null, 'layout' => 'layouts.app', 'view' => 'layup::frontend.page', ], // Tailwind safelist 'safelist' => [ 'enabled' => true, 'auto_sync' => true, 'path' => 'storage/layup-safelist.txt', ], // Frontend container class (applied to each row's inner wrapper) // Use 'container' for Tailwind's default, or 'max-w-7xl', etc. 'max_width' => 'container', // Responsive breakpoints 'breakpoints' => [ 'sm' => ['label' => 'sm', 'width' => 640, 'icon' => 'heroicon-o-device-phone-mobile'], 'md' => ['label' => 'md', 'width' => 768, 'icon' => 'heroicon-o-device-tablet'], 'lg' => ['label' => 'lg', 'width' => 1024, 'icon' => 'heroicon-o-computer-desktop'], 'xl' => ['label' => 'xl', 'width' => 1280, 'icon' => 'heroicon-o-tv'], ], 'default_breakpoint' => 'lg', // Row layout presets (column spans, must sum to 12) 'row_templates' => [ [12], [6, 6], [4, 4, 4], [3, 3, 3, 3], [8, 4], [4, 8], [3, 6, 3], [2, 8, 2], ], ];
Multiple Dashboards
To use Layup across multiple Filament panels with separate page tables:
// Panel A LayupPlugin::make() ->model(PageA::class) // or set via config // Panel B — different table LayupPlugin::make() ->model(PageB::class)
Your custom model with your overrides:
class PageB extends \Crumbls\Layup\Models\Page { }
Contributing
See CONTRIBUTING.md for development setup, code style, and testing guidelines. For widget development, see agents.md for a detailed reference covering every method, Blade pattern, and common pitfall.
Vision & Roadmap
See VISION.md for where Layup is headed and how you can help shape it.
License
MIT — see LICENSE.md
