Skip to content

Widgets and blocks

Two systems put editable content on a page. They look alike and suit different jobs.

Widgets Content blocks
Lives in widgets and widget_assignments, per site The entry’s content_blocks JSON column, global
Placed in Slots the theme declares The entry’s body, in editor order
Varies by Site and theme The entry. Any block can be limited to some sites
Good for Banners, sidebars, help cards, a footer strip, things repeated across a type The body of a page or post
Registered in WidgetTypeManager config/filament-flexible-content-blocks.php

Staff views: widgets, blocks and reusable blocks. The code is in app-modules/content-management/src/Widgets/ and src/ContentBlocks/.

Table Holds
widgets site_id, type (the type’s key), config (JSON owned by the type)
widget_assignments site_id, theme, assignable_type, assignable_id, area_key, widget_id, position

The assignable decides what a placement belongs to:

assignable_type assignable_id Meaning
null null Site chrome, an area in the theme’s widget_areas
A handle An id That entity’s own placement
A handle null The type’s default on the site

A placement with a null widget_id is a “hide here” marker. A saving hook refuses a widget from another site. Because assignments carry the theme, each site keeps a separate layout per theme. A widget left with no placements is deleted, one model at a time so its media goes too.

A theme declares areas in its manifest, and its Blade renders them:

'widget_areas' => [
'bottom-left' => ['name' => 'Bottom Left Widget Area', 'description' => '...'],
],
'registered_types' => [
'post_category' => [
'widget_areas' => [
'category-banner' => ['name' => 'Category Banner', 'description' => '...', 'inherit' => true],
],
],
],
<x-widget::area name="bottom-left" />
<x-widget::area name="category-banner" :for="$postCategory" />

An empty area renders nothing, which is normal. Entity widgets need both halves: the model uses HasEntityWidgets and the site’s theme declares areas for its type.

Widgets\WidgetAreaResolver decides what an area shows. Without :for, it is the site’s chrome placements. With an entity, in an area with no flag, it is the entity’s own. In an area marked defaults or inherit, the first of these with anything placed wins outright (replace, not merge):

  1. The entity itself.
  2. With inherit only: its ancestors on the site, nearest first, from site_documents.resolved_parent_id. A parent not on the site is skipped, so its widgets cannot reach the child there.
  3. The type’s default for the site.

A hide marker wins like any other placement and shows nothing. HasEntityWidgets::widgetsForSite() returns an entity’s own placements only, with no fallback.

Both editors use one Filament Builder per area, and round-trip through Widgets\Filament\WidgetLayoutState.

  • SiteResource\Pages\ManageWidgetLayout (the site’s Widgets page): a tab for chrome, then one per type with fallback areas, for type defaults. It only touches the site’s currently saved theme.
  • Filament\Support\EntityWidgetsTab: the Widgets tab on an entity’s edit page, with a tab per site it is live on. An area with a fallback offers inherit, own widgets, or hide.

A worked example of a hypothetical notice widget. A type owns its key, form and view; the config shape is its own business.

1. The class. Extend AbstractWidgetType, which supplies getDescription(), describe() and afterSave() defaults.

class NoticeWidgetType extends AbstractWidgetType
{
public function getKey(): string
{
return 'notice';
}
public function getName(): string
{
return __('Notice');
}
public function getViewName(): string
{
return 'catalog::widgets.notice';
}
public function makeFormComponent(Site $site): Component
{
return Grid::make(1)->schema([
TextInput::make('text')->label(__('Text'))->required(),
])->columnSpanFull();
}
public function describe(Site $site, array $config): string
{
return $config['text'] ?? parent::describe($site, $config);
}
}

$site is the site the widget belongs to. Use it to scope pickers and to read per-site titles from SiteDocument, as FeaturedPostCategoryWidgetType does. describe() is the label in the collapsed editor and should not assume any key exists.

2. Register. Built-in types are in the WidgetTypeManager singleton in CmsServiceProvider. Another module adds its own without content-management knowing, as CatalogServiceProvider::register() does:

$this->callAfterResolving(WidgetTypeManager::class, fn (WidgetTypeManager $types) => $types->register(new NoticeWidgetType));

It then appears as an option in every area’s Builder.

3. The view. It receives $widget, $config and $for (the entity whose page the area is on, null in chrome):

<div class="wm-widget wm-widget--notice">{{ $config['text'] ?? '' }}</div>

Render plain markup with semantic classes and style it from the theme’s CSS. A theme can replace the view under views/vendor/<namespace>/.

Images. A type with one image implements Widgets\Contracts\HasWidgetImage and uses Widgets\Concerns\StoresImageInWidgetMedia, as ImageBannerWidgetType does. The form is an item in a Builder and not bound to the Widget record, which is why afterSave() exists: it moves the upload into the widget’s media.

Blocks use statikbe/laravel-filament-flexible-content-blocks. HasContentBlocks is a thin wrapper that adds the content_blocks column and the package’s media handling. Blocks are not projected: the renderer reads the JSON directly.

