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/.
Widgets
Section titled “Widgets”| 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.
Inheritance
Section titled “Inheritance”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):
- The entity itself.
- With
inheritonly: its ancestors on the site, nearest first, fromsite_documents.resolved_parent_id. A parent not on the site is skipped, so its widgets cannot reach the child there. - 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.
Editors
Section titled “Editors”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.
Adding a widget type
Section titled “Adding a widget type”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.
Content blocks
Section titled “Content blocks”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\SiteContentBlocksFieldadds a globe button to every block’s header, keeping ticked site ids in the block’s data undersites. No block type knows about it. Raw readers ofcontent_blocks, such as search, see every block. - Reusable blocks:
ContentBlocks\Models\ReusableBlockis a saved set of blocks, embedded byReusableBlock(the block) orReusableBlockWidgetType. 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 undertemplatesin the config, with no form. - Links: both base models answer Statik’s
getViewUrl()throughLinksFromContentBlocks, per the current site. An entry not on the site gives an empty string, which templates render as no link.
Templates and styling
Section titled “Templates and styling”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.
Adding a block
Section titled “Adding a block”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.
Nav menus
Section titled “Nav menus”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.