Skip to content

Widget Rendering: Blade vs Livewire

Every widget on a page is rendered through resources/views/components/layout/widget.blade.php. This component inspects $widget->getMetaComponentType() and routes to one of two paths:

@if ($type === 'blade')
<x-dynamic-component
:component="$component"
:$widget
...
/>
@elseif ($type === 'livewire')
@livewire($component, [...], key(...))
@endif

The $type value comes from the widget’s meta['component_type'] field. If not set, it defaults to 'blade'.

Asset-backed widgets should store stable frontend component keys instead of package Blade namespaces. For example, store section.block or section.team-member in widget asset configuration, not capell-layout-builder::section.block.

The frontend component registry resolves that stable key to the active Blade implementation at render time. Content Sections registers neutral defaults, and the core layout builder APIs or a theme package can override the same keys with richer templates:

use Capell\Frontend\Contracts\FrontendComponentRegistryInterface;
$this->callAfterResolving(
FrontendComponentRegistryInterface::class,
fn (FrontendComponentRegistryInterface $registry): FrontendComponentRegistryInterface => $registry
->register(
key: 'section.block',
component: 'capell-example-theme::section.block',
aliases: [
'capell-content-sections::section.block',
'capell-layout-builder::section.block',
],
props: [
'asset',
'class',
'color',
'icon',
'image',
'linkText',
'loop',
'meta',
'size',
'summary',
'title',
'url',
],
),
);

This keeps saved content portable. A theme can replace the template without requiring migrations, manual edits to existing widget assets, or duplicate enum values for each package namespace.

Use a Blade component for any widget that:

  • Reads data from model relations ($widget->translation, $widget->image, $widget->backgroundImage, $widget->assets)
  • Reads configuration from $widget->getMeta('key')
  • Has no server-side interactivity (no form submissions, no real-time updates)

This is the correct approach for the vast majority of widgets. Blade is the default.

Only use Livewire when the widget needs:

  • Reactive state (user input that changes the rendered output without a full page reload)
  • Server-side form submissions within the widget (e.g. contact form-builder that validate and send emails)
  • Real-time data polling

Place the view in resources/views/components/widget/ or resources/views/components/modern/:

<?php declare(strict_types=1); ?>
@props([
'title' => $widget->translation?->title,
'content' => $widget->translation?->content,
'someOption' => $widget->getMeta('some_option', 'default'),
'container',
'containerKey',
'containerWidth' => null,
'loop',
'widget',
])
<x-capell-layout-builder::widget.wrapper
class="widget-my-widget"
:$container
:$containerKey
:$containerWidth
:index="$loop->index"
:$widget
>
<section>
@if ($title)
<h2>{{ $title }}</h2>
@endif
{{-- ... --}}
</section>
</x-capell-layout-builder::widget.wrapper>

The class attribute on widget.wrapper is the CSS selector used in tests (widget-my-widget).

Add a case to WidgetComponentEnum:

case MyWidget = 'capell-layout-builder::modern.my-widget'; // for components/modern/my-widget.blade.php
case MyWidget = 'capell-layout-builder::widget.my-widget'; // for components/widget/my-widget.blade.php

The string value is the Blade component name — it maps directly to the file path under resources/views/components/.

public function myWidget(?Blueprint $blueprint = null): Widget
{
$blueprint ??= resolve(TypeCreator::class)->defaultElementType();
return $this->widgetModel::query()->firstOrCreate(['key' => 'my-widget'], [
'name' => 'My Widget',
'blueprint_id' => $blueprint->id,
'meta' => [
'component' => WidgetComponentEnum::MyWidget,
'some_option' => 'value',
'margin' => ['lg'],
],
]);
}

The meta['component'] value drives the component resolution in widget.blade.php.

SourceExample
Translation title$widget->translation?->title
Translation content (HTML)$widget->translation?->content
Meta config$widget->getMeta('columns', 3)
Primary image$widget->image (Media model)
Background image$widget->backgroundImage (Media model)
All images$widget->assets (Collection of WidgetAsset)

Images are Spatie\MediaLibrary models. Use $image->getFullUrl() for the URL and $image->name for the alt text.

Use TestingFrontend + DOM assertions. The widget must be in a layout on a page to be rendered:

uses(TestingFrontend::class);
it('renders my widget on page', function (): void {
$site = Site::factory()->withTranslations()->create();
$widget = resolve(WidgetCreator::class)->myWidget();
$translation = Translation::factory()->translatable($widget)->language($site->language)->create();
$image = Media::factory()->model($widget)->image()->create();
$layout = (new LayoutFactory)->widgets([$widget])->create();
$page = Page::factory()->site($site)->layout($layout)->withTranslations()->create();
get($page->pageUrl->full_url)
->assertOk()
->assertElementExists('.widget-my-widget',
fn (AssertElement $elm) => $elm
->containsText($translation->title)
->find('img', fn (AssertElement $img) => $img
->has('alt', $image->name)
->has('src', $image->getFullUrl())
)
);
});

Place package-level tests beside the package that owns the widget, and place core layout builder coverage in the admin/frontend core package tests.