---
title: Structured Data
description: JSON-LD script block emitter (XSS-safe via JSON_HEX_TAG)
visibility: guest
draft: false
---

# 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.

:::preview{title="WebSite schema — rendered demonstration"}
<x-wirekit::callout variant="info" :icon="false">
    <x-slot:title>JSON-LD injected silently</x-slot:title>
    A <code>&lt;script type="application/ld+json"&gt;</code> block is now in the page source — exactly the WebSite schema shown below as the rendered output. The component itself produces no visible markup.
</x-wirekit::callout>
<x-wirekit::structured-data :data="[
    '@context' => 'https://schema.org',
    '@type' => 'WebSite',
    'url' => 'https://wirekit.app',
    'name' => 'WireKit',
]" />
:::

**Blade input:**

```blade
<x-wirekit::structured-data :data="[
    '@context' => 'https://schema.org',
    '@type' => 'WebSite',
    'url' => 'https://wirekit.app',
    'name' => 'WireKit',
]" />
```

**Rendered output (JSON-LD `<script>` block):**

```html
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","url":"https://wirekit.app","name":"WireKit"}</script>
```

## Schema Examples

### Article schema

```blade
<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:

```html
<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

```blade
<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:

```html
<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:

```blade
<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](https://search.google.com/test/rich-results) or [Schema.org Validator](https://validator.schema.org/) — 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](/components/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.

```php
use Pushery\WireKit\Schema\Schema;
```

```blade
{{-- 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:

```blade
<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.

```blade
<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.

::: warning
**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](https://schema.org) — full type catalog
- [Google Search Central — Structured Data](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data)
