Structured Data
Emit safe, valid JSON-LD (<script type="application/) from a plain PHP array. Solves a Blade footgun: @json splits its argument at every comma, so an array literal written into it loses the escaping flags or stops compiling. The component encodes the array itself, and bakes in the JSON_HEX_TAG flag so user-controlled string values cannot break out of the script block.
Basic Usage
<x-wirekit::renders nothing visible. It injects astructured-data> <script type="application/block at the spot you mount it — typically inside the layout'sld+json"> <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 \u003C and \u003E. This means a user-controlled value containing </script> cannot break out of the JSON-LD block. @json($schema) over a variable passes the same flag; written over an array literal, the directive takes the text after the first comma as its flags, and the escaping is gone.
The component also passes JSON_ (URLs stay readable: https://example. instead of https:\/\/example.) and JSON_ (native characters: Café instead of Caf\u00e9). Slashes are safe because JSON_HEX_TAG already neutralizes the < that would be needed for tag injection.
Text that is not valid UTF-8, such as a value from an old Latin-1 column, keeps the block valid: each bad byte becomes �. A value JSON cannot hold at all, NAN, INF or data nested past the encoder's depth limit, renders no block rather than an empty one, and with APP_DEBUG on your log says why.
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.
Import the class in the view that calls the builders, with @use at its top, as
every example below does. A use statement in your component class does not reach
the compiled view, and there Schema is Laravel's database facade unless you
import this one.
@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 — from a name => url map or a list of steps, positions numbered for you. A step whose address is not http, https, mailto, tel or relative counts as a step without a page |
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:
@use('Pushery\WireKit\Schema\Schema')
<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.
A node that only some pages carry can be written as a condition that yields null,
such as $faqs !== [] ? Schema::: graph() leaves a null node out.
A breadcrumb trail
breadcrumb takes the steps in order, as a name => url map or as a list of
['name' => …, 'url' => …] steps. Two steps with the same name need the list: a map keeps each
name once, so a category and a product that share a name would leave the trail one step short.
@use('Pushery\WireKit\Schema\Schema')
<x-wirekit::structured-data :data="Schema::breadcrumbList([
['name' => 'Shop', 'url' => 'https://acme.test'],
['name' => 'Gift card', 'url' => 'https://acme.test/gift-card'],
['name' => 'Gift card', 'url' => 'https://acme.test/gift-card/25'],
])" />
A step with no page of its own takes null and is left out of the list unless it is the last,
and two steps in a row with the same address count as one. A page that renders
<x-wirekit:: carries its BreadcrumbList already, so
build one here only where the visible trail is not that component: a page carries exactly one.
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.
@use('Pushery\WireKit\Schema\Schema')
<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.. Passing the full URL also works.org/ InStock - 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. |
nonce |
?string |
null |
CSP nonce for the emitted <script type="application/ld+json">. Resolved automatically when omitted. |
Content Security Policy
The component emits a <script type="application/, and a JSON-LD block is data
rather than code. The HTML standard stops preparing a <script> whose type is not a script type
before it consults the page's Content Security Policy, so script-src does not apply to the
block and a strict policy leaves your structured data alone.
There is nothing to do. The component still carries the page's nonce when one exists, which
is harmless on a data block. It is resolved the same way <x-wirekit:: resolves it —
from a csp-nonce container binding or from Vite:::
{{-- 1. Nothing to pass: the nonce is discovered. --}}
<x-wirekit::structured-data :data="$schema" />
{{-- 2. An explicit nonce always outranks a discovered one. --}}
<x-wirekit::structured-data :data="$schema" :nonce="$nonce" />
With no nonce anywhere, no nonce attribute is emitted.
Accessibility
This component renders nothing a person can see, and that is the whole point. It emits a
<script type="application/ 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