Skip to main content
WireKit
Copy for LLM

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.

WebSite schema — rendered demonstration
JSON-LD injected silently
A <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(...) AggregateRatingreviewCount 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.9 emits "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' emits https://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

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store