Skip to content

Start building your next app or website — with AI, right in your browser.

Visit devdojo.com
Docs
Collections

Sites 07 / 13

Collections

Put your content in JSON files and loop over it in your pages — add an item, and your page grows a card.

Collections

Collections keep your content separate from your design. Put your data — features, team members, sites, FAQs — in a JSON file, then loop over it in your pages. Add an item to the JSON, and your page grows a card. No HTML surgery required.

Creating a collection

Creating a collection is as simple as creating a .json file inside the resources/data/collections/ directory, containing a list of items:

[
    {
        "title": "Fast",
        "description": "No build step. Ever.",
        "highlight": true
    },
    {
        "title": "Simple",
        "description": "Just HTML and Tailwind.",
        "highlight": false
    }
]

Two rules cover everything:

  1. The file must be a top-level array ([ ... ]).
  2. Each item's fields hold text, numbers, or true/false.

The friendliest way to work with them is the Content surface — the top bar's Content entry opens every collection as a spreadsheet-style grid where you can browse, search, edit entries, add rows, and create new collections. Collections also open in the code editor like any other file — edit the JSON, save, and every page that loops over it updates in the preview.

A collection JSON file open in the editor

Each file becomes a variable named after it: resources/data/collections/features.json is $features, available everywhere — pages, layouts, and components — exactly like $site, with no wiring required (see Components). Reach for a bound attribute (:items="$features") only when you want a component to receive a different slice of data per instance, under a name that isn't the collection's own.

Looping with @foreach

Loop over a collection with @foreach, giving each item a name:

<div class="grid gap-6 md:grid-cols-3">
    @foreach ($features as $feature)
        <div class="rounded-xl border p-6">
            <h3 class="font-semibold">{{ $feature->title }}</h3>
            <p class="mt-2 text-sm text-zinc-500">{{ $feature->description }}</p>
        </div>
    @endforeach
</div>

Inside the loop, read a field with the arrow: {{ $feature->title }} pulls the title field from the current item. Items render in the order they appear in the JSON file — want a different order, reorder the JSON.

$loop — the loop helper

Inside any @foreach, a $loop variable tracks where you are. It carries exactly these fields:

Field What it is
$loop->index The current position, starting at 0
$loop->iteration The current position, starting at 1
$loop->first true on the first item
$loop->last true on the last item
$loop->count Total number of items
$loop->even / $loop->odd true on even / odd iterations

Great for numbered lists:

@foreach ($steps as $step)
    <p><strong>Step {{ $loop->iteration }}:</strong> {{ $step->title }}</p>
@endforeach

Showing the first N items

There's no count attribute — you cap a loop with @break, which stops it early. Put it as the last line of the loop body to render the first N items and then stop:

@foreach ($sites as $site)
    <div class="rounded-xl border p-6">
        <h3>{{ $site->title }}</h3>
    </div>
    @break($loop->iteration == 3)
@endforeach

This is how a homepage shows "the top 3 sites" while the work page loops the same collection with no @break to show them all — same data, two views. Its sibling @continue skips the rest of the current item and moves to the next; both take an optional condition (@continue($loop->first)).

Detail pages for every entry

A collection can also power a full page per entry — a blog post page for every post, a case study for every site — without creating a file for each one. Name a page [post.slug].blade.php and it serves /post/{slug} for every entry, with $post bound to the matched entry (store the body HTML in a content field and output it with {!! $post->content !!}). In the builder's Content place, HTML fields like that open in a rich text editor — headings, bold, lists, and links, no angle brackets required.

To make the types explicit, give the collection a schema filecollections/posts.yml beside collections/posts.json:

fields:
    title: { type: text }
    link: { type: url }
    image: { type: image }
    content: { type: richtext }

It's the source of truth for the collection's shape: the Content editor renders every column with the right input, and any section that lists the collection picks the types up automatically — no redeclaring the shape per section. See Dynamic pages.

Conditions with @if

Show something only when a condition holds with @if — and, unlike a lot of simple template tools, you get @elseif and @else too:

@foreach ($pricing as $tier)
    <div class="rounded-xl border p-8">
        @if ($tier->featured)
            <span class="badge">Most popular</span>
        @endif
        <h3>{{ $tier->name }}</h3>
    </div>
@endforeach
@if ($tier->featured)
    <a class="btn-solid" href="#">Choose</a>
@else
    <a class="btn-outline" href="#">Choose</a>
@endif

Conditions support ==, !=, >, <, >=, <=, combined with && (and), || (or), ! (not), and parentheses:

@if ($site->year >= 2025 && $site->featured)
@if ($loop->first)
@if (!$tier->featured)

Comparisons are forgiving in the PHP way: '1' == 1 is true. When a field might be missing, default it with ?? so it never breaks the build: @if (($tier->badge ?? '') == 'new').

Global data: $site

Site-wide facts that aren't a list — your business name, tagline, phone, address — live in resources/data/site.json, a single flat object:

{
    "name": "Harbor & Pine",
    "tagline": "Coffee, slowly.",
    "phone": "(555) 010-2288"
}

It's available everywhere — pages, layouts, and components — as $site:

<h1>{{ $site->name }}</h1>
<p>{{ $site->tagline ?? 'Welcome' }}</p>

Use ?? for any field that might not be set, so a missing value never fails the build.

Markdown content collections

For longer-form content — blog posts, docs, case studies — drop Markdown files into resources/data/content/post/. Each .md file becomes an item in the $post collection, ordered by filename, carrying its frontmatter fields plus two extras: content (the rendered HTML) and link (the item's route). Render the body inline with the raw echo:

@foreach ($post as $item)
    <article class="prose">
        <a href="{{ $item->link }}">{{ $item->title }}</a>
        {!! $item->content !!}
    </article>
@endforeach

Note the {!! !!} — you want the Markdown's HTML rendered, not escaped. (More on escaped vs. raw output in the Syntax Reference.)

List fields

A field can hold a list, like tags:

{ "title": "Ledgerline", "tags": ["Design", "Development"] }

Reach into it by position — {{ $site->tags[0] }} renders Design — or loop it:

@foreach ($site->tags as $tag)
    <span class="chip">{{ $tag }}</span>
@endforeach

Don't echo the whole list. {{ $site->tags }} tries to print an array, which is a build error. Loop it or index into it. When list lengths vary between items, a flat shape ("tag_one", "tag_two") is often simpler than an array.

When something's off

Collections fail loudly, on purpose. The variable exists only when the JSON file does — reference $features with no features.json and the build stops with an error naming the missing variable. A file that's present but has invalid JSON, or that isn't a top-level array, is the usual culprit.

The usual suspect. A stray comma is the classic way to break a JSON file — check for one before anything else.

Need this data in the browser's JavaScript? Handing a collection to client-side JS (for a chart or a live filter) is an app-tier feature — it needs your site running as a full app. For a static site, render the content into HTML with @foreach and let the markup carry it.

Next up

Want ready-made designs to pour your collections into? Browse the Section library.

© 2026 DevDojo Edit this page