Any design library row that loops over items — a features grid, a card list, a gallery, an accordion — can be bound to a live data source instead of hand-typed JSON. Once bound, the row renders one card per database record. When the underlying records change (a new blog post is published, an event date moves, a location closes), every page that uses that row reflects the change on the next request. No re-edit, no find-and-replace.
The problem
The naive way to put a "Latest posts" or "Our locations" block on a page is to copy the data into the row's repeater field. It works on day one, but every subsequent change requires touching every page where the data appears. Three locations on the contact page, the footer, and a pricing-area sidebar means three places to update when a phone number changes — and the inevitable drift when one gets missed.
Hand-coding a Livewire component that queries the model and renders the template is the other extreme: full flexibility, but every variation needs an engineer, and the styling lives outside the design library so editors can't tweak it.
The fix
WebProCMS adds a Data button to every repeater field in the page editor. Clicking it opens a popover where the editor picks:
- a preset (blog, locations, events, or any custom content type — plus a taxonomy preset for a type's categories/tags)
- filters declared by that preset (category, status, state, etc.)
- a sort order from the preset's options
- a limit (capped per preset)
- optional pagination
The repeater's saved JSON template stays as-is and becomes the visual template — its first item supplies the field shape ({title}, {image}, {url}, etc.). At render time, Resolver::expand() runs the preset's query, walks the results, and substitutes {item.x} tokens against each record. The page editor still shows the repeater's raw template, so layout changes (different card style, new sub-field) are made the same way as any other row — bound or not.
When the data source returns zero records, the resolver falls back to the saved template so editor previews aren't blank.
What lives where
| Path | Purpose |
|---|---|
app/Support/DataSources/PresetRegistry.php |
Singleton registry. Built-in presets register on first access; one ContentTypePreset is added per registered content type definition. Feature modules register additional presets via $this->app->afterResolving(PresetRegistry::class, …). |
app/Support/DataSources/Contracts/Preset.php |
The interface every preset implements: filter definitions, order options, default + max limit, token schema, and the base Eloquent query builder. |
app/Support/DataSources/Resolver.php |
Decodes the saved spec, validates filters against the preset, applies order + limit, runs the query, and substitutes tokens into the template. Also exposes the per-loop paginator state so <x-dl.pagination> can read it. |
app/Support/DataSources/TokenSubstitutor.php |
Walks the template recursively and replaces {item.path} tokens against each record (and {parent.path} against the enclosing loop's record). |
app/Support/DataSources/LoopContext.php |
Push/pop stack so content() calls and computed paths inside loop bodies resolve against the right record. |
app/Support/DataSources/CollectionContext.php |
Push/pop stack for <x-dl.collection> scopes. Carries the active preset + URL-resolved filter values. Read by <x-dl.collection-filter> (to render pills) and by Resolver::loopFor (to fall back to when no per-loop data source is set). |
app/Support/DataSources/Presets/ |
Built-in presets (Placeholder, Users, Tiers) and the dynamic ContentTypePreset / TaxonomyTermsPreset, one instance per registered content type / taxonomy. |
Built-in presets
A handful of presets ship with the base install, plus one ContentTypePreset and one TaxonomyTermsPreset registered automatically per content type / taxonomy. Blog, Locations, and Events are all content types like any other — there's no separate "Blog Posts," "Locations," or "Events" preset class; feature modules can register more presets too.
| Preset | Group | Source model | Key filters | Default order |
|---|---|---|---|---|
Content type: {slug} (e.g. content_type:blog, content_type:locations, content_type:events) |
Content Types | ContentItem (scoped to type) |
status, q search, plus one auto-derived filter per enumerable field (see below) |
published_at:desc |
Taxonomy: {type} {taxonomy} (e.g. blog categories) |
Taxonomies | ContentTaxonomyTerm (scoped to one type's taxonomy) |
— | name:asc |
| Users | Users | User |
— | (provided by preset) |
Each preset declares a token schema — the paths editors can pick when wiring up template fields. A content type's preset (blog included) exposes its declared data.{field} fields with the right type (image, richtext, text) baked in, plus rel_{field}.title / rel_{field}.url for single relation fields. Computed paths like url (permalink) come from the preset's tokenValue() override.
Content types as a first-class query source
Custom content types aren't second-class citizens of the preset system — every field an admin declares on a type automatically shapes its preset, with zero extra configuration:
- Auto-derived filters.
ContentTypePreset::filterDefinitions()derives aselectfilter per select/radio/checkboxes field (options = the declared choices, never distinct DB values), atogglefilter per toggle/checkbox field, atextfilter per relation field (value = related item id), and a trailingqtext filter (LIKE against the derived title — so<x-dl.collection-search>works on any content-type collection). Free-text/richtext/image/date/file fields are skipped. Each derived filter is simultaneously a validated query-string filter, a structuralfilter-{name}attr, and an editor Data-button control. The meta-query case — "portfolio items where industry = healthcare" — isfilter-industry="Healthcare"on the collection tag, with the value validated against the declared choices. - The
relationfield type. A type's field can point at another type (config: target type slug + single/multiple). The item edit form renders it as a picker of the target type's items, and the chosen id(s) are stored in the item'sdataJSON — normalized to clean ints on save byContentItem::normalizeRelationFields(). Deleting a related item never breaks referrers: dead ids are filtered at read time (they simply drop out of the resolvingwhereIn), no cleanup hook needed. rel_{field}nested loops. Every relation field is loopable:<x-dl.collection source="relation:rel_team" preset="content_type:team-members">iterates the bound item'steamrelation field, resolving via a virtualrel_*accessor onContentItem(relatedItemsFor()— published-only, in the author's pick order, dead ids dropped). Editor-preview placeholder synthesis works through the child preset's token schema like any other relation loop.- Related-record tokens. Single relations resolve
{item.rel_client.auto.title},{item.rel_client.url},{item.rel_client.data.*}through the target type's preset. Multiple relations are loop-only — token access on them resolves empty. - Relation filters — the Bricks marquee case in one attribute.
filter-client="{item.id}"on a client detail page renders "projects related to the current client": the token binds the filter to the bound page record's id, matching is equality-or-JSON-containment so single and multiple relation fields both work, and the bound record is auto-excluded from same-type grids.
Eager-load caveat (honest limit): the with= attr can't batch rel_* pseudo-relations — they're JSON id lists, not Eloquent relations. Each parent costs one batched whereIn query (the count grows with parents, never with children). ContentTypeRelationLoopTest pins that contract with a query-count assertion.
How the spec is stored
Each repeater field with a binding gets a sibling {prefix}_data_source content override holding a small JSON spec:
{
"mode": "preset",
"preset": "blog_posts",
"filters": { "category": "news", "status": "published" },
"order": "published_at:desc",
"limit": 6,
"paginate": false
}
Two safety properties of this design:
- Filters are validated against the preset's declarations on every render. A removed category or a hand-edited spec can't trick the resolver into running an unauthorised query — unknown filter keys are silently dropped, and
select-typed filters must match a declared option value. - Limits are clamped to
maxLimit()per preset. A spec asking for 10,000 records returns at most the preset's cap (typically 100), so a misconfigured page can't accidentally pull a multi-megabyte payload.
When the spec is missing, empty, or mode: static, the resolver returns null and the row falls through to its saved JSON template — a static row and a dynamic row are the same row template, just with or without a binding.
How it renders
The compiled @dlItems directive calls Resolver::expand($slug, $prefix, $defaultJson). The flow:
- Read the saved template from
repeater_{prefix}(falling back to the directive's default JSON). - Apply per-key language translation for non-English locales (any
{key}__{lang}value overwrites the default key). - Read
{prefix}_data_source. If empty, return the template as-is. - Decode the spec. If
mode != presetor the preset is missing, fall back to the template. - Validate filters → apply order → clamp limit → optionally paginate.
- For each record, deep-substitute
{item.path}tokens in the template's first item against the record. - Return the expanded list — or, on zero records, the unchanged template (so editor previews aren't blank).
For hand-built loops authored directly in row blade, the @dlLoop directive uses Resolver::loopFor() instead and pushes each record onto LoopContext, so content() calls and {item.x} tokens inside the body resolve against the current record.
The collection pattern
For top-quality data-driven rows, the five <x-dl.collection*> primitives compose into a complete row without any PHP block:
<x-dl.collection preset="key">wraps a region and pushes aCollectionContextcarrying the preset + filter values it resolved from the URL (?{prefix}_{column}=value). Withsource="relation:NAME"it instead iterates the current bound record's relation — a nested loop (see below). An optionalwith="rel1,rel2"attr declares eager-load hints. Structuralfilter-{name}="value"attrs bind a filter at authoring time — to a fixed value (filter-category="news") or to the current page's record via tokens (filter-category="{item.auto.category.slug}"= "posts in the same category as the current post"). Structural values go through the same server-side validation as URL filters, always win over a same-name URL param, and drop out gracefully when the bound record has no value. The record being viewed is automatically excluded from same-model queries, so a "related posts" row is one attribute — no custom PHP.<x-dl.collection-filter column="X">renders<a wire:navigate>pills from the preset's distinct values, with active state derived from the URL. A'multi'-typed column renders toggleable pills (each link adds/removes its value,aria-pressedper pill,?{prefix}_{column}[]=a&barray form). When the preset implementsfilterOptionCounts($column, $activeFilters)the pills show faceted counts — "News (12)" — that respect every active filter except the pill's own column, so checking a tag updates the category counts but never shrinks its own.<x-dl.collection-search column="q">renders a plain<form method="GET" wire:navigate>text input. Preset must declare a matching'text'-type filter ('q' => ['type' => 'text', 'label' => 'Search']) and apply it inbuildQuery()(typicallyLIKEagainst title/excerpt).<x-dl.collection-grid paginate="N">renders the outer grid container; its body holds an@dlLoopthat iterates the records resolved from the active collection scope. Withpaginate="N", it switches to->paginate(N)and auto-emits a sibling<x-dl.pagination>below. Page param is{prefix}_pageso multiple paginated grids coexist.<x-dl.collection-item>wraps each iteration's template;{item.X}tokens inside resolve against the per-iterationLoopContext.
Filter state is the query string — no Livewire properties, no Alpine state. Two collections on a page coexist because their pill params are namespaced ({collection_prefix}_*). The runtime path: Resolver::loopFor reads the per-loop {prefix}_data_source override; when it's empty (the collection-pattern case), it falls back to resolveFromCollection($activeCollection).
Two sibling grids inside one collection can also slice the record set (offset="0" limit="1" hero + offset="1" rest) — both share the collection's filter state, so pills and search affect them together.
See the design-library skill's collection-pattern reference for the canonical row shape and the current limitations (no {item.X} token substitution in arbitrary HTML attributes; inner relation loops repeated per parent share one page query param, so per-parent pagination/filter pills stay top-level).
Nested loops (relation mode)
A collection doesn't have to query globally — it can loop the current record's own children. Dropping the global query for source="relation:NAME" turns the collection into a per-record nested loop:
{{-- On a location detail page (the location is the page-bound record): --}}
<x-dl.collection slug="__SLUG__" prefix="team" source="relation:rel_team" preset="content_type:team">
<x-dl.collection-grid slug="__SLUG__" prefix="team_grid" field-classes="grid md:grid-cols-3 gap-6">
@dlLoop('__SLUG__', 'team_grid')
<x-dl.collection-item slug="__SLUG__" prefix="member_card" field-classes="...">
<x-dl.image slug="__SLUG__" prefix="member_photo" field-image="{item.featured_image}" ... />
<x-dl.heading slug="__SLUG__" prefix="member_name" field="{item.auto.title}" field-tag="h3" ... />
<x-dl.subheadline slug="__SLUG__" prefix="member_role" field="{item.data.role}" ... />
</x-dl.collection-item>
@endDlLoop('team_grid')
</x-dl.collection-grid>
</x-dl.collection>
How it resolves:
- The parent is whatever record is currently bound. On a detail page that's the page-bound record (the location, the post, the event). Nested inside another collection's loop body, it's the current iteration's record — so a "locations grid where each card lists that location's team" is the same markup, just placed inside the outer loop.
relation:NAMEaccepts a real Eloquent relation (publishedItems), an accessor returning a collection, or a content-type relation field via its virtualrel_{field}accessor (rel_team).preset=names the child token resolver, so{item.auto.title}/{item.data.role}resolve against each child record, with the preset'stokenValue()handling computed paths.- Context stacks are LIFO. After the inner loop finishes,
{item.x}resolves against the parent again — a heading after the team grid can still say{item.data.address.city}and get the location's city. {parent.x}reaches back up. Inside the inner loop's body,{parent.x}tokens (same filter syntax as{item.x}) resolve against the enclosing record — a post card inside a categories loop can render "in {parent.name}". With no parent frame, the token is left verbatim so authoring mistakes stay visible.- The inner grid's
offset/limit/paginateattrs are honored.<x-dl.collection-grid limit="3">shows each parent's three newest children; the slice is applied in memory, so accessor-backed pseudo-relations work the same as real Eloquent relations. Slice wins over paginate. Pagination is for single-instance relation loops (e.g. a detail page) — inner loops repeated per parent share one page query param, so leavepaginateoff there. - Relations used by rows must be scoped + ordered. Relation mode applies no status filtering or ordering itself — the relation bakes both in.
ContentTaxonomyTerm::publishedItems()(published-only,published_at <= now(), newest first) is the pattern; a relation that includes drafts never feeds a public row. - Empty relations render zero iterations on the public site (no phantom children). In the editor preview, a placeholder child is synthesized from the child preset's token schema so the row is never blank while authoring.
Shipping examples: the location detail page's team, reviews, FAQs, and services rows are all relation-mode collections (resources/design-library/rows/page-layouts/detail/, gated @requiresContentType locations), and the blog's "Posts by Category Sections" row (posts-category-sections) is the canonical categories → each category's three newest posts nested loop — outer global collection, inner source="relation:publishedItems" grid with limit="3" and a {parent.name} subtitle.
Eager-loading hints (no N+1 by design)
Page builders that loop relations are notorious for the N+1 query problem: render 20 parents, fire 20 lazy child queries (plus 20 × M image lookups). The collection pattern counters this in two layers:
- Presets eager-load what their tokens touch.
ContentTypePreset::buildQuery()shipswith('featuredMedia'), so a 9-post grid resolving{item.featured_image}is 2 queries, not 20. - The
with=attr on<x-dl.collection>declares relation paths for nested loops. On a global collection, the paths are eager-loaded onto the preset query (with="teamMembers.photoMedia"→ one batched query for every parent's children + one for their photos). On a relation-mode collection, the paths areloadMissing()d on the bound parent before the relation is read — a no-op if an outer query already loaded them, and non-fatal if the path doesn't apply.
Covered by CollectionEagerLoadTest and ContentTypeRelationLoopTest, which assert query counts stay flat as record counts grow.
How it compares
The same capability in the page-builder ecosystem, for context:
| WebProCMS collections | Bricks Builder (WP) | Elementor Pro Loop Builder (WP) | Webflow CMS lists | |
|---|---|---|---|---|
| Loop a data source visually | ✅ presets + Data button | ✅ query loop | ✅ loop grid/carousel | ✅ collection list |
| Field binding | {item.x} tokens, schema-driven |
dynamic data tags | dynamic tags | bound fields |
| Nested loops (children of current record) | ✅ one attribute (source="relation:…"), with per-parent limit/offset, {parent.x} tokens, and pagination on single-instance loops — including custom-type relation fields (rel_{field}) |
✅ but complex cases need custom PHP / query editor | ❌ not natively (third-party addons) | ⚠️ one nested list per page, max 5 items |
| Meta/field queries ("industry = healthcare") | ✅ auto-derived per declared field, validated against declared choices | ⚠️ meta_query UI — keys typed by hand, no validation | ⚠️ limited meta controls | ⚠️ filter UI on collection fields |
| Relationship querying ("projects for this client") | ✅ relation field type + filter-{field}="{item.id}" / rel_{field} loops — no raw queries |
✅ via ACF relationship + PHP or query editor | ⚠️ ACF addons | ⚠️ reference fields, single level |
| Arbitrary query exposure | ❌ deliberate — presets validate filters server-side, limits clamped | raw WP_Query args + PHP | WP_Query controls | closed |
| N+1 protection | eager-load presets + with= hints, test-enforced |
❌ author's responsibility | ❌ author's responsibility | n/a (hosted) |
| Filter/search state | faceted multi-select pills with live counts, search, and load-more/infinite scroll — every state a shareable URL, every response served from the full-page cache; JS only morphs in the cached result | JS/AJAX filter elements (admin-ajax — every filter click bypasses the page cache and hits PHP) | JS widgets (same admin-ajax cost) | limited native filtering |
| Per-loop styling editable by client | ✅ every element is a schema field | ✅ | ✅ | ✅ |
The deliberate trade: WebProCMS editors pick from curated presets rather than composing raw queries. That's what keeps every collection row cacheable (filters live in the URL, not in session/JS state), safe (a hand-edited spec can't run an unauthorized query), and fast (the query shape is known, so eager loading is owned by the preset — not rediscovered per page).
Pagination
Setting paginate: true on the spec switches the resolver from ->limit($n)->get() to ->paginate($n). The paginator is stashed under {slug}:{prefix} via Resolver::paginationFor(), so a sibling <x-dl.pagination> component placed inside or right after the loop reads it without threading any variable through Blade. Each loop gets its own query string name ({prefix}_page) so multiple paginated loops can coexist on one page.
Three presentation modes, picked at authoring time (<x-dl.collection-grid paginate-mode="…"> forwards to the auto-emitted pagination):
- default — numbered / simple / prev-next link styles. Each page is a real URL; the enhancer pushes it to the address bar.
load-more— one button that is literally a plain link to the next page's URL. With JS, clicking appends the next page's cards to the grid (respecting the grid's stagger animation) and swaps in the following page's button — gone on the last page. Without JS it just navigates to page 2.infinite— the same button auto-triggered as it nears the viewport (IntersectionObserver). Visitors withprefers-reduced-motion, or without JS, get the load-more button instead.
Interactive filtering without losing the page cache
Filter pills, search, and pagination are plain GET links/forms — every state is a shareable URL that ResponseCache stores once and serves to everyone. The progressive enhancer (resources/js/collection-filter.js, lazy-booted from the public bundle when a data-collection region exists) intercepts those clicks, pushStates the new URL, fetches that same cached URL, and morphs each data-collection region (pills + search + grid + pagination swap as one unit, so active states and page links stay in sync) — with an aria-live announcement and focus preserved on the clicked pill. There is no filter endpoint, no admin-ajax equivalent, nothing that bypasses the page cache: the "AJAX" response is the cached page. With JS disabled, behavior is byte-for-byte the plain-links experience.
Adding a new preset
A preset is a class implementing Preset. It declares:
key()— the stable string stored in the saved speclabel()/group()— display in the editor dropdown's optgroupsfilterDefinitions()— typed filter options (select / multi / text / toggle), each with allowed valuesfilterUrl($column, $value)(optional) — pretty archive URL per pill (e.g./blog/tag/{slug})filterOptionCounts($column, $activeFilters)(optional) — faceted value⇒count map; pills render "Name (n)" when presentorderOptions()/defaultOrder()—column:dirpairsdefaultLimit()/maxLimit()— sensible default and hard captokenSchema()— paths editors can pick under{item.x}buildQuery($filters)— the base Eloquent query with validated filters appliedtokenValue($item, $path)— optional override for computed paths (e.g. permalink URL)
For feature modules, register the preset from the service provider via $this->app->afterResolving(PresetRegistry::class, …) so the registry stays the single source of truth without each module touching PresetRegistry::boot().
Marketing angle
A dynamic-bound row is one row template that can stand in for dozens of static "Latest posts," "Featured locations," "Upcoming events" sections across the site. Edit the post once, every page where the row appears reflects it on the next request. Add a new content type — minutes, case studies, team members — and it shows up in the data source dropdown the same day, no theme work required.