Structured Data
Emit safe, valid JSON-LD (<script type="application/ld+json">) from a plain PHP array. Solves the Blade tokenizer footgun where hand-writing JSON-LD with @json([...]) across multiple lines breaks at { / }, and bakes in the JSON_HEX_TAG flag so user-controlled string values cannot break out of the script block.
Basic Usage
<x-wirekit::structured-data>renders nothing visible. It injects a<script type="application/ld+json">block at the spot you mount it — typically inside the layout's<head>. The previews on this page intentionally pair the Blade input with the resulting<script>output so you can see exactly what the component emits.
<script type="application/ld+json"> block is now in the page source — exactly the WebSite schema shown below as the rendered output. The component itself produces no visible markup.Blade input:
<x-wirekit::structured-data :data="[
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'url' => 'https://wirekit.app',
'name' => 'WireKit',
]" />
Rendered output (JSON-LD <script> block):
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","url":"https://wirekit.app","name":"WireKit"}</script>
Schema Examples
Article schema
<x-wirekit::structured-data :data="[
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => 'WireKit — Developer polish',
'author' => [
'@type' => 'Organization',
'name' => 'Pushery',
],
'datePublished' => '2026-04-25',
'image' => 'https://wirekit.app/og.png',
]" />
Rendered output:
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","headline":"WireKit — Developer polish","author":{"@type":"Organization","name":"Pushery"},"datePublished":"2026-04-25","image":"https://wirekit.app/og.png"}</script>
Organization schema with social profiles
<x-wirekit::structured-data :data="[
'@context' => 'https://schema.org',
'@type' => 'Organization',
'name' => 'WireKit',
'url' => 'https://wirekit.app',
'logo' => 'https://wirekit.app/logo.svg',
'sameAs' => [
'https://github.com/pushery/wirekit',
'https://packagist.org/packages/pushery/wirekit',
],
]" />
Rendered output:
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","name":"WireKit","url":"https://wirekit.app","logo":"https://wirekit.app/logo.svg","sameAs":["https://github.com/pushery/wirekit","https://packagist.org/packages/pushery/wirekit"]}</script>
Security
Every string value is encoded with JSON_HEX_TAG, which converts < and > into < and >. This means a user-controlled value containing </script> cannot break out of the JSON-LD block — a real XSS vector that hand-rolled @json() does NOT defend against.
The component also passes JSON_UNESCAPED_SLASHES (URLs stay readable: https://example.com instead of https:\/\/example.com) and JSON_UNESCAPED_UNICODE (native characters: Café instead of Café). Slashes are safe because JSON_HEX_TAG already neutralizes the < that would be needed for tag injection.
Pairing with Page Layouts
Mount one or more structured-data blocks in your layout's <head> — typically alongside the <title> and meta tags:
<head>
<title>{{ $title }}</title>
<meta name="description" content="{{ $description }}">
<x-wirekit::structured-data :data="$websiteSchema" />
<x-wirekit::structured-data :data="$breadcrumbSchema" />
</head>
Validation
Test your output with Google's Rich Results Test or Schema.org Validator — paste the rendered URL and confirm the JSON-LD is detected with no errors.
Typed builders
Schema is a set of typed JSON-LD builders for your app — you call them
directly, wherever the data lives. (One of them, faqPage, can additionally be
fed by the faq component; that is a convenience on top, not
what the builders are for.)
Hand-writing schema.org arrays is where rich results go to die: a price with a
currency symbol, an availability written as a bare word, a rating with no count.
The Schema builders encode those rules once.
use Pushery\WireKit\Schema\Schema;
{{-- 1. Fragments nest — the context is stamped once, at the top --}}
<x-wirekit::structured-data :data="Schema::product(
name: $product->name,
description: $product->summary,
image: $product->image_url,
brand: 'Acme',
offers: Schema::offer(
price: $product->price,
priceCurrency: 'EUR',
availability: 'InStock',
),
aggregateRating: Schema::aggregateRating(
ratingValue: $product->rating_avg,
reviewCount: $product->rating_count,
),
)" />
| Builder | Emits |
|---|---|
Schema::product(...) |
Product — nests offers, aggregateRating, review |
Schema::offer(...) |
Offer — price as a bare string, availability expanded to its URL |
Schema::aggregateRating(...) |
AggregateRating — reviewCount is required for a rich result |
Schema::review(...) |
Review — nests the author and the rating |
Schema::faqPage(...) |
FAQPage — from a question => answer map or a list of rows |
Schema::article(...) |
Article |
Schema::breadcrumbList(...) |
BreadcrumbList — positions numbered for you |
Schema::webSite(...) |
WebSite — the site itself |
Schema::organization(...) |
Organization — who is behind it, with sameAs profiles |
Schema::softwareApplication(...) |
SoftwareApplication — a library, app or SaaS product |
Schema::node(...) |
Any schema.org type, unmodeled |
Schema::graph(...) |
Combines several nodes into one @graph |
Every builder takes an extra: array for properties it does not model — and
those override the modeled ones, so you are never boxed in.
Describing the site itself
A marketing page usually describes more than one thing: the site, the
organization behind it, and the product it offers. graph() combines them into
one connected document instead of a pile of separate scripts, and @id lets the
nodes reference each other:
<x-wirekit::structured-data :data="Schema::graph([
Schema::webSite(
name: 'Acme',
url: 'https://acme.test',
id: 'https://acme.test/#site',
publisher: ['@id' => 'https://acme.test/#org'],
),
Schema::organization(
name: 'Acme Inc',
url: 'https://acme.test',
id: 'https://acme.test/#org',
logo: 'https://acme.test/logo.png',
sameAs: ['https://github.com/acme'],
),
Schema::softwareApplication(
name: 'Acme CLI',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
isAccessibleForFree: true,
),
])" />
The context is stamped once at the top of the @graph, never on the individual
nodes.
Any other type
node() is the escape hatch: it builds any schema.org type with the same
null-pruning and the same @type-first shape, so you never have to wait for a
builder to exist.
<x-wirekit::structured-data :data="Schema::node('Event', [
'name' => 'Acme Conf',
'startDate' => '2026-09-01T09:00:00+02:00',
'location' => Schema::node('Place', ['name' => 'Berlin']),
])" />
What the builders fix for you
- Price is a string, with no symbol and no separator.
price: 49.9emits"49.9"—"€49,90"is the single most common reason a Product rich result is rejected. - Availability is a URL, not a word.
availability: 'InStock'emitshttps://schema.org/InStock. Passing the full URL also works. - Null properties are dropped. An empty key is worse than a missing one.
- The context is stamped exactly once, at the top level — so nesting an Offer inside a Product needs no context-stripping.
Only describe what the page actually shows. Structured data that claims
content the visitor cannot see is a Google policy violation and gets the rich
result withdrawn — permanently, for the whole site in bad cases. Emit an
FAQPage only where the questions are really rendered, an AggregateRating
only where the rating really is on screen.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data |
array |
[] |
Associative array serialized to JSON-LD. Nested arrays and objects are supported. |
Accessibility
This component renders nothing a person can see, and that is the whole point. It emits a
<script type="application/ld+json"> block: machine-readable metadata for search engines and
other crawlers. Browsers do not paint it, and assistive technology does not announce it —
correctly, because a screen reader reading out a JSON document would be noise.
So the accessibility question here is not about this component. It is about the page around it, and it has one rule:
Every fact you put in the JSON-LD must also be in the visible markup. A price, a rating, an availability or an event date that exists only in the structured data is invisible to everybody using the page — including, but not only, someone using a screen reader.
It is also against every search engine's structured-data guidelines, which require the markup to represent content actually present on the page. So the two requirements point the same way, and the fix for one is the fix for the other.
Nothing enforces that pairing, and nothing can: only you know which visible element carries the fact. The practical check is to read the JSON block field by field and find each value on the rendered page.
Keyboard Interaction
This component emits no rendered element, so there is nothing to focus and no keyboard model to describe.
See Also
- Schema.org — full type catalog
- Google Search Central — Structured Data