How to Add JSON-LD Schema in Laravel Blade Templates (Safely)

A clean pattern for generating JSON-LD in Laravel: a small schema builder, one graph per page through a Blade stack, safe output with @json, and linked authors and articles.

To add JSON-LD schema in Laravel, build the schema as a PHP array from the same data that renders the page, output it with Blade’s @json directive inside a script tag of type application/ld+json, and collect each page’s nodes into one graph through a layout stack. @json escapes characters such as < and >, which prevents a value in your data from closing the script tag. The pattern below keeps one Organization node for the whole site, adds page-specific nodes, and works the same in Blade with or without a JavaScript front end.

Why build schema from data, not by hand

Hand-written JSON-LD drifts: a price changes on the page but not in the schema, or a service is renamed in one place only. Google’s structured data guidelines require that markup describe what is visible on the page. Generating the schema from the same model that renders the page keeps them in step.

Step 1: a small schema builder

// app/Support/Schema.php
namespace App\Support;

class Schema
{
    protected array $nodes = [];

    public static function organization(): array
    {
        $home = url('/');
        return [
            '@type'  => 'Organization',
            '@id'    => $home . '#organization',
            'name'   => config('app.name'),
            'url'    => $home,
            'logo'   => asset('images/logo-512.png'),
            'sameAs' => config('seo.same_as', []),
        ];
    }

    public function add(array $node): static
    {
        $this->nodes[] = $node;
        return $this;
    }

    public function graph(): array
    {
        return [
            '@context' => 'https://schema.org',
            '@graph'   => array_merge([static::organization()], $this->nodes),
        ];
    }
}

Keep the official profile links in config/seo.php, so there is one place to update LinkedIn, Wikidata or other sameAs URLs.

Step 2: a stack in the layout

{{-- resources/views/layouts/app.blade.php, inside <head> --}}
@stack('jsonld')

Step 3: build page nodes in the controller

// app/Http/Controllers/ServiceController.php
public function show(Service $service)
{
    $url = route('services.show', $service);

    $schema = (new \App\Support\Schema)->add([
        '@type'       => 'Service',
        '@id'         => $url . '#service',
        'name'        => $service->name,
        'description' => $service->summary,
        'url'         => $url,
        'provider'    => ['@id' => url('/') . '#organization'],
        'areaServed'  => 'IN',
    ]);

    return view('services.show', [
        'service' => $service,
        'schema'  => $schema->graph(),
    ]);
}

Step 4: output it safely in the view

{{-- resources/views/services/show.blade.php --}}
@push('jsonld')
<script type="application/ld+json">@json($schema)</script>
@endpush

Laravel’s @json directive calls json_encode with flags that convert <, >, &, single quotes and double quotes into Unicode escapes. That matters because JSON-LD often contains text from your database. Without escaping, a product description containing “</script>” would end the script tag early and could open the page to script injection. If you pass your own flags as a second argument to @json, they replace these defaults, so include JSON_HEX_TAG in them. Avoid echoing json_encode output with {!! !!} unless you pass JSON_HEX_TAG yourself.

Step 5: articles and authors

For blog posts, link the article to a Person node and the Person to the Organization, so the content, author and company connect:

$schema->add([
    '@type'    => 'Person',
    '@id'      => url('/about') . '#' . $post->author->slug,
    'name'     => $post->author->name,
    'sameAs'   => array_filter([$post->author->linkedin_url]),
    'worksFor' => ['@id' => url('/') . '#organization'],
])->add([
    '@type'         => 'BlogPosting',
    'headline'      => $post->title,
    'datePublished' => $post->published_at->toAtomString(),
    'dateModified'  => $post->updated_at->toAtomString(),
    'author'        => ['@id' => url('/about') . '#' . $post->author->slug],
    'publisher'     => ['@id' => url('/') . '#organization'],
    'mainEntityOfPage' => route('blog.show', $post),
]);

Use a content-change timestamp for dateModified rather than updated_at if your models are touched by background jobs that do not change the text.

If the front end is Next.js

When Laravel serves an API and Next.js renders the pages, build the same array in Laravel, return it in the API response, and let the Next.js server component print it in a script tag. The schema stays in one place and renders on the server, where every crawler can read it. See the Next.js checklist for AI search.

Packages

The spatie/schema-org package provides typed builders for every schema.org type, which helps on large projects with many types. For most sites, a small builder like the one above is enough and easier to review.

Test it

  1. View the page source and confirm one script block with a single @graph.
  2. Run the URL through the Schema Markup Validator and Google’s Rich Results Test.
  3. Check that names, prices and dates match what the page shows.

Frequently asked questions

Should schema be cached?

It is built from data already loaded for the page, so the cost is small. If you cache the page, the schema is cached with it.

Can I put JSON-LD in the body instead of the head?

Yes. Google reads JSON-LD anywhere in the HTML. The head is simply easier to manage with a stack.

Which types should a service business add first?

Organization, WebSite, Service for each service page, Person for authors, and BreadcrumbList. More on choosing types in our JSON-LD guide.

Sources

  1. Google Search Central, “General structured data guidelines”
  2. Laravel documentation, Blade templates: “Rendering JSON” and stacks
  3. schema.org type definitions for Organization, Service, Person and BlogPosting