Widgets and surfaces
Letting the Designer place your content, and opening your screens to it, without ever depending on it.
The contracts live in the core (App\Support\Widgets and
App\Support\Designer\*), and the Designer module consumes them. That is the
whole trick: you register against the core, and if the Designer is not
installed, nothing happens.
Note Every registration on this page is inert without the Designer. Your module must work identically with it uninstalled.
Widget areas#
A screen can open a region for widgets without knowing who fills it:
\App\Support\Widgets::declareArea('profile.sidebar');
And in the view:
{{ \App\Support\Widgets::render('profile.sidebar', ['member' => $member]) }}
A published Designer document for that area wins over the widget stack; unpublishing gives the stack back immediately.
Registering a widget#
A Designer widget is a core widget with a designer key in its descriptor.
Without that key it still works in the legacy areas and simply does not appear
in the editor.
Widgets::register(
'directory.latest',
static fn (): string => __('directory::widgets.latest'),
static function (array $context) {
$items = /* ... */;
return $items->isEmpty()
? '' // nothing to show, no empty box
: view('directory::widgets.latest', ['items' => $items]);
},
[
'areas' => ['profile.sidebar'],
'cache_ttl' => 300,
'designer' => [
'category' => 'directory',
'icon' => 'list',
'keywords' => ['directory', 'latest'],
'schema' => [
'limit' => ['type' => 'number', 'label' => 'directory::widgets.limit', 'min' => 1, 'max' => 20],
],
'defaults' => ['limit' => 5],
'supports' => ['tag', 'conditions'],
],
],
);
Labels are always callables: at boot time your language files may not be loaded yet.
The $context your renderer receives carries settings (schema values with
defaults applied and dynamic tags resolved), item when it is inside a loop,
node for stable ARIA ids, and whatever the surface declared in data.
Surfaces#
A surface is a screen of yours whose middle the Designer may replace:
Surfaces::register('directory.index', [
'label' => static fn (): string => __('directory::admin.surface_index'),
'module' => 'directory',
'data' => static fn (array $ctx): array => ['items' => $ctx['items'] ?? null],
'item' => 'directory.item',
'preview' => static fn (): string => route('directory.index'),
]);
And in your view:
@designer('directory.index', ['items' => $items])
@foreach ($items as $item)
@include('directory::partials.card', ['item' => $item])
@endforeach
@enddesigner
The body is captured into a buffer. If a published document exists for that key, the layout replaces it; if not, the buffer is emitted unchanged. Without the Designer installed, the directive returns the fallback and touches nothing.
Collections#
A collection is a data source for the loop element:
Collections::register('directory.items', [
'label' => static fn (): string => __('directory::admin.collection_items'),
'module' => 'directory',
'item' => 'directory.item',
'orders' => ['created_at', 'title', 'random'],
'filters' => [
'category' => ['type' => 'select', 'label' => 'directory::admin.f_category', 'options' => []],
],
'query' => static fn (array $args) => Item::query()->visibleToCurrentMember(),
]);
Visibility is the responsibility of the collection, never of whoever uses it. Apply the same guards your own screens apply. A loop built by an administrator in the editor must never reveal what your screen would hide.
Dynamic tags, conditions and form actions#
The same shape applies to the other three registries: a key, a lazy label, the module, and a callable that does the work.
- Dynamic tags turn an item into a value for a control.
- Conditions decide whether a node renders. They are evaluated on the server, so protected content never reaches the HTML.
- Form actions run after a valid submission. An action that throws is reported and the submission still succeeds: the visitor must not lose their message because a third-party webhook is down.
Breadcrumbs#
Push, and the breadcrumb widget draws:
\App\Support\Breadcrumbs::push(__('directory::labels.title'), route('directory.index'));
\App\Support\Breadcrumbs::push($item->title); // the last level carries no URL