Theme views draw them with <x-flexible-content-blocks :page="$page" />. Pass layout="contained" when they sit in a column. CmsServiceProvider re-registers that tag to ContentBlocks\Support\SiteContentBlocks, which only draws blocks for the current site, and takes only and except lists of block classes. A block left out is never constructed, so it adds nothing to the page, including structured data. Because Blade writes the component class into compiled views, compiled views are cleared on deploy.

  • Per-site blocks: Filament\Support\SiteContentBlocksField adds a globe button to every block’s header, keeping ticked site ids in the block’s data under sites. No block type knows about it. Raw readers of content_blocks, such as search, see every block.
  • Reusable blocks: ContentBlocks\Models\ReusableBlock is a saved set of blocks, embedded by ReusableBlock (the block) or ReusableBlockWidgetType. It excludes itself from its own editor, so reusable blocks cannot nest.
  • One-off templates: before writing a block, check whether the Template block is enough. It includes a view from the theme’s views/block-templates/, listed under templates in the config, with no form.
  • Links: both base models answer Statik’s getViewUrl() through LinksFromContentBlocks, per the current site. An entry not on the site gives an empty string, which templates render as no link.

Every block renders as <section class="cb cb--<block> cb--bg-<colour>"><div class="cb__inner">, styled by themes/default/css/content-blocks.css through --cb-* variables that a child theme can override. Editor options insert these named classes, not Tailwind utilities, as a theme’s Tailwind build does not scan config.

Blocks Default templates Override in a theme at
Statik’s themes/default/views/vendor/filament-flexible-content-blocks/ Same path in the child
This module’s resources/views/content-blocks/tailwind/ views/vendor/content-management/content-blocks/tailwind/

Block styles are how one block serves several looks, with one template per style named <block>-<style>.blade.php (faqs-columns.blade.php). The list is global config, under block_specific, so a theme can restyle a style but not add one. Name styles by role, not by look.

A worked example of a hypothetical notice block, following WebMoves\Forms\ContentBlocks\FormBlock, the closest model outside the core module.

1. The class. Extend AbstractCmsContentBlock.

class NoticeBlock extends AbstractCmsContentBlock
{
use HasBackgroundColour;
use HasBlockStyle;
public ?string $text;
/** The block's stored type: changing it orphans saved blocks. */
public static function getName(): string
{
return 'catalog::notice';
}
public static function getLabel(): string
{
return __('Notice');
}
public static function getFieldLabel(string $field): string
{
return __($field);
}
public static function getIcon(): string
{
return 'heroicon-o-megaphone';
}
public static function getNameSuffix(): string
{
return 'notice';
}
public static function getContentSummary(array $state): ?string
{
return $state['text'] ?? null;
}
public function __construct(HasMedia&HasContentBlocks $record, ?array $blockData)
{
parent::__construct($record, $blockData);
$this->text = $blockData['text'] ?? null;
$this->backgroundColourType = $blockData['background_colour'] ?? null;
$this->setBlockStyle($blockData ?? []);
}
public function render()
{
return $this->getView('catalog::content-blocks', ['text' => $this->text]);
}
protected static function makeFilamentSchema(): array|Closure
{
return [
TextInput::make('text')->label(__('Text'))->required(),
BackgroundColourField::create(static::class),
BlockStyleField::create(static::class),
];
}
}

The base’s getName(), getLabel() and getFieldLabel() assume the Statik package’s namespace and translations, which is why a custom block overrides all three. getContentSummary() feeds the header of the collapsed block in the editor.

2. Register. Add it to default_flexible_blocks in the root config/filament-flexible-content-blocks.php. A model can narrow its list by overriding registerContentBlocks().

3. The template. getView() builds <namespace>::content-blocks.tailwind.<suffix>, adding the style suffix for non-default styles. The block’s public methods are available as view variables:

<section class="cb cb--notice {{ $getBackgroundColourClass() }}">
<div class="cb__inner">
<p>{{ $text }}</p>
</div>
</section>

4. Styles (optional). Add the block under block_specific in the config with block_styles options, and add a notice-<style>.blade.php for each.

5. Theme it. Add .cb--notice to the theme’s CSS, and rebuild the theme.

Blocks with structured data can add it in the constructor, as FaqBlock adds its FAQPage JSON-LD: SEO::generate() in the layout head prints it, because the slot renders first. Blocks with files register their own collection, as DownloadsBlock does.

Menus have the same shape as widgets: per-site content placed into theme-declared slots. NavMenu holds a menu_items JSON tree, and NavMenuAssignment maps (site, theme, location_key) to one menu, as a location holds exactly one. Three registries hold the kinds: MenuTypeManager, MenuStrategyManager and NavItemTypeManager. Every registered content type and taxonomy becomes a nav item type automatically. Items store an id, and NavMenus\MenuItemLinks resolves each to its path on the site, dropping any the site cannot show. Staff use menus; app-modules/content-management/ARCHITECTURE.md has the rest.