Skip to main content

Documentation

No results found.
Features

Content blocks: CTAs, photo galleries, and FAQs

WebProCMS ships three structured-content blocks — a CTA button group, a photo gallery, and an FAQ list — that share one polymorphic model and one rendering pattern on any content type. Editors manage the blocks in a "Content Blocks&quo...

WebProCMS ships three structured-content blocks — a CTA button group, a photo gallery, and an FAQ list — that share one polymorphic model and one rendering pattern on any content type. Editors manage the blocks in a "Content Blocks" card on the content edit screen, then either drop a [[type:slug]] shortcode (e.g. [[gallery:summer]]) into the body wherever a block should appear, or leave it unplaced and let it auto-append at the end.

The FAQ block emits Google-friendly FAQPage JSON-LD inline for rich-result eligibility. The gallery block ships a click-to-zoom Alpine lightbox and responsive srcset Glide variants out of the box.


The problem

Structured content like CTA button groups, FAQ lists, and photo galleries lives somewhere awkward between "content" and "structure". Putting them inside the rich-text editor means editors hand-roll markup, lose semantic structure, get inconsistent styling, and (for FAQs) never produce the JSON-LD search engines need for rich-result eligibility. Building them as separate custom content types means each host record that wants its own block has to point at a separate record and manage a relationship. Baking them at a fixed location in the show template removes editor flexibility — editors who want to mention the gallery mid-narrative, or put the FAQ before a closing paragraph, have no way to express that. And because content types (blog posts, events, and anything else defined via the content-type builder) all resolve to the same ContentItem model, a per-model solution (one gallery table for blog, another for events) doesn't scale.

The fix

All three blocks are rows in a single polymorphic content_blocks table, attached to their host via a blockable morph (blockable_type / blockable_id). Any host model can opt in with one trait — currently that's ContentItem, which every content type (blog, events, and any custom type) is built on. Editors manage blocks in a "Content Blocks" card on the content edit screen, alongside the rest of the item's fields.

A block carries a type (cta, gallery, or faq), a per-type-unique slug, and an auto_append flag. The shortcode [[type:slug]] (e.g. [[faq:pricing]]) marks the inline placement for a specific named block. If a block's shortcode isn't placed in the body and its auto_append flag is on, it renders at the end of the content instead, in the blocks' shared sort order. Content with no blocks renders nothing extra.

On the public side, each type ships its own visual: CTA renders as a row of buttons using the site's btn-* styles; the gallery is a responsive grid with click-to-zoom lightbox and keyboard navigation (Esc / ←/→); the FAQ accordion is brand-coloured, accessible, first-item-open, with inline FAQPage JSON-LD.

One polymorphic table, one pivot

The migration creates a content_blocks table: blockable_type / blockable_id (the host), type (cta | gallery | faq), slug (the shortcode handle), label, auto_append, sort_order, and two JSON columns — settings and translations. A unique constraint on (blockable_type, blockable_id, type, slug) lets a host have multiple named blocks of the same type (e.g. two galleries, [[gallery:before]] and [[gallery:after]]); an index on (blockable_type, blockable_id, sort_order) keeps host fetches cheap.

A second migration creates the content_block_media pivot for gallery blocks: content_block_id and media_item_id, both cascade-deleting FKs, plus sort_order.

ContentBlock model — one row, three payload shapes

App\Models\ContentBlock is the single model backing all three types. Payload by type:

  • cta — settings.buttons, a list of {text, url, newTab, style}. buttons(): array reads it; buttonText(int $i) returns the localized label for that index.
  • gallery — settings.columns (via columns(): int, defaults to 4) plus the content_block_media pivot, exposed as the galleryMedia(): BelongsToMany relation (ordered by pivot sort_order then id).
  • faq — the block itself is the polymorphic faqable: ContentBlock uses the same HasFaqs trait as any other faqable model, so a faq-type block gets its own faqs(): MorphMany against the existing faqs table. No separate FAQ-block table — a "FAQ block" is just a content_blocks row whose id is a faqable_id in faqs.

