secit-pl/json-ld-bundle
A Symfony bundle for generating JSON-LD structured data in your applications. It provides services and Twig helpers to define schema.org objects, embed SEO-friendly rich snippets, and improve search visibility with minimal configuration and flexible integration.
First, re-scope the framing. secit-pl/json-ld-bundle is a Symfony bundle (2.8 / 3.0+), not a Laravel package. There is no extra.laravel.providers, no facade, no publishable config, no migrations. A Laravel developer's day-one task is therefore not "install and configure" but "decide how much of this I actually need."
Minimal steps:
composer require secit-pl/json-ld-bundle --dry-run and read the dependency diff. The bundle's own composer.json will require symfony/framework-bundle (or symfony/symfony), which drags Symfony DI/HttpKernel/Config into an app that already has illuminate/*. Some overlap is harmless; the boot-time bundle registration never runs because Laravel has no Symfony kernel bundle loop.services.yaml wiring that are dead weight in Laravel. What you actually want is the small set of plain-PHP builder classes behind them (Thing, Organization, Product, BreadcrumbList, and their property traits). Confirm they have no constructor dependencies on the container.Product with offers and a BreadcrumbList. Do it in a controller or a view composer, dump the string to the log, and paste it into Google's Rich Results Test before you touch a Blade template.app/Support/JsonLd/ and owning them.If you want a Laravel-native, maintained alternative on the same problem, spatie/schema-org is the obvious comparison and is worth 20 minutes before you commit to this one.
Pattern 1 — Builder as a plain object, rendered in a controller.
$product = (new Product())
->setName($model->name)
->setDescription(Str::limit($model->description, 200))
->setSku($model->sku)
->setOffers((new Offer())->setPrice($model->price)->setPriceCurrency('GBP'));
return view('products.show', ['jsonLd' => $product->toJson()]);
The value of the package is entirely in these fluent setters and the nesting behaviour — the json_encode is trivial. If the builders are clean, this is a 20-line extraction, not a bundle.
Pattern 2 — Blade directive for output.
// AppServiceProvider::boot()
Blade::directive('jsonLd', fn ($expr) => "<?php echo '<script type=\"application/ld+json\">' . ($expr) . '</script>'; ?>");
Then in your layout: @jsonLd($jsonLd ?? ''). Keep it out of {{ }} — Blade escapes, and you'd get " everywhere.
Pattern 3 — Global site graph via a view composer. Organization, WebSite with a SearchAction, and the logo belong on every page, not per-controller. Register one composer on your layout and merge it with page-level entities into a single @graph block:
{"@context":"https://schema.org","@graph":[ {...org...}, {...product...} ]}
One script block per page, not five. Multiple ld+json blocks are legal but you lose the ability to cross-reference nodes by @id.
Pattern 4 — Nesting and arrays. Breadcrumbs and ItemList are where the builder earns its keep: BreadcrumbList → array of ListItem with position and item. Build them in a dedicated BreadcrumbBuilder service so the trail logic lives in one place (route name → label mapping is not something the package can do for you).
Pattern 5 — Cache the string, not the object. For static-ish pages (homepage org graph, category pages), cache the rendered JSON with Cache::remember("jsonld:product:{$id}", 3600, fn () => $builder->toJson()). The builders build fast, but you don't want to re-run them in a layout that renders on every request.
Pattern 6 — Test the structure, not the string.
$data = json_decode($response->viewData('jsonLd'), true);
$this->assertSame('https://schema.org', $data['@context']);
$this->assertSame('Product', $data['@type']);
$this->assertArrayHasKey('offers', $data);
Assert on @type and required properties per Google's rich-result requirements — that's the contract that actually matters, and it survives a refactor or a swap to Spatie.
Framework coupling is the headline gotcha. The SecIT\JsonLdBundle class extends Symfony's Bundle; nothing instantiates it in Laravel. The Twig extension is unusable. Any services.yaml / Extension::load() examples in the README must be translated to a service provider — or, more likely, skipped entirely because the builders need no DI.
PHP 8 compatibility is unverified and likely. Code from 2017 predates PHP 7.2's object type, readonly, and strict dynamic-property handling in 8.2. Run php -l over the vendored classes and a single builder instantiation under your actual PHP version before building anything on top of it. If it errors on PHP 8.2+, you're now maintaining a fork on day one — factor that in.
Don't let the builders choose your escaping strategy. If any value comes from user input (product names, seller names, review text), json_encode alone can emit </script> inside a string and break out of the tag. Always encode with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT if you're writing your own serializer; verify what the package's toJson() actually passes.
Missing properties fail silently. These builders set what you tell them to and nothing else. There is no schema validation. A Product without offers or image is invalid JSON-LD that nobody will tell you about — validate manually against the Rich Results Test during development and keep a feature test asserting the required keys.
@context and @type may be yours to set. Confirm the emitted output includes both at the root. When composing a @graph, @context goes once at the top level only.
Beware array vs single object. offers may legitimately be an object or an array; image likewise. Check exactly what the builder emits for one child vs many, and match Google's expectations — a single-element array where an object is expected is a common cause of "no rich result."
Config caching, Octane, and queues are non-issues if you vendor. Because there's no service provider, there's no boot cost, no cached-config drift, and no request-scoped state in singletons. This is the package's accidental strength in Laravel: adopted as plain classes, its operational footprint is zero. If you instead write a Laravel provider with a singleton builder, you've reintroduced exactly the state-leak risk the bundle avoided — make builders bind() (fresh per resolution), not singleton().
Extension point is inheritance, not configuration. Because the bundle is a thin wrapper, custom schema.org types mean extending the base Thing/Abstract type class and adding setters, then registering your subclass in whatever factory you write. There is no plugin registry here; that's fine for 3–4 custom types and painful beyond.
Write down why you chose this. Two years from now the next engineer will see a 2017-dated, 6-star Symfony bundle in composer.json and assume it's load-bearing framework glue. A // vendored from secit-pl/json-ld-bundle, builders only, no Symfony boot comment in composer.json and a one-paragraph ADR in the repo saves a half-day of confused archaeology — probably more than the package itself saves you.
How can I help you explore Laravel packages today?