Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Json Ld Laravel Package

torann/json-ld

torann/json-ld is a Laravel package for generating JSON-LD structured data. It provides a fluent builder for schema.org markup, supports multiple schema types, and makes adding SEO-friendly rich results to your application simple.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and resolve constraints before anything else. The package is pre-1.0 and last shipped in March 2020, so the composer constraint is the highest-risk part of the whole exercise:

composer require torann/json-ld:0.0.19

Pin the exact version — ^0.0 on a package that has never left 0.0.x buys you nothing but surprise. If you get an illuminate/support conflict, do that work on a spike branch. Don't reach for --ignore-platform-reqs in the project composer.json; if the constraints genuinely don't resolve, that's a legitimate signal to stop and price out spatie/schema-org (or a 200-line hand-rolled presenter) instead. The integration you're about to write is identical either way, so the decision is cheap now and expensive after you've wired it into twenty templates.

Laravel auto-discovery registers the service provider. Publish config if the package ships one:

php artisan vendor:publish --provider="Torann\JsonLd\JsonLdServiceProvider"

Where to look first, in this order:

  1. src/ — read it end to end, it's small. You want the facade/entry class, the per-type builders (Article, Product, BreadcrumbList, Organization, plus Book, MediaObject, Recipe added in 0.0.19), and the exact method names each one exposes. The README has drifted from the code; trust src/ over the docs, and confirm the facade namespace and any Blade directive actually exist before you build on them.
  2. The rendering path — find exactly how the <script type="application/ld+json"> block is emitted and whether it escapes. You need to know this before user-supplied titles and descriptions go into it.
  3. config/ — where custom type definitions and site-wide defaults live, and the thing most likely to be silently stale after a deploy.

First use case: one template, one type. Pick your product detail page or an article page, render a single entity into the layout <head>, and validate it with Google's Rich Results Test before merging. Half a day end to end. Do not start with breadcrumbs across twelve templates.

The minimal honest integration is a presenter that returns a plain array, plus one Blade component that encodes it:

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

interface SchemaPresenter
{
    /** @return array<string, mixed> */
    public function toArray(): array;
}
// app/Schema/ArticlePresenter.php
namespace App\Schema;

use App\Models\Post;
use Torann\JsonLd\Facades\JsonLd; // verify exact namespace in src/

final class ArticlePresenter implements SchemaPresenter
{
    public function __construct(private Post $post) {}

    public function toArray(): array
    {
        return JsonLd::article()                    // builder API varies — read src/Article.php
            ->headline($this->post->title)
            ->datePublished($this->post->published_at->toIso8601String())
            ->dateModified($this->post->updated_at->toIso8601String())
            ->author($this->author())
            ->image($this->post->heroUrl())
            ->toArray();
    }
}
{{-- resources/views/components/json-ld.blade.php --}}
@props(['schema'])
<script type="application/ld+json">
{!! json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}
</script>

Nothing else in your views should ever touch the package.

Implementation Patterns

Presenter per type, one type per file. The package gives you fluent builders; it does not give you model→schema mapping. That mapping is yours, and the only sustainable shape is app/Schema/{Type}Presenter.php, constructed from a model, returning an array. Controllers resolve the presenter (directly or via a tiny factory), Blade renders the array. The package stays confined to the presenter layer, which means replacing it later is one file per type.

Keep site-wide defaults in config, not in every presenter. Organization name, logo URL, sameAs profiles, publisher reference — these change once a year. Put them in config/schema.php and merge them from a BasePresenter. If you're passing the same logo path into five presenters, that's the signal you've missed this.

Compose with @graph instead of stacking script tags. Multiple entities on one page (Article + BreadcrumbList + Organization) belong in a single @graph with stable @id references — not four separate <script> blocks. Overlapping or duplicated markup is one of the most common reasons rich results silently don't appear:

$graph = [
    '@context' => 'https://schema.org',
    '@graph'   => [
        ['@type' => 'Organization', '@id' => url('/').'#organization', /* ... */],
        array_merge($articleArray, ['publisher' => ['@id' => url('/').'#organization']]),
        $breadcrumbArray,
    ],
];

Derive @ids from canonical URLs so they stay stable across deploys.

Cache the assembled array, keyed on the model. Assembly is cheap, but caching makes invalidation explicit and keeps you honest about staleness:

