Data-Bound Sections

Shortcodes render components. Data-bound sections go further: an editor designs any section freely and then points it at real records (products, posts, services…). The records' data is baked into the saved HTML, which stays fully editable, and is refreshed automatically when the records change.

In the user guide: Data-Bound Sections.

Shortcode or data-bound section?

ShortcodeData-bound section
MarkupOwned by your Blade component; the editor tweaks settingsOwned by the page author; any design, any layout
DataQueried on every requestBaked into the page when saved, refreshed on change (hourly safety net)
Cost at renderQuery per request (cached with the page)None — it is static HTML
Best forInteractive or complex widgets (forms, carts, calendars)Hero cards, product spotlights, team grids, "featured" strips

The pieces

Editor                         Server                                                Public page
section HTML with markers  --bake-->  BindingBaker::bake(html)                          static HTML
 data-bound="note"                    - finds [data-bound] elements                      (markers kept so the
 data-bound-ids="3,7"                 - BindingSourceRegistry::get('note')                editor can re-bake)
 data-bind="title" ...                - source->resolve([3,7], opts)   one batch query
                                      - fills markers, returns HTML (markers intact)
Record saved elsewhere --> BoundPageRefresher (listeners on watchedModels())
                          --> RefreshBoundPages job re-bakes every page that references the record
                              (PageBindingIndex maps pages to sources/ids; pages:refresh-bound hourly as a safety net)

1. Implement a source

All methods of the contract, in one compact example:

Modules/Playground/app/Services/NoteBindingSource.php

final class NoteBindingSource implements BindingSource
{
    public function key(): string { return 'note'; }                  // used in data-bound="note"
    public function label(): string { return 'Playground notes'; }    // shown in the editor

    /** Fields that markers may reference, with a human description. */
    public function fields(): array
    {
        return ['title' => 'Title', 'body' => 'Body', 'url' => 'Note URL', 'tags' => 'List: tags'];
    }

    /** The editor controls shown for a bound section. */
    public function traits(): array
    {
        return [
            AsyncSelectTrait::make('data-bound-ids', 'Notes')->provider('notes')->multiple()->placeholder('Search notes...'),
            NumberTrait::make('data-bound-limit', 'Max items')->min(1)->max(12)->step(1),
        ];
    }

    /** Options for the async select (admin.page-builder.shortcodes.source-options). */
    public function options(string $field, array $context = []): array
    {
        $term = trim((string) ($context['q'] ?? ''));

        return Note::query()
            ->when($term !== '', fn ($query) => $query->where('title', 'like', "%{$term}%"))
            ->limit(50)->get()
            ->map(fn (Note $note): array => ['id' => (string) $note->id, 'name' => $note->title])
            ->all();
    }

    /** Resolve the picked ids into plain arrays: ONE batch query, constant regardless of the number of ids. */
    public function resolve(array $ids, array $opts = []): array
    {
        $limit = max(1, min(12, (int) ($opts['limit'] ?? 12)));        // data-bound-limit arrives as $opts['limit']
        $ids = array_slice(array_values(array_unique(array_map('intval', $ids))), 0, $limit);

        $notes = Note::query()->whereKey($ids)->with('slugEntry')->get()->keyBy('id');

        $records = [];
        foreach ($ids as $id) {                                           // keep the order the editor picked
            if (! ($note = $notes->get($id)) instanceof Note) {
                continue;                                                 // deleted/unpublished: silently skipped
            }
            $records[] = [
                'id' => (string) $note->id,                              // 'id' is required
                'title' => (string) $note->title,
                'body' => (string) $note->body,
                'url' => (string) $note->frontendUrl(),
                'tags' => [['name' => 'one'], ['name' => 'two']],        // arrays = lists for data-bind-list
            ];
        }

        return $records;
    }

    /** Models whose changes make baked pages stale, mapped to the attribute that holds the record id. */
    public function watchedModels(): array { return [Note::class => 'id']; }

    /** Plain-text formatting; the DOM escapes it, so do not HTML-escape here. */
    public function format(string $field, mixed $value): string { return (string) $value; }
}

Register it in your provider's boot() — not on AdminBooting, because the bake endpoint and the refresh job run outside admin requests:

app(BindingSourceRegistry::class)->register(new NoteBindingSource);

The core registers post (PostBindingSource); TruCommerce registers product, products, product-category (+ -detail variants); Booking registers service, provider, location (+ -detail). Read those for richer examples (LocationBindingSource is a good size).

Rules every source must follow

  1. Constant query count.resolve() may run for many ids and many sections; use one query per relation (whereKey($ids), eager loading). A test in each module asserts this.
  2. Costly fields are lazy. If a field needs queries per record (next free slot, availability), return new LazyValue(fn () => ...); the baker evaluates it only if a marker reads it. Never compute it eagerly.
  3. Return plain data. Scalars and (for lists) arrays of arrays/scalars. No models, no HTML (the baker writes text, not markup).
  4. Skip what is not public. Filter unpublished/inactive records in both options() and resolve().
  5. Preserve order of the requested ids.
  6. Do not reference Modules\* from core code. Sources live in the module that owns the data.

2. The markup contract

An editor (or a library layout) writes plain HTML with data-* markers. Never write {{ }} in bound markup: saved pages go through Blade::render(), and the baker neutralises Blade tokens inside values for the same reason.

<section data-bound="note" data-bound-ids="3,7" data-bound-limit="3">
  <div class="grid">
    <article data-bind-each>
      <h3 data-bind="title">Sample title</h3>
      <p  data-bind="body">Sample text shown until records are chosen</p>
      <a  data-bind-href="url" href="#">Read</a>
      <ul><li data-bind-list="tags"><span data-bind="name">tag</span></li></ul>
    </article>
    <article data-bind-each> ... a second sample slot ... </article>
  </div>
</section>
MarkerOnEffect
data-bound="key"wrapperWhich source. Every other marker is resolved inside this element (a bound element owns markers up to the next bound element)
data-bound-ids="1,2"wrapperChosen record ids (written by the editor). No ids: the sample content stays untouched
data-bound-limit="3" and any other data-bound-*wrapperPassed to resolve() as $opts['limit'], $opts['sort']… (everything except ids and v). Add your own traits to expose more options
data-bind="field"elementReplaces the element's text with the field (via format())
data-bind-href, -src, -alt, -labelelementWrites the field into href, src, alt or aria-label
data-bind-attr="service:id, location:slug"elementWrites record fields into attributes of that name, e.g. to point a shortcode tag at the record (<x-shortcode-booking-form service="12">). Refused: href src srcdoc style action formaction xlink:href class id and anything starting on
data-bind-var="--pct:pct, --swatch:color"elementWrites fields into CSS custom properties (bar widths, swatches); values containing url()/image() are dropped
data-bind-eachrepeating itemItems inside one container are positional: record n fills item n; extra records clone the last item; spare items become hidden data-bind-empty placeholders. Author as many sample items as the design has slots
data-bind-list="tags" (+ data-bind-max="5")repeating itemSame positional logic for a nested list field of the record (an array of arrays); lists nest
data-bind-count="tags"containerWrites the number of visible list items to --n (and data-lm-scenes when present) so a pinned scene is as long as its content
data-bind-show="field" / "!field"elementHides the element while the field (or list) is empty; ! inverts. It only un-hides what it hid itself

When the values contain SVG, give shapes explicit closing tags (libxml nests self-closed ones). The baker lower-cases SVG attribute names (viewBox); browsers restore them.

3. How it behaves at run time

4. Testing a source

it('bakes a bound section and keeps the markers', function (): void {
    $note = Note::create(['title' => 'Alpha', 'body' => 'Alpha body']);

    $html = app(BindingBaker::class)->bake(
        '<section data-bound="note" data-bound-ids="' . $note->id . '">'
        . '<article data-bind-each><h3 data-bind="title">Sample</h3><a data-bind-href="url" href="#">Read</a></article>'
        . '</section>'
    );

    expect($html)->toContain('Alpha')
        ->toContain('data-bound="note"')                     // markers survive for re-editing
        ->and($html)->not()->toContain('Sample');
});

Also test: the option endpoint (admin.page-builder.shortcodes.source-options), a constant query count with 1 vs. N records (DB::getQueryLog()), and the refresh (update the record, call BoundPageRefresher::flush(), assert the page content).

Gotchas