ContentBlock::shortcode() returns the placement token for the block, e.g. [[gallery:summer]]. On delete, ContentBlock::booted() cleans up the block's own faqs rows (the morph has no DB-level FK); the gallery pivot cascades automatically via its FK.

Only cta blocks carry translatable text: translatableKeys() returns button_{i}_text for each button when type === 'cta', and returns nothing for gallery/faq — FAQ question/answer text is translated on the Faq model itself (via its own HasTranslations), and gallery alt text lives on MediaItem.

HasContentBlocks trait — host opt-in

App\Models\Concerns\HasContentBlocks gives a host model contentBlocks(): MorphMany (ordered by sort_order then id), plus ctaBlocks() / galleryBlocks() / faqBlocks() type-scoped variants. Its boot hook cascades block deletion when the host is deleted (which in turn cleans up each block's faqs + gallery pivot rows via ContentBlock::booted()).

App\Models\ContentItem is currently the only model using this trait — every content type (blog, events, and any type built via the content-type builder) is a ContentItem row, so this one opt-in covers all of them. Adding content-block support to a future non-ContentItem model is a one-line use HasContentBlocks;.

[[type:slug]] shortcode — dynamic, host-aware placement

ShortcodeProcessor is normally a static substitution engine — DB shortcodes, system shortcodes, and [[field:key]] resolutions against a content-item context. Block tags extend that model with a dedicated host model slot that pages populate before rendering body content.

ShortcodeProcessor::setHostModel($item) and clearHostModel() bracket the rendering window. Inside that window, renderBlockTag() recognises [[cta:slug]], [[gallery:slug]], and [[faq:slug]] (any other tag returns null and falls through to DB/system shortcode resolution). It resolves the named block via resolveBlock() — $host->contentBlocks()->with(['galleryMedia', 'faqs'])->where('type', $type)->where('slug', $slug)->first() — and renders it, or expands to an empty string if the block is missing or has no content (an empty gallery, an FAQ block with no questions, a CTA block with no buttons). Orphan or empty tags never break a page.

Bare-token back-compat: [[gallery]] and [[faq]] (no slug) resolve to the host's first content block of that type. [[cta]] has no such bare form — it falls through to a regular DB/system shortcode lookup, since cta had no reserved meaning historically.

This pattern parallels the existing setItemContext() / clearItemContext() used by [[field:key]], so installs can safely add new dynamic shortcodes that need page-level context without re-architecting the processor.

processBodyContent() — one call that handles host context + auto-append

ShortcodeProcessor::processBodyContent($content, $host) is the helper a show template's primary body field uses. It:

  1. Sets the host model.
  2. Runs processRaw($content) so inline [[type:slug]] tokens resolve at the typed location (and rewrites inline media references).
  3. Appends every auto_append block whose token the author did NOT place inline (renderAutoAppendBlocks()), in the blocks' shared sort_order. A slugged token in the source suppresses that specific block; a bare [[type]] token suppresses the first block of that type (mirroring the bare-token resolution above).
  4. Clears the host model in a finally, so a thrown exception during rendering still cleans up.

The call site is ContentTypePreset::mapValue() (around line 1101–1108), which drives every content-type show page: the item's primary richtext_tiptap field goes through processBodyContent() (inline placement + auto-append); any secondary Tiptap field on the same item goes through plain processRaw() (inline [[type:slug]] placement still works there, but nothing auto-appends from a non-primary field).

'richtext_tiptap' => $name === $this->primaryBodyFieldName()
    ? ShortcodeProcessor::processBodyContent((string) $value, $item)
    : ShortcodeProcessor::processRaw((string) $value),

Manager UI — the "Content Blocks" card

resources/views/components/⚡content-blocks-manager.blade.php is a Livewire SFC embedded on the generic content edit screen:

<livewire:content-blocks-manager :model="$this->item" :key="'content-blocks-item-'.$itemId" />

— in resources/views/pages/dashboard/content/⚡edit.blade.php (around line 697). It's gated: the card only renders when the content type has a richtext_tiptap field and a registered {typeSlug}.show route, since blocks only make sense for a type with a rich-text body and a public detail page.

The card surface:

  • Collapsible header with a block-count badge
  • One row per block with a type badge, label, summary (button/image/question count), the [[type:slug]] shortcode with a copy-to-clipboard button, an optional "Manual" badge (when auto_append is off), and edit/remove actions
  • Click-based reordering (reorderBlocks(from, to)) — click the move handle, then click the row to drop it there
  • "Add CTA / Add Gallery / Add FAQ" affordances (addBlock($type)), which auto-name and auto-slug the new block

CTA and gallery blocks open a modal editor (label, slug, auto_append toggle, plus type-specific fields: CTA button list with text/URL/new-tab/style and a page-link picker; gallery column count and a media-library picker/reorder). FAQ blocks are not edited through that modal — editBlock() returns early for type === 'faq'. Instead, each FAQ block's card embeds the question/answer manager inline:

<livewire:faqs-manager :model="$faqBlock" :show-shortcode="false" :key="'faqs-block-'.$block['id']" />

⚡faqs-manager.blade.php — still live, now scoped to a block

resources/views/components/⚡faqs-manager.blade.php is the same self-contained Livewire SFC from before FAQs moved onto content blocks — a collapsible list with drag-and-drop reordering, a Tiptap-backed question/answer editor modal, and per-language translation fields. What changed is its $model prop: it now takes a faq-type ContentBlock instance (not a page-host model directly), and the content-blocks-manager passes :show-shortcode="false" so it doesn't render its own shortcode row — the parent block card already shows [[faq:slug]]. Saving a question dispatches faqs-updated, which the parent card listens for (#[On('faqs-updated')]) to refresh its block summaries.

Public render surfaces

All three render components are invoked directly by ShortcodeProcessor::renderBlock() (view('components.…')->render()) — never through an <x-…> tag:

  • resources/views/components/cta-buttons.blade.php — takes $block, renders $block->buttons() as btn-{style} links (URLs resolve through link_url(), so a route:{name} token or a raw URL both work); renders nothing if there are no buttons.
  • resources/views/components/gallery.blade.php — takes $media (a MediaItem collection) + $columns when called from a content block. It also has a $model-driven branch (calling $model->galleryImagesData()) for a pre-content-block calling convention; that branch is currently unreachable — every live caller passes $media, and no model in the codebase implements galleryImagesData() anymore. Renders a responsive grid (2–5 columns), Glide srcset (400/600/800 widths), focal-point-aware object-position, and a click-to-zoom Alpine lightbox with Esc / ← / → keyboard navigation.
  • resources/views/components/faq-accordion.blade.php — takes $faqs (a Faq collection), renders an accessible accordion (x-data="{ open: 0 }", first item open, single-open behaviour, arrow/home/end keyboard navigation) plus an inline <script type="application/ld+json"> FAQPage schema block. Rendering the JSON-LD inline (rather than @push('head')) keeps the shortcode's output self-contained — copy [[faq:slug]] anywhere and the schema travels with it.

What lives where

Path Purpose
database/migrations/2026_06_08_210555_create_content_blocks_table.php The content_blocks table — polymorphic blockable_type / blockable_id, type, slug, label, auto_append, sort_order, settings, translations.
database/migrations/2026_06_08_210556_create_content_block_media_table.php The content_block_media pivot — content_block_id / media_item_id (cascade FKs), sort_order.
database/migrations/2026_05_25_040553_create_faqs_table.php The faqs table — polymorphic faqable_type / faqable_id, question, answer, sort_order. Reused by faq-type content blocks.
database/migrations/2026_06_08_210557_add_translations_to_faqs_table.php Adds translations (JSON) to faqs.
app/Models/ContentBlock.php The block model — morphTo blockable, galleryMedia(): BelongsToMany, buttons()/columns()/shortcode(), CTA-only translation keys.
app/Models/Faq.php The FAQ item model — morphTo faqable, fillable question/answer/sort_order/translations.
app/Models/Concerns/HasContentBlocks.php Trait providing contentBlocks() / ctaBlocks() / galleryBlocks() / faqBlocks(). Used by ContentItem — the only current host.
app/Models/Concerns/HasFaqs.php Trait providing faqs(): MorphMany pre-sorted by sort_order then id. Used by ContentBlock (a faq-type block is its own faqable).
resources/views/components/⚡content-blocks-manager.blade.php Livewire SFC for the dashboard "Content Blocks" card — list, click-reorder, add/edit/delete cta/gallery, copy-shortcode affordance, embeds the FAQ question editor.
resources/views/components/⚡faqs-manager.blade.php Livewire SFC for FAQ question/answer CRUD — drag-reorder, Tiptap editor modal, per-language translations. Takes any faqable $model (now a ContentBlock).
resources/views/components/cta-buttons.blade.php Public-side CTA button row.
resources/views/components/gallery.blade.php Public-side gallery component — responsive grid, Glide srcset, Alpine lightbox, keyboard navigation.
resources/views/components/faq-accordion.blade.php Public-side accordion component with first-item-open behaviour and inline FAQPage JSON-LD.
app/Support/ShortcodeProcessor.php setHostModel / clearHostModel, renderBlockTag() / resolveBlock() / renderBlock() / renderAutoAppendBlocks(), and processBodyContent() (host-aware content render with auto-append).
app/Support/DataSources/Presets/ContentTypePreset.php Calls processBodyContent() for a content item's primary Tiptap field (and plain processRaw() for secondary Tiptap fields) when resolving field values for a show page.
resources/views/pages/dashboard/content/⚡edit.blade.php Content edit screen — embeds <livewire:content-blocks-manager :model="$this->item" />, gated on the type having a Tiptap body field and a public show route.

Adding content-block support to a new host model

Three steps:

  1. Add use App\Models\Concerns\HasContentBlocks; to the model class.
  2. Drop <livewire:content-blocks-manager :model="$record" :key="'content-blocks-X-'.$record->id" /> into the model's edit screen.
  3. In the public show template, render the content via ShortcodeProcessor::processBodyContent($record->content, $record) — host context, inline [[type:slug]] placement, and auto-append all come along for free.

No new migration (the polymorphic table covers it), no new components, no new shortcode registration.

Schema reference

content_blocks

Column Notes
id Primary key.
blockable_type / blockable_id Polymorphic host (currently always App\Models\ContentItem).
type cta | gallery | faq.
slug Shortcode handle, unique per (blockable_type, blockable_id, type).
label Editor-facing name.
auto_append Whether this block renders at the end when its shortcode isn't placed inline.
sort_order Shared ordering across all of a host's blocks.
settings JSON — cta → {buttons: [...]}; gallery → {columns}.
translations JSON — CTA button text per non-default language.

content_block_media (gallery pivot)

Column Notes
content_block_id FK to content_blocks, cascade delete.
media_item_id FK to media_items, cascade delete.
sort_order Image order within the gallery.

faqs (reused by faq-type blocks)

Column Notes
id Primary key.
faqable_type / faqable_id Polymorphic parent — for a block-owned FAQ, faqable_type is App\Models\ContentBlock.
question String, max 500 chars. Required.
answer Text — rich HTML from Tiptap. Required.
sort_order Unsigned integer; smallest first.
translations JSON — question/answer per non-default language.

Marketing angle

Editors manage CTAs, photo galleries, and FAQs in the same place they edit any piece of content — no separate page, no managed relationship. They drop a [[type:slug]] shortcode wherever they want a block to render, or skip it entirely and let it auto-append at the end. The public site emits btn-*-styled CTA rows, a responsive grid with click-to-zoom lightbox for galleries, and a brand-coloured accessible accordion with rich-result-eligible FAQPage JSON-LD for FAQs. One model, one manager UI, works on every content type today and any future host model with one trait.