Site data and collections
The JSON behind your templates — global site data, data collections, the Content place, and pages generated one per entry.
Templates describe shape. Data describes content. Keeping the two apart is what lets you change a phone number in one place, add a service without touching a template, or run a blog with a single page file.
Your website's data lives in two kinds of JSON file, both under resources/data/.
Global site data
resources/data/site.json is a single object of facts about the business. Every template
on your website — pages, layouts, and components alike — can read it as $site.
{
"name": "Keystone Home Services",
"tagline": "Professional home services, made simple.",
"phone": "(555) 204-8890",
"phone_href": "tel:+15552048890",
"email": "hello@keystone.example",
"address": "482 Alder Street, Springfield",
"hours_note": "Emergency calls answered 24/7",
"cta_label": "Get a free estimate"
}
<footer class="border-t border-line py-12 text-sm text-muted">
<p>{{ $site->name }} — {{ $site->address }}</p>
<p class="mt-2">
<a href="{{ $site->phone_href }}">{{ $site->phone }}</a>
·
<a href="mailto:{{ $site->email }}">{{ $site->email }}</a>
</p>
</footer>
The keys are yours to choose. Add whatever the website repeats — a licence number, an opening hours note, a booking URL — and reference it everywhere instead of typing it out five times.
A key that does not exist is a compile error rather than a blank, which is a feature: a
typo shows up at once instead of leaving a silent hole in the footer. Use ?? where a
value is genuinely optional.
Data collections
A collection is a list. Anything your website shows more than one of — services, reviews, team members, FAQs, opening hours, blog posts, menu items — belongs in one.
Each file at resources/data/collections/NAME.json is a JSON array of flat objects,
and it is available in every template as $NAME (a large collection can be a folder of
entry files instead — see Large collections):
[
{
"name": "Plumbing",
"blurb": "Leaks, clogs, water heaters, and full re-pipes.",
"detail": "Faucets and fixtures, drain clearing, water heater repair and replacement."
},
{
"name": "Electrical",
"blurb": "Panels, outlets, lighting, and EV chargers, wired to code.",
"detail": "Panel upgrades, troubleshooting, recessed lighting, EV charger installation."
}
]
resources/data/collections/services.json becomes $services:
<dl class="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($services as $service)
<div class="border-t border-line py-6">
<dt class="font-semibold">{{ $service->name }}</dt>
<dd class="mt-2 text-muted">{{ $service->blurb }}</dd>
</div>
@endforeach
</dl>
Adding a fourth service is now a data edit. No template changes, and every page that loops
over $services picks it up.
The rules
- The file is an array,
[ … ], not an object. - Every entry carries every key its siblings carry. A missing property is a compile
error, not an empty string. If one service has no
detail, give it"detail": "". - Entries are flat. Strings, numbers, and booleans — not nested objects. For structured content, either flatten it into more keys or use a second collection.
- Names are lowercase letters, numbers, and underscores, starting with a letter, up to
40 characters.
blog_postsis fine;blog-postsis not, because a dashed name cannot become a template variable. site,slot,errors, andentriesare reserved and cannot be used as collection names.
The Content place
You do not have to edit JSON by hand. The Content icon in the builder's left rail opens your collections at full size — a sidebar of collections with their entry counts, and the chosen one as a table.
- Search filters entries as you type; click a column heading to sort (again to reverse, a third time to clear). Collections over fifty entries page.
- Click a row to open it in an editor beside the table. Every column is a proper field for its type: a text box, a multi-line box, a switch for true/false, a number, an image picker, or a rich-text editor for HTML bodies (bold, headings, lists, quotes, links, images). Save changes writes only what you changed; Delete entry asks first.
- Add entry creates one shaped like the last entry and opens it straight away.
- Structure shows what the data says each column is — its type, whether any entry
leaves it empty, and the type the collection's
.ymldeclares — the first place to look when a template and its data disagree. - Create a collection with +; it starts with a single
titlefield and its own.yml.
Press Esc to close the editor, and Esc again to go back to the site. The canvas
re-renders after every change, so a page reading the collection is already up to date when
you return.
To add a new field to a collection, edit the JSON file in the code editor and give every entry the new key. The fields are the schema, and the grid follows the file.
The companion .yml
Every collection carries a small file beside its JSON —
resources/data/collections/services.yml — that names it and types its columns:
label: Services
icon: 🔧
fields:
name: { type: text, label: Service }
detail: { type: textarea, label: Detail, rows: 2 }
icon: { type: image, label: Icon }
labelandiconare how the collection appears in the Content place.fieldstypes each column:text,textarea,url,image,select,color,number,toggle, orrichtextfor an HTML body — and that type decides which control the entry editor shows. A column left out is still editable; the editor guesses from its value.
Max writes this file whenever it creates a collection, and + in the Content place seeds one too. Editable sections explains how a section's repeater borrows these types.
Content types
A collection you keep adding entries to — blog posts, products, case studies, a changelog — follows one fixed shape so the builder can offer an entry editor for it:
- the detail page at
pages/posts/[posts.slug].blade.php(folder named after the collection, routed onslug) and a listing atpages/posts/index.blade.php; - entries with the standard columns first:
title,slug,date,description,image,link, andcontent(the HTML body) — posts addreadTime, products addpriceandavailable; - a
.ymlwithcontent: { type: richtext }; - drafts, when you have them, in
resources/data/drafts/posts.json— never compiled, never published.
Ask Max for "a blog" or "a product catalogue" and it builds exactly this.
Large collections: one file per entry
A collection is normally one file. Once it passes 100 entries — a big blog, a long
catalogue, every post of a website you brought across — it is stored as a folder
instead, with one JSON object per entry named after its slug:
resources/data/collections/posts/first-post.json
resources/data/collections/posts/spring-checklist.json
resources/data/collections/posts.yml
Nothing else changes. $posts is the same variable in every template, the listing and the
dynamic page work unchanged, and one posts.yml still types the columns. A folder
collection reads newest first — by date, then by slug — so a listing that loops over
$posts is already in order.
- Max and the website importer switch to a folder past 100 entries on their own; you never have to. The Content place shows which form a collection uses in its header.
- Editing an entry in the Content place rewrites only that entry's file; changing its slug renames the file; deleting the row deletes the file.
- Keep one form per collection. If a website holds both
posts.jsonand aposts/folder, the folder is used and the build reports the duplicate until you remove one — the two are never merged.
Every change made in the Content place goes through the same save path as any other edit, so it lands in revision history and recompiles the website immediately.
Dynamic pages
A collection can generate pages, not just fill them. Name a page file
[collection.field].blade.php and the build produces one URL per entry, matching the
last segment of the address against that field.
resources/views/pages/post/[post.slug].blade.php
resources/data/collections/post.json (or post/<slug>.json, one file per entry)
With a post.json of:
[
{
"slug": "zen-mornings",
"title": "Five ways to start the day",
"date": "March 4, 2026",
"excerpt": "Small routines, compounding.",
"body": "<p>The first hour sets the rest…</p>"
},
{
"slug": "spring-checklist",
"title": "Your spring maintenance checklist",
"date": "March 18, 2026",
"excerpt": "Twelve things worth an hour.",
"body": "<p>Gutters first…</p>"
}
]
the build serves /post/zen-mornings and /post/spring-checklist.
Inside a dynamic page, two variables matter:
$post— the single entry this URL is for, not the array. It is named after the collection.$entries— the whole collection, for "more posts" lists and sidebars. The name is reserved for exactly this.
{{-- resources/views/pages/post/[post.slug].blade.php --}}
<x-layouts.main title="{{ $post->title }}" description="{{ $post->excerpt }}">
<article class="mx-auto max-w-2xl px-6 py-20">
<p class="text-sm text-muted">{{ $post->date }}</p>
<h1 class="mt-2 text-4xl font-bold">{{ $post->title }}</h1>
<div class="mt-8 space-y-6">{!! $post->body !!}</div>
</article>
<aside class="mx-auto max-w-2xl border-t border-line px-6 py-12">
<h2 class="font-semibold">More posts</h2>
<ul class="mt-4 space-y-2">
@foreach ($entries as $entry)
<li><a href="/post/{{ $entry->slug }}">{{ $entry->title }}</a></li>
@endforeach
</ul>
</aside>
</x-layouts.main>
This is how you run a blog
An index page that lists the collection, one dynamic page that renders an entry, and that
is the whole blog. Note the difference in what $post means: on an ordinary page it is
the collection — the full array — and only inside the dynamic page does it narrow to the
single entry that URL is for.
{{-- resources/views/pages/blog.blade.php --}}
<x-layouts.main title="Blog" description="News and advice.">
<section class="mx-auto max-w-2xl px-6 py-20">
<h1 class="text-4xl font-bold">Blog</h1>
@foreach ($post as $entry)
<article class="mt-10 border-t border-line pt-8">
<p class="text-sm text-muted">{{ $entry->date }}</p>
<h2 class="mt-1 text-2xl font-semibold">
<a href="/post/{{ $entry->slug }}">{{ $entry->title }}</a>
</h2>
<p class="mt-2 text-muted">{{ $entry->excerpt }}</p>
</article>
@endforeach
</section>
</x-layouts.main>
Publishing a new post means adding one entry — in the Content place or in the JSON file — and never creating another page file. Ask the assistant to "write a post about spring gutter maintenance and add it to the blog" and that is precisely what it does.
Things worth knowing
- The matched field should be URL-friendly: lowercase, dashes, no slashes. An entry whose
field is empty or contains a
/is skipped rather than producing a broken address. - The field must be unique across entries, or two posts fight over one URL.
- A dynamic page whose collection has no entries cannot generate anything, and the build says so, naming both places an entry can live. Add at least one entry.
- The folder the page sits in becomes the URL prefix:
pages/post/[post.slug].blade.phpserves/post/…, whilepages/[post.slug].blade.phpserves/…straight off the root.
Where to go next
- Building with Blade — the templates that read all of this.
- Blade syntax reference — every directive, in one place.