Layup Widget Development Guide for AI Agents
This document is a complete reference for AI coding agents (Claude Code, Cursor, Copilot, etc.) to create Layup widgets without error. Follow every rule precisely. Deviations cause runtime failures, missing form fields, or broken rendering.
Architecture Overview
A Layup widget consists of exactly three files:
- PHP class -- extends
Crumbls\Layup\View\BaseWidget, defines form fields, defaults, preview, and metadata - Blade view -- renders the widget's frontend HTML using the
$dataarray - Test file (optional) -- Pest test using
LayupAssertionstrait
The PHP class is a static configuration object. All public methods are static. The class is never instantiated by the developer -- BaseWidget::make($data) handles construction internally. The Blade view receives $data (associative array) and $children (array of child view components, usually empty for widgets).
File Locations
| File | Path | Convention |
|---|---|---|
| PHP class | app/Layup/Widgets/{ClassName}Widget.php |
PascalCase, must end in Widget |
| Blade view | resources/views/components/layup/{type}.blade.php |
kebab-case, matches getType() |
| Test file | tests/Unit/Layup/{ClassName}WidgetTest.php |
Matches class name |
For package development (inside crumbls/layup itself):
| File | Path |
|---|---|
| PHP class | src/View/{ClassName}Widget.php |
| Blade view | resources/views/components/{type}.blade.php |
| Test file | tests/Unit/{ClassName}WidgetTest.php |
The Critical Rule: Field Name Alignment
Every key in getDefaultData() must exactly match a field name in getContentFormSchema(), and vice versa. This is the most common source of errors. The builder stores data using the field names from the form schema. The Blade view accesses data using the keys from getDefaultData(). If these do not align, fields silently lose their values or Blade views throw Undefined array key errors.
// CORRECT -- field names match default data keys
public static function getContentFormSchema(): array
{
return [
TextInput::make('title'), // field name: 'title'
TextInput::make('subtitle'), // field name: 'subtitle'
];
}
public static function getDefaultData(): array
{
return [
'title' => '', // matches 'title'
'subtitle' => '', // matches 'subtitle'
];
}
// WRONG -- mismatch causes silent data loss
public static function getContentFormSchema(): array
{
return [
TextInput::make('heading'), // field name: 'heading'
];
}
public static function getDefaultData(): array
{
return [
'title' => '', // WRONG -- does not match 'heading'
];
}
For Repeater fields, the default must be an array of associative arrays matching the repeater's child schema:
Repeater::make('items')
->schema([
TextInput::make('title'),
RichEditor::make('content'),
])
// Default:
'items' => [
['title' => 'Item 1', 'content' => ''],
['title' => 'Item 2', 'content' => ''],
]
PHP Class Template
Every widget class follows this exact structure. Do not add constructors, instance properties, or non-static methods other than render().
<?php
declare(strict_types=1);
namespace App\Layup\Widgets;
use Crumbls\Layup\View\BaseWidget;
use Crumbls\Layup\Support\WidgetContext;
use Filament\Forms\Components\TextInput;
class ExampleWidget extends BaseWidget
{
public static function getType(): string
{
return 'example';
}
public static function getLabel(): string
{
return 'Example Widget';
}
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(),
];
}
public static function getDefaultData(): array
{
return [
'title' => '',
];
}
public static function getPreview(array $data): string
{
return $data['title'] ?? '(empty)';
}
}
Required Static Methods
getType(): string
Returns a unique kebab-case identifier. This value:
- Is stored in the JSON content structure
- Maps to the Blade view path:
components.layup.{type}(for app widgets) orlayup::components.{type}(for package widgets) - Must be unique across all registered widgets
public static function getType(): string
{
return 'pricing-card';
}
getLabel(): string
Human-readable name shown in the widget picker. For built-in widgets, use translation keys. For app widgets, a plain string is fine.
// Built-in widget:
public static function getLabel(): string
{
return __('layup::widgets.labels.pricing-card');
}
// App widget:
public static function getLabel(): string
{
return 'Pricing Card';
}
getIcon(): string
Must start with heroicon-o- (outline) or heroicon-s- (solid). The icon appears in the widget picker. Choose an icon that represents the widget's purpose.
Common choices:
heroicon-o-document-text-- text/content widgetsheroicon-o-photo-- image/media widgetsheroicon-o-cursor-arrow-rays-- interactive/button widgetsheroicon-o-cube-- generic custom widgetsheroicon-o-chart-bar-- data/stats widgetsheroicon-o-clock-- time-related widgetsheroicon-o-map-pin-- location widgets
getCategory(): string
One of these five values. Do not invent new categories:
'content'-- text, headings, lists, accordions, tabs'media'-- images, video, audio, galleries, maps'interactive'-- buttons, forms, countdowns, pricing'layout'-- spacers, dividers, sections'advanced'-- HTML, code, embeds
getContentFormSchema(): array
Returns Filament form components for the Content tab only. The Design tab (colors, spacing, borders, shadows) and Advanced tab (ID, classes, CSS, visibility, animations) are inherited automatically from BaseView.
Do not include design or advanced fields here -- they are handled by the framework.
Available Filament components:
TextInput::make('name')-- single-line text, URL, color, numberTextarea::make('name')-- multi-line textRichEditor::make('name')-- WYSIWYG HTML editorSelect::make('name')-- dropdownToggle::make('name')-- boolean switchCheckbox::make('name')-- boolean checkboxCheckboxList::make('name')-- multiple checkboxesFileUpload::make('name')-- file/image uploadRepeater::make('name')-- repeatable groupsDateTimePicker::make('name')-- date/time
For color fields, use TextInput with ->type('color'):
TextInput::make('bg_color')
->label('Background Color')
->type('color')
->nullable(),
For image uploads:
FileUpload::make('src')
->label('Image')
->image()
->directory('layup/images'),
The upload disk is applied automatically by BaseView::withUploadDisk(). Do not call ->disk() manually.
getDefaultData(): array
Every key must match a field name from getContentFormSchema(). Provide sensible defaults:
- Strings:
''(empty string) - Booleans:
falseortrue - Numbers:
0 - Selects: the default option value (e.g.,
'primary') - Repeaters: array with 1-3 example items
- File uploads:
''
getPreview(array $data): string
Returns a short string shown on the builder canvas. The base class provides a fallback that checks $data['content'], then $data['label'], then $data['src']. Override for better previews.
Pattern: descriptive text showing the widget's key value.
// Simple:
return $data['title'] ?? '(empty)';
// With context:
$count = count($data['items'] ?? []);
return "Accordion -- {$count} items";
// With URL:
$label = $data['label'] ?? 'Button';
$url = $data['url'] ?? '#';
return "{$label}" . ($url !== '#' ? " -> {$url}" : '');
Blade View Template
Every Blade view must integrate four features from the Design and Advanced tabs. These are provided as static helper methods on BaseView. Missing any of these means that feature silently stops working for your widget.
Mandatory Integration Points
idattribute -- from Advanced tabclassattribute -- visibility classes + user's custom classesstyleattribute -- inline styles from Design tab- Animation attributes -- Alpine.js directives from Advanced tab
Standard Wrapper Pattern
@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) !!}
>
{{-- Widget content here --}}
</div>
Helper Methods Reference
BaseView::visibilityClasses(array $hideOn): string
Converts the hide_on checkbox values into Tailwind responsive visibility classes. Always pass $data['hide_on'] ?? [].
// Input: ['sm', 'lg']
// Output: 'hidden md:block lg:hidden'
BaseView::buildInlineStyles(array $data): string
Reads Design tab fields and compiles them into a CSS string. Pass the entire $data array. Handles: text_color, text_align, font_size, border_radius, border_width+border_style+border_color, box_shadow, opacity, background_color, inline_css.
// Output: "color: #333; text-align: center; font-size: 1.25rem; background-color: #f0f0f0;"
BaseView::animationAttributes(array $data): string
Generates Alpine.js directives for entrance animations. Returns an empty string if no animation is configured. Use {!! !!} (unescaped) because the output contains HTML attributes.
// Output: 'x-data="{ shown: false }" x-intersect.once="shown = true" :style="shown ? ..."'
Data Access in Blade
Always use null-coalescing for every data access:
{{-- Text output (escaped) --}}
{{ $data['title'] ?? '' }}
{{-- HTML output (unescaped -- for RichEditor content) --}}
{!! $data['content'] ?? '' !!}
{{-- Conditional rendering --}}
@if(!empty($data['caption']))
<p>{{ $data['caption'] }}</p>
@endif
{{-- File uploads -- check is_array for edit-mode compatibility --}}
@if(!empty($data['src']))
<img src="{{ is_array($data['src']) ? '' : asset('storage/' . $data['src']) }}"
alt="{{ $data['alt'] ?? '' }}" />
@endif
{{-- Repeater items --}}
@foreach(($data['items'] ?? []) as $index => $item)
<div>{{ $item['title'] ?? '' }}</div>
@endforeach
{{-- Select/match for variant styles --}}
{{ match($data['style'] ?? 'primary') {
'primary' => 'bg-blue-600 text-white',
'secondary' => 'bg-gray-600 text-white',
default => 'bg-blue-600 text-white',
} }}
File Upload Image Handling
File uploads store a string path during save (e.g., layup/images/photo.jpg) but may be a temporary array during editing. Always guard:
@php
$src = is_array($data['src'] ?? null) ? '' : ($data['src'] ?? '');
@endphp
@if($src !== '')
<img src="{{ asset('storage/' . $src) }}" alt="{{ $data['alt'] ?? '' }}" />
@endif
Using WidgetData (Optional)
For cleaner Blade views, use the WidgetData value object:
@php $d = \Crumbls\Layup\Support\WidgetData::from($data); @endphp
<h1>{{ $d->string('title') }}</h1>
<img src="{{ $d->storageUrl('src') }}" alt="{{ $d->string('alt') }}" />
@if($d->bool('show_overlay'))
<div style="opacity: {{ $d->float('opacity', 0.5) }}"></div>
@endif
Optional Static Methods
These have no-op defaults in BaseWidget. Override only when needed.
prepareForRender(array $data): array
Transform data before it reaches the Blade view. Use for computed values, type casting, or URL resolution. Called automatically in the render pipeline.
public static function prepareForRender(array $data): array
{
$data['target_timestamp'] = strtotime($data['target_date'] ?? 'now');
$data['is_expired'] = $data['target_timestamp'] < time();
return $data;
}
getValidationRules(): array
Validation rules checked by ContentValidator. Keys are field names, values are Laravel validation rule strings.
public static function getValidationRules(): array
{
return [
'label' => 'required|string',
'url' => 'required|url',
];
}
getSearchTerms(): array
Extra terms for the widget picker search. The picker already searches getType() and getLabel().
public static function getSearchTerms(): array
{
return ['cta', 'action', 'conversion'];
}
isDeprecated(): bool / getDeprecationMessage(): string
Mark a widget for removal. Surfaced in layup:doctor and layup:audit.
public static function isDeprecated(): bool
{
return true;
}
public static function getDeprecationMessage(): string
{
return 'Use CardWidget instead. Removal planned for v2.0.';
}
getAssets(): array
Declare external JS/CSS the widget requires. Collected by WidgetAssetCollector.
public static function getAssets(): array
{
return [
'js' => ['https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js'],
'css' => [],
];
}
Lifecycle Hooks
All receive $data and optional WidgetContext. All return the (possibly modified) $data array except onDelete which returns void.
// Called after the form is saved
public static function onSave(array $data, ?WidgetContext $context = null): array
{
// Normalize, sanitize, or transform data before storage
return $data;
}
// Called when widget is first created
public static function onCreate(array $data, ?WidgetContext $context = null): array
{
return $data;
}
// Called when widget is deleted -- cleanup resources
public static function onDelete(array $data, ?WidgetContext $context = null): void
{
if (!empty($data['src']) && is_string($data['src'])) {
Storage::disk(config('layup.uploads.disk', 'public'))->delete($data['src']);
}
}
// Called when widget is duplicated -- clone resources
public static function onDuplicate(array $data, ?WidgetContext $context = null): array
{
if (!empty($data['src']) && is_string($data['src'])) {
$ext = pathinfo($data['src'], PATHINFO_EXTENSION);
$newPath = 'layup/images/' . Str::uuid() . '.' . $ext;
Storage::disk(config('layup.uploads.disk', 'public'))->copy($data['src'], $newPath);
$data['src'] = $newPath;
}
return $data;
}
Form Field Packs
Reusable field groups for common patterns. Spread them into getContentFormSchema():
use Crumbls\Layup\Support\FieldPacks;
public static function getContentFormSchema(): array
{
return [
TextInput::make('heading')->required(),
...FieldPacks::image('hero'), // hero_src (FileUpload) + hero_alt (TextInput)
...FieldPacks::link('cta'), // cta_url (TextInput) + cta_new_tab (Toggle)
...FieldPacks::colorPair('text', 'bg'), // text_color + bg_color
...FieldPacks::hoverColors('btn'), // btn_bg_color, btn_hover_bg_color, btn_text_color, btn_hover_text_color
];
}
When using FieldPacks, the field names are prefixed. Your getDefaultData() must match:
public static function getDefaultData(): array
{
return [
'heading' => '',
'hero_src' => '', // from FieldPacks::image('hero')
'hero_alt' => '', // from FieldPacks::image('hero')
'cta_url' => '', // from FieldPacks::link('cta')
'cta_new_tab' => false, // from FieldPacks::link('cta')
'text_color' => '', // from FieldPacks::colorPair('text', 'bg')
'bg_color' => '', // from FieldPacks::colorPair('text', 'bg')
'btn_bg_color' => '', // from FieldPacks::hoverColors('btn')
'btn_hover_bg_color' => '',
'btn_text_color' => '',
'btn_hover_text_color' => '',
];
}
Registration
After creating the PHP class and Blade view, the widget must be registered.
Option 1: Auto-discovery -- place in app/Layup/Widgets/. Also register with the Filament plugin:
LayupPlugin::make()->widgets([MyWidget::class])
Option 2: Config -- add to config/layup.php widgets array.
Option 3: Plugin only -- register via LayupPlugin::make()->widgets([...]).
Test File
Generate with php artisan layup:make-widget MyWidget --with-test, or create manually:
<?php
use Crumbls\Layup\Testing\LayupAssertions;
use App\Layup\Widgets\MyWidget;
uses(LayupAssertions::class);
it('satisfies the widget contract', function () {
$this->assertWidgetContractValid(MyWidget::class);
});
it('defaults cover all form fields', function () {
$this->assertDefaultsCoverFormFields(MyWidget::class);
});
it('renders with default data', function () {
$this->assertWidgetRendersWithDefaults(MyWidget::class);
});
The assertions check:
assertWidgetContractValid-- implements Widget, type/label/icon/category non-empty, icon starts withheroicon-, form schema is array, defaults is array, preview returns string,toArray()has required keysassertDefaultsCoverFormFields-- every field name ingetContentFormSchema()has a corresponding key ingetDefaultData()assertWidgetRendersWithDefaults-- widget renders non-empty HTML with default data
Debugging
Use the Artisan command to inspect a widget's full state:
php artisan layup:debug-widget my-type --data='{"title":"Hello"}'
This prints: type, class, category, deprecated status, form fields, defaults, merged data, validation rules, assets, search terms, preview, prepareForRender output, and rendered HTML.
Complete Example: Testimonial Card Widget
PHP Class
<?php
declare(strict_types=1);
namespace App\Layup\Widgets;
use Crumbls\Layup\View\BaseWidget;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
class TestimonialCardWidget extends BaseWidget
{
public static function getType(): string
{
return 'testimonial-card';
}
public static function getLabel(): string
{
return 'Testimonial Card';
}
public static function getIcon(): string
{
return 'heroicon-o-chat-bubble-bottom-center-text';
}
public static function getCategory(): string
{
return 'content';
}
public static function getContentFormSchema(): array
{
return [
Textarea::make('quote')
->label('Quote')
->required()
->rows(3),
TextInput::make('author')
->label('Author Name')
->required(),
TextInput::make('role')
->label('Role / Title'),
FileUpload::make('avatar')
->label('Avatar')
->image()
->directory('layup/images')
->avatar(),
Select::make('style')
->label('Card Style')
->options([
'default' => 'Default',
'bordered' => 'Bordered',
'filled' => 'Filled Background',
])
->default('default'),
];
}
public static function getDefaultData(): array
{
return [
'quote' => '',
'author' => '',
'role' => '',
'avatar' => '',
'style' => 'default',
];
}
public static function getPreview(array $data): string
{
$author = $data['author'] ?? '';
$quote = $data['quote'] ?? '';
$short = mb_strlen($quote) > 40 ? mb_substr($quote, 0, 40) . '...' : $quote;
if ($author !== '') {
return "\"{$short}\" -- {$author}";
}
return $short !== '' ? "\"{$short}\"" : '(empty testimonial)';
}
public static function getSearchTerms(): array
{
return ['review', 'feedback', 'quote', 'endorsement'];
}
public static function getValidationRules(): array
{
return [
'quote' => 'required|string',
'author' => 'required|string',
];
}
}
Blade View
File: resources/views/components/layup/testimonial-card.blade.php
@php
$vis = \Crumbls\Layup\View\BaseView::visibilityClasses($data['hide_on'] ?? []);
$styles = \Crumbls\Layup\View\BaseView::buildInlineStyles($data);
$avatar = is_array($data['avatar'] ?? null) ? '' : ($data['avatar'] ?? '');
$cardClass = match($data['style'] ?? 'default') {
'bordered' => 'border border-gray-200 dark:border-gray-700',
'filled' => 'bg-gray-50 dark:bg-gray-800',
default => '',
};
@endphp
<blockquote
@if(!empty($data['id']))id="{{ $data['id'] }}"@endif
class="rounded-lg p-6 {{ $cardClass }} {{ $vis }} {{ $data['class'] ?? '' }}"
style="{{ $styles }}"
{!! \Crumbls\Layup\View\BaseView::animationAttributes($data) !!}
>
@if(!empty($data['quote']))
<p class="text-gray-700 dark:text-gray-300 italic mb-4">"{{ $data['quote'] }}"</p>
@endif
<footer class="flex items-center gap-3">
@if($avatar !== '')
<img src="{{ asset('storage/' . $avatar) }}" alt="{{ $data['author'] ?? '' }}" class="w-10 h-10 rounded-full object-cover" />
@endif
<div>
@if(!empty($data['author']))
<cite class="font-medium text-gray-900 dark:text-white not-italic">{{ $data['author'] }}</cite>
@endif
@if(!empty($data['role']))
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $data['role'] }}</p>
@endif
</div>
</footer>
</blockquote>
Test File
File: tests/Unit/Layup/TestimonialCardWidgetTest.php
<?php
use Crumbls\Layup\Testing\LayupAssertions;
use App\Layup\Widgets\TestimonialCardWidget;
uses(LayupAssertions::class);
it('satisfies the widget contract', function () {
$this->assertWidgetContractValid(TestimonialCardWidget::class);
});
it('defaults cover all form fields', function () {
$this->assertDefaultsCoverFormFields(TestimonialCardWidget::class);
});
it('renders with default data', function () {
$this->assertWidgetRendersWithDefaults(TestimonialCardWidget::class);
});
Checklist for Every Widget
Before considering a widget complete, verify each item:
-
declare(strict_types=1)at the top of the PHP file - Class extends
BaseWidget -
getType()returns kebab-case string -
getType()value matches the Blade view filename (minus.blade.php) -
getLabel()returns non-empty string -
getIcon()returns a string starting withheroicon- -
getCategory()returns one of:content,media,interactive,layout,advanced -
getContentFormSchema()returns array of Filament components -
getDefaultData()has a key for every field ingetContentFormSchema() -
getDefaultData()does not have keys that are not ingetContentFormSchema() -
getPreview()returns a non-empty string for default data - Blade view uses
$data['key'] ?? ''(null coalescing) for every data access - Blade view includes
idattribute:@if(!empty($data['id']))id="{{ $data['id'] }}"@endif - Blade view includes visibility:
{{ \Crumbls\Layup\View\BaseView::visibilityClasses($data['hide_on'] ?? []) }} - Blade view includes custom classes:
{{ $data['class'] ?? '' }} - Blade view includes inline styles:
style="{{ \Crumbls\Layup\View\BaseView::buildInlineStyles($data) }}" - Blade view includes animations:
{!! \Crumbls\Layup\View\BaseView::animationAttributes($data) !!} - File uploads use
is_array()guard in Blade - RichEditor content uses
{!! !!}(unescaped output) - Plain text uses
{{ }}(escaped output) - No design/advanced fields in
getContentFormSchema()(inherited from BaseView) - Widget registered via auto-discovery, config, or plugin
Common Mistakes
- Missing default for a form field -- causes
Undefined array keyin Blade. Runlayup:doctorto detect. - Field name in schema does not match key in defaults -- data entered in the form is lost on save.
- Using
$data['key']without?? ''-- throws errors when data is incomplete. - Forgetting
is_array()check on FileUpload data -- breaks during editing when Livewire sends temp array. - Adding Design tab fields to
getContentFormSchema()-- duplicates fields, confuses users. - Using
{{ }}for RichEditor content -- escapes HTML, renders raw tags as text. - Omitting animation attributes -- entrance animations stop working for the widget.
- Omitting visibility classes -- responsive hide/show stops working.
- Using a
getType()that does not match the view filename -- widget renders blank. - Forgetting to register the widget -- widget type is unknown, pages using it render nothing.
Documentation Builder Instructions
When creating or repairing Layup documentation, read .ai/llm-package-documentation-standard.md first and follow it unless this file is more specific.
Documentation must be verified from source before it is written. Check composer.json, config/layup.php, src/LayupServiceProvider.php, src/LayupPlugin.php, src/Console/Commands, public models, public support classes, migrations, tests, and existing docs. Do not copy claims from old docs unless the current source still proves them.
Layup supports PHP 8.3+, Laravel 12 or 13, and Filament 5. livewire/livewire is suggested for projects that create widgets extending BaseLivewireWidget; do not describe Livewire as a required dependency for the standard Blade-widget install unless composer.json changes.
Every page under docs/ must start with YAML front matter containing title, nav_title, and order. Keep the existing weight value where present because older documentation tooling may still read it. Use docs/nav.json as the source of truth for the new documentation crawler navigation.
Internal documentation links should be stable in-package links such as installation.md, customization/livewire-widgets.md, or ../embedding-the-field.md. Do not add new legacy public-site links inside package docs.
Before finishing documentation work, validate navigation, links, and package behavior:
bin/docs-lint
php artisan test
If bin/docs-lint still enforces an older site rule, prefer the global standard and update the lint rule instead of changing docs back to the old format.