return Cache::remember(
    "schema:article:{$this->post->id}:{$this->post->updated_at->timestamp}",
    now()->addDay(),
    fn () => $this->build()
);

Timestamp-in-key is crude, but it means you never write an observer and never ship a stale price or rating.

Test the rendered HTML, not the presenter. A perfect presenter can still be broken by escaping or a layout that renders it twice:

it('emits valid Article json-ld', function () {
    $post = Post::factory()->published()->create();

    $html = $this->get(route('posts.show', $post))->getContent();
    preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $m);
    $data = json_decode($m[1], true);

    expect(json_last_error())->toBe(JSON_ERROR_NONE)
        ->and($data['@type'])->toBe('Article')
        ->and($data['headline'])->toBe($post->title)
        ->and($data['image'])->toStartWith('https://');
});

Assert only the fields Google actually requires for that type, plus a generic "no nulls, no empty strings" sweep — that null-stripper assertion catches the majority of real regressions.

Roll out by template, in this order: Product/Offer → Article → BreadcrumbList → vertical types (Recipe/Book/MediaObject) only where the catalogue data genuinely supports the required fields. Instrument Search Console enhancements per template so you can attribute any movement to a specific change.

Integration point matters more than the API. Keep presenters out of controllers and out of Eloquent models. If a model gains a toJsonLd() method, you've coupled your domain to a 2020 package and the replacement cost just went from "one file per type" to "every model."

Gotchas and Tips

Escaping is the one thing that can bite you hard. JSON-LD sits inside a <script> tag, so a </script> in a title or description breaks the document. JSON_HEX_TAG handles it, as does replacing </ with <\/. Never use {{ }} — Blade will entity-encode the JSON and make the block invalid. Use {!! !!}, but always with the encode flags and never with raw, unprocessed user strings.

Never emit placeholder values. No invented aggregateRating, no availability: InStock for out-of-stock items, no stale prices. Fabricated structured data is a manual-action risk and it's the highest-severity failure mode here. If a template lacks the fields a type requires, that's a content-modelling decision to escalate — not a reason to fill in a default.

Strip empties before encoding. null, '', and [] are all valid JSON but invalid schema, and validators flag them inconsistently. Run a recursive filter at the end of every presenter:

$clean = array_filter($data, fn ($v) => ! is_null($v) && $v !== '' && $v !== []);

Type the values properly. Dates as ISO 8601 (->toIso8601String()), prices as numeric strings with a separate priceCurrency, all URLs absolute and canonical, images absolute and large enough to be eligible. A missing scheme or a relative image path is the most common "why isn't this showing up" cause after duplication.

Verify the API against src/, not the README. Method names for nested entities, the facade alias, and any helper function are all worth checking in the installed version. This package's documentation has not tracked its code closely, and guessing at a fluent method name costs more time than reading a 40-line builder class.

Config caching on deploy. If custom types or defaults live in config, a config:cache deploy that skips config:clear will silently emit the old shape. Put it in the deploy script and eyeball one page immediately after.

Partial navigation won't re-render the head. Livewire, Turbo, and Inertia-style navigation swap content while leaving your JSON-LD untouched — so the markup describes the previous page. Either force full page loads on structured-data pages, or emit the schema per-view rather than in the layout.

Debugging workflow when a validator complains: dump the raw array from php artisan tinker ((new ArticlePresenter($post))->toArray()), then compare it against the validator's complaint field by field. If the array is right but the output is wrong, the problem is escaping or the layout — grep the source of the rendered page for application/ld+json and check how many blocks there are.

Supply-chain risk is the real risk, not the code. March 2020, pre-1.0, ~148 stars, zero dependents. Budget a few hours per Laravel major upgrade to confirm it still installs. Treat it as internal code from day one: pinned version, a named owner, a fork ready. BSD-2-Clause makes vendoring legally trivial and the codebase is small enough to fork in an afternoon if it ever blocks an upgrade.

Validation gate. Google Rich Results Test plus the Schema Markup Validator on every template you add, and a recurring check in Search Console enhancements. Write the conventions down — one presenter per type, one graph per page, stable @ids, no empty values, no fabricated fields — so the next developer doesn't re-derive them from scratch.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons