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.
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. |
Keyboard Interaction
This component is purely presentational and does not respond to keyboard input.
See Also
- Schema.org — full type catalog
- Google Search Central — Structured Data