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 | Data-bound section | |
|---|---|---|
| Markup | Owned by your Blade component; the editor tweaks settings | Owned by the page author; any design, any layout |
| Data | Queried on every request | Baked into the page when saved, refreshed on change (hourly safety net) |
| Cost at render | Query per request (cached with the page) | None — it is static HTML |
| Best for | Interactive or complex widgets (forms, carts, calendars) | Hero cards, product spotlights, team grids, "featured" strips |
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)BindingSource (contract) — describes a record type; registered once.BindingSourceRegistry (singleton) — register(), registerIfMissing(), get(), all(), editorPayload() (what the editor receives).BindingBaker — the HTML transformer (DOM based).PageBindingIndex + page_bindings table — which page uses which record; rebuilt by PageTranslationObserver when a page's content is saved.BoundPageRefresher — boots each watched model and listens to saved/deleted/restored.RefreshBoundPages (job) and the console command pages:refresh-bound.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).
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.new LazyValue(fn () => ...); the baker evaluates it only if a marker reads it. Never compute it eagerly.options() and resolve().Modules\* from core code. Sources live in the module that owns the data.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>| Marker | On | Effect |
|---|---|---|
data-bound="key" | wrapper | Which 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" | wrapper | Chosen record ids (written by the editor). No ids: the sample content stays untouched |
data-bound-limit="3" and any other data-bound-* | wrapper | Passed to resolve() as $opts['limit'], $opts['sort']… (everything except ids and v). Add your own traits to expose more options |
data-bind="field" | element | Replaces the element's text with the field (via format()) |
data-bind-href, -src, -alt, -label | element | Writes the field into href, src, alt or aria-label |
data-bind-attr="service:id, location:slug" | element | Writes 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" | element | Writes fields into CSS custom properties (bar widths, swatches); values containing url()/image() are dropped |
data-bind-each | repeating item | Items 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 item | Same positional logic for a nested list field of the record (an array of arrays); lists nest |
data-bind-count="tags" | container | Writes 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" | element | Hides 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.
grapesjs/bound-sections.js): detects data-bound elements, shows the traits of the source, and on change calls POST admin.page-builder.shortcodes.bake (debounced; a re-bake in flight is queued). The returned HTML replaces the section; markers stay.can('edit page')): GET shortcodes/sources (editor payload), GET shortcodes/sources/{source}/options/{field}, POST shortcodes/bake.PageTranslationObserver rebuilds the page_bindings rows for it (BindingBaker::references() finds the sources and ids).BoundPageRefresher::watch() collect source => ids and, deferred to the end of the request, dispatch RefreshBoundPages, which re-bakes affected pages (updating a note re-bakes every page that shows it).pages:refresh-bound (scheduled hourly in routes/console.php) re-bakes everything, which also catches time-based values and media changes. Make sure the Laravel scheduler runs.QUEUE_CONNECTION=sync the job runs inline.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).
register() throws); use registerIfMissing() when two plugins may provide the same key..html sections under sections/library are generated from PHP layout classes; add markers in the builder class, regenerate, and add a bake test per converted section (see Template library).