Wire layout builder editorial
Goal
Provision Layout Builder on a content type so content editors compose visually consistent pages from an approved palette — with contextual editing working end-to-end, repeating sections modeled without Paragraphs, no path for an editor to break page consistency, and the entire editorial capability living in exported configuration, not only in the database.
Opinion
Choose an editorial posture; never default to open. allow_custom is not a bare on/off — combined with restrictions and styles it is a deliberate posture: locked (templated, allow_custom: false), curated (approved palette), or open (full freedom, rare). Pick one per bundle before enabling anything. Source: guide drupal/layout-builder/defaults-vs-overrides.
Curate the palette; never hand editors raw Layout Builder. A curated posture means an explicit allowlist of blocks and layouts, so editors compose from an approved set rather than the full plugin surface. Source: guide drupal/layout-builder/lb-restrictions.
Editors get styles, not CSS. Visual variation is expressed as Layout Builder Styles groups tied to design-token classes, so editors select approved options instead of writing markup. Source: guides drupal/layout-builder/lb-styles-overview, drupal/layout-builder/lb-styles-groups.
Model repeating sections as block_content + taxonomy + Views — never Paragraphs. Lists (testimonials, gallery, logos) are atomic block_content items grouped by a taxonomy reference and listed by a View of block_content. Source: guide drupal/layout-builder/block-content-list-components.
Group by authoring intent, not by element. Components that share authoring intent and section-level styling (heading + body + CTA) belong in one section, not three sibling sections. Source: guide drupal/layout-builder/sections-layouts.
Every inline-block template must render its own attributes. A block template used in Layout Builder that omits {{ attributes }} or {{ title_suffix }} silently breaks the contextual edit pencil and the Configure menu. Source: guide drupal/layout-builder/theming-lb.
The capability ships in config, not the database. Permissions, restrictions, styles, and the view display that carry the editorial capability must be in the exported configuration — a capability that works locally but was never exported does not survive deploy. Source: guides drupal/layout-builder/enabling-lb, drupal/layout-builder/defaults-vs-overrides.
What this recipe refuses
- Paragraphs as the list-section modeling tool under Layout Builder.
- Any inline-block / block_content template used in LB that omits
{{ attributes }}or{{ title_suffix }}. - Granting an editor role Layout Builder access without a restriction allowlist (any non-open posture).
- Emitting three sibling sections for a component group that shares section-level styling.
- Leaving the editorial capability (perms, restrictions, styles) only in the database, unexported.
Preconditions
- Drupal 10.4+ or 11.x;
layout_builderandblock_contentenabled. - For any curated or locked posture:
layout_builder_restrictionsandlayout_builder_stylesavailable (the palette and style groups depend on them). Seedrupal/layout-builder/lb-restrictions. - The target content-type bundle exists, with its
Manage displayview mode available. - The editor role to enable exists.
- When
list_componentsis non-empty, a custom module exists, or is created, to holdhook_block_content_view(): core invokes entity view hooks for modules only. On Drupal 10 or 11.0 it also needs the#[LegacyHook]procedural function and theservices.ymlentry the guide shows. Seedrupal/layout-builder/block-content-list-components. - Config export is in use (the capability must be exportable — see the state-awareness contract).
- DDEV runs the site; the verifier's commands go through
ddev.
Input contract
Generic, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
target:
entity_type: string # e.g. node
bundle: string # the content type
view_mode: string # default: "default"
editorial_posture: string # "locked" | "curated" | "open" (default: curated)
editor_role: string # machine name of the role to enable
block_palette: # allowlisted blocks (curated/locked); omit for open
- string # plugin id or category
layout_palette: # allowlisted layouts (curated/locked)
- string
style_palette: # Layout Builder Styles to expose to editors
- group: string # style group machine name
styles: [string]
list_components: # repeating sections modeled without Paragraphs
- block_bundle: string # block_content bundle for the item
group_field: string # taxonomy-term reference field used to filter
view: string # View of block_content that lists the items
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview instead of writing config. Dry-run is required.
-
Validate preconditions and resolve the posture. Confirm modules and the target bundle. Map
editorial_postureto itsallow_customvalue and restriction strategy: locked →allow_custom: false; curated →allow_custom: true+ allowlist; open →allow_custom: true, no allowlist. Seedrupal/layout-builder/enabling-lb,drupal/layout-builder/defaults-vs-overrides. -
Enable Layout Builder on the bundle view display with the resolved
allow_custom. Seedrupal/layout-builder/enabling-lb. -
Model the list components. For each entry in
list_components, ensure itsblock_contentbundle, its taxonomy-term grouping field, and the View of block_content that lists it — including per-category fixed-argument displays where a single placement must render one category. Create a dedicatedblock_contentview mode for the rows, such aslist_item, and select it as the View's row view mode on its default display. No Paragraphs. Seedrupal/layout-builder/block-content-list-componentsanddrupal/layout-builder/field-extra-field-blocks. -
Curate the palette. For non-open postures, apply the
layout_builder_restrictionsallowlist forblock_paletteandlayout_paletteon the view display. Seedrupal/layout-builder/lb-restrictions. -
Expose the style palette. Wire the
style_palettegroups/styles as the Layout Builder Styles editors may apply. Seedrupal/layout-builder/lb-styles-overview,drupal/layout-builder/lb-styles-groups. -
Ensure render theming. Where list components render as View rows, ensure the block_content render theme-hook trio is registered:
hook_theme()and the bundle suggestions alter in the theme, andhook_block_content_view()in a module, since core fires entity view hooks for modules only. Scope that hook to a dedicatedblock_contentview mode, such aslist_item: create the view mode and select it as the row view mode of eachlist_componentsView, so placed and inline blocks keep their output; ensure every inline-block template used in LB emits{{ attributes }}and{{ title_suffix }}. Seedrupal/layout-builder/block-content-list-components,drupal/layout-builder/theming-lb. -
Harden the editor form display. Use the Media Library widget for media fields, hide legacy/raw fields, and add field descriptions on the bundle (and on any block_content bundle used inline). See
drupal/layout-builder/editor-form-display-hardening. -
Grant the editor role its permissions. For a scoped posture, prefer the per-bundle
configure editable <bundle> <entity_type> layout overridespermission overconfigure any layout; always includecreate and edit custom blockswhen inline blocks are in play. Seedrupal/layout-builder/enabling-lb. -
Export and confirm the capability is in config. Export configuration and confirm the role permissions, restriction allowlist, style groups, and view-display settings are all present in the export — not only in the database.
-
Rebuild caches and emit a summary of what was created, skipped as a no-op, or surfaced as a conflict for operator review.
Data flow
input: target (entity_type, bundle, view_mode)
editorial_posture · editor_role
block_palette · layout_palette · style_palette · list_components
reads project state:
core.entity_view_display.<entity>.<bundle>.<view_mode> (LB + restrictions + styles)
user.role.<editor_role> (permissions)
block_content.type.* / field.* / views.view.* (list components)
layout_builder_styles.style.* / .group.* (style palette)
core.entity_form_display.* (editor form hardening)
theme templates + hooks, module block_content_view hook (render theming)
applies opinion (guardrails):
posture-first · curate-don't-open · styles-not-CSS ·
block_content-not-Paragraphs · group-by-intent ·
templates-emit-attributes · capability-in-config
references atomic detail (guides):
drupal/layout-builder/{ enabling-lb, defaults-vs-overrides,
inline-vs-reusable, field-extra-field-blocks, block-placement,
sections-layouts, block-content-list-components, lb-restrictions,
lb-styles-overview, lb-styles-groups, theming-lb,
editor-form-display-hardening }
emits:
core.entity_view_display ... layout_builder.enabled + allow_custom
+ third_party_settings.layout_builder_restrictions (allowlist)
user.role.<editor_role> (LB permission set)
block_content.type.* / field.* / views.view.* (list components)
layout_builder_styles exposure (style palette)
core.entity_form_display.* (hardened editor form)
theme: block_content theme hook + suggestions + attribute-emitting inline templates
core.entity_view_mode.block_content.<row view mode> (View rows only)
module: hook_block_content_view setting #theme in the row view mode
State-awareness contract
The recipe reads existing state before writing. For every emitted config object: absent → create; present and matching the resolved spec → skip, log no-op; present and differing → conflict, do not overwrite, request operator review.
The capability must be present in exported configuration, not only the active (database) state. A permission, restriction, or style that exists in the running site but not in the export is treated as a conflict to resolve (export it), because it will not survive deploy — this is the difference between "works on my install" and a delivered capability.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run.
Verifier
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only; a non-interactive ddev drush keeps Drush's messages on standard error. A stopped DDEV project prints its start-up text on standard output, so start the site before the verify run. Entries that call .aida/wire-layout-builder-editorial/verify.php run the script in ## Files. It prints one line per violation and exits non-zero when it printed any.
verifier: - id: lb-enabled-with-posture kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- display {target.entity_type} {target.bundle} {target.view_mode:json} {editorial_posture:json} pass: stdout empty - id: restriction-allowlist kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- restrictions {target.entity_type} {target.bundle} {target.view_mode:json} {editorial_posture:json} {layout_palette:json} pass: stdout empty - id: allowlist-denies kind: self-fixture run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- offered {target.entity_type} {target.bundle} {target.view_mode:json} {editorial_posture:json} {block_palette:json} pass: stdout empty - id: editor-permissions kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- permissions {editor_role} {target.entity_type} {target.bundle} {editorial_posture:json} {block_palette:json} pass: stdout empty - id: editor-can-edit-layout kind: self-fixture run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- editor-access {editor_role} {target.entity_type} {target.bundle} {editorial_posture:json} {target.view_mode:json} pass: stdout empty - id: style-palette kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- styles {style_palette:json} pass: stdout empty - id: list-components kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- lists {list_components:json} pass: stdout empty - id: block-content-theme-hook kind: self-fixture run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- trio {list_components:json} pass: stdout empty - id: templates-emit-attributes kind: config-assert run: ddev drush php:script /var/www/html/.aida/wire-layout-builder-editorial/verify.php -- templates pass: stdout empty - id: active-equals-export kind: config-assert run: ddev drush config:status pass: stdout empty
What the entries do not prove, and where the proof is:
- An absent
editorial_posturereads ascurated, and an absenttarget.view_modeasdefault, the Input contract defaults. lb-enabled-with-posturealso checks thelayout_builder__layoutfield storage and instance for a curated or open posture. Core adds that field when overrides are allowed.restriction-allowlistandallowlist-deniescheck nothing for an open posture. For the others,restriction-allowlistcomparesallowed_layoutswithlayout_palette.allowlist-deniesasks core's block plugin manager which blocks the Layout Builder chooser offers on the default layout, withlayout_builder_restrictionsfiltering them. It adds the inline blocks the module's restriction plugins allow in that region, as the module's inline block list does. Every offered block must match ablock_paletteentry by plugin id, base plugin id or untranslated category, and every entry must match an offered block. It builds Layout Builder's sample entity for the bundle and deletes it afterwards.editor-permissionsrequires, for a curated or open posture, one ofconfigure editable <bundle> <entity_type> layout overrides,configure all <bundle> <entity_type> layout overridesorconfigure any layout, the three that core's overrides access check accepts. Core generates the per-bundle two only for a display that allows overrides, so a locked posture has none to hold. It requirescreate and edit custom blockswhen the posture is open orblock_palettenames aninline_blockplugin or theInline blockscategory.- The contextual pencil and the "Configure" menu are drawn in the browser by the contextual module's JavaScript, so no command sees them.
editor-can-edit-layoutstands in for the permission half: it saves a user witheditor_roleand an entity of the bundle that user owns, then asks the overrides section storage forviewaccess, the check the layout route runs. Access must be granted for curated and open and denied for locked. Both fixtures are deleted. The "editable" permission also needs the role's own update access to the entity, so a role without it fails the entry. templates-emit-attributesstands in for the template half. It renders a block through the default theme for eachblock_contentbundle, once as an inline block and once as a reusableblock_contentblock, as a user who may see contextual links. The block's content is the view builder'sfullbuild of an unsaved block of that bundle, so core's#block_contentsuggestions, such asblock__block_content__type__<bundle>, pick the template. Nothing is saved. The block's attributes carry a probe attribute and core's contextual module adds its placeholder totitle_suffix, so the markup shows whether the template that won prints each one. Rendering followsinclude,extendsandembed, and aliases such as{% set block_attributes = attributes %}, which a text search of the template misses. A template that prints only on some condition the probe does not meet, such as a label, is not caught.style-palettechecks each listed group and style exists, and each style belongs to its group. Layout Builder Styles are site-wide, so styles outside the palette are not flagged. An absent or emptystyle_palettechecks nothing.list-componentschecks theblock_contentbundle, a taxonomy-term reference field on it, and a View whose base table isblock_content_field_data, so no Paragraphs type backs the list. An absent or emptylist_componentschecks nothing, and so doesblock-content-theme-hook.block-content-theme-hookchecks two of the three hooks: ablock_contenttheme hook is registered, and an unsavedblock_contentof each listed bundle builds with#themeset toblock_content. It reads the row of the entry'sviewfrom its default display. The entry fails when that View does not exist, when that row's type is notentity:block_content, when its view mode isdefaultorfull, which placed and inline blocks also build in, or whencore.entity_view_mode.block_content.<mode>does not exist. A View whose row is overridden on a non-default display (defaults.row: false) fails the entry, because the check reads the default display, which is where Sequence step 3 sets it. Otherwise it builds in that view mode. The second is not implied by the first: core'sBlockContentViewBuilderremoves the#themeits parent sets, so only the second hook puts it back. The bundle suggestions from the third hook are not checked.active-equals-exportproves the permissions, restrictions, styles and display are in the export after Sequence step 9, not only in the database.
Files
One script, which the consumer writes before the verifier runs and removes after it. Do not edit it or commit it. Drush runs it with Drupal booted and passes the words after -- in $extra.
```php .aida/wire-layout-builder-editorial/verify.php