Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
inertia-protocol
nytodev/inertia-bundle
claude-code
inertia
http-headers
partial-reloads
asset-versioning
page-object
infinite-scroll

Complete reference for the Inertia.js v3 server-side protocol. Auto-loaded when working on InertiaResponse, InertiaListener, Inertia service, or any HTTP response handling.

Install
php artisan boost:add-skill nytodev/inertia-bundle

Save this content to: .claude/skills/inertia-protocol/SKILL.md

# Inertia.js v3 Protocol Reference

## Request Detection

```php
// Detect an Inertia XHR request
$isInertia = $request->headers->has('X-Inertia')
    && 'true' === $request->headers->get('X-Inertia');

// Asset versioning
$clientVersion = $request->headers->get('X-Inertia-Version');

// Partial reload detection
$isPartial    = $request->headers->has('X-Inertia-Partial-Component');
$partialComp  = $request->headers->get('X-Inertia-Partial-Component'); // component name
$onlyProps    = $request->headers->get('X-Inertia-Partial-Data');       // CSV: include list
$exceptProps  = $request->headers->get('X-Inertia-Partial-Except');     // CSV: exclude list
$resetProps   = $request->headers->get('X-Inertia-Reset');              // CSV: keys omitted from merge metadata → client replaces instead of merging
$onceProps    = $request->headers->get('X-Inertia-Except-Once-Props');  // CSV: already-loaded once keys
$scrollIntent = $request->headers->get('X-Inertia-Infinite-Scroll-Merge-Intent'); // 'append'|'prepend'

// Other headers sent by client (no server action required unless noted):
// X-Requested-With: XMLHttpRequest
// Purpose: prefetch               (prefetch request — server may optimize, but not required)
// Cache-Control: no-cache         (reload requests)
// X-Inertia-Error-Bag: {bag}      (validation error bag name override)
// Precognition: true              (Precognition validation — out of scope for this bundle)
```

## Response Types

### HTML (first visit — no X-Inertia header)

```php
// v3: JSON embedded in a <script> tag (prevents </script> injection without extra escaping)
$json = json_encode($pageObject, JSON_HEX_TAG | JSON_THROW_ON_ERROR);
$html = '<script data-page="app" type="application/json">' . $json . '</script>' . "\n" . '<div id="app"></div>';
// Headers: Vary: X-Inertia
// Note: JSON_HEX_TAG alone is sufficient inside a <script> tag — JSON_HEX_APOS/AMP/QUOT are NOT needed.
```

### JSON (XHR visit — X-Inertia: true header present)

```php
$response = new JsonResponse($pageObject);
$response->headers->set('X-Inertia', 'true');
$response->headers->set('Vary', 'X-Inertia');
$response->headers->set('Content-Type', 'application/json');
```

## Page Object (v3) — Canonical Structure

```php
$pageObject = [
    // ALWAYS present:
    'component'      => 'User/Edit',          // string
    'props'          => ['errors' => new \stdClass(), ...], // always has 'errors' key — empty must encode as {} not []
    'url'            => '/user/123',           // RELATIVE path + query (no scheme/host)
    'version'        => '6b16b94d7c51',        // string|null

    // CONDITIONAL — omit entirely when false/empty:
    // v3 history flags: only present when true (omit when false — v3 change from v2)
    'clearHistory'   => true,
    'encryptHistory' => true,
    'preserveFragment' => true,              // NEW v3: preserve URL hash on redirect; omit when false

// Flash data: TOP-LEVEL field (NOT inside props) — v3 change from v2
    // Omit when empty; client defaults to {} when absent
    'flash'          => ['message' => 'Saved!'],

    // Shared prop keys metadata (NEW v3): list of keys that come from Inertia::share()
    // Allows client to distinguish shared vs page-specific props. Omit when empty.
    'sharedProps'    => ['auth', 'appName'],

// Other conditional fields (omit when empty):
    'deferredProps'  => ['default' => ['comments'], 'sidebar' => ['related']],
    'mergeProps'     => ['posts'],             // or ['posts.data'] for sub-path
    'prependProps'   => ['notifications'],
    'deepMergeProps' => ['conversations'],
    'matchPropsOn'   => ['posts.id', 'notifications.id'],
    'onceProps'      => ['plans' => ['prop' => 'plans', 'expiresAt' => null]],
    'scrollProps'    => ['posts' => ['pageName' => 'page', 'nextPage' => 2,
                                     'previousPage' => null, 'currentPage' => 1]],
];
```

### Official page object examples (v3):

**Basic (clearHistory/encryptHistory absent when false):**
```json
{ "component": "User/Edit", "props": { "errors": {}, "user": { "name": "Jonathan" } },
  "url": "/user/123", "version": "6b16b94d7c51cbe5b1fa42aac98241d5" }
```

**With clearHistory:**
```json
{ "component": "User/Edit", "props": { "errors": {} }, "url": "/users", "version": null,
  "clearHistory": true }
```

**With flash data (top-level, not in props):**
```json
{ "component": "User/Edit", "props": { "errors": {} }, "url": "/users", "version": null,
  "flash": { "message": "User created!" } }
```

**With sharedProps:**
```json
{ "component": "Dashboard", "props": { "errors": {}, "auth": {...}, "appName": "MyApp" },
  "url": "/dashboard", "version": null, "sharedProps": ["auth", "appName"] }
```

**With deferred props:**
```json
{ "deferredProps": { "default": ["comments", "analytics"], "sidebar": ["relatedPosts"] } }
```

**With merge props:**
```json
{ "mergeProps": ["posts"], "prependProps": ["notifications"],
  "deepMergeProps": ["conversations"],
  "matchPropsOn": ["posts.id", "notifications.id", "conversations.data.id"] }
```

**With scroll props:**
```json
{ "mergeProps": ["posts.data"],
  "scrollProps": { "posts": { "pageName": "page", "previousPage": null, "nextPage": 2, "currentPage": 1 } } }
```

**With once props:**
```json
{ "onceProps": { "plans": { "prop": "plans", "expiresAt": null } } }
```

## Asset Versioning (409)

```php
// In kernel.request listener:
// IMPORTANT: version check must run BEFORE consuming any FlashBag keys (e.g. 'errors'),
// so that all flash data including 'errors' is still present when reflashing for the 409.
if (
    $request->isMethod('GET')
    && $isInertia
    && null !== $serverVersion
    && $clientVersion !== $serverVersion
) {
    // Reflash ALL flash data (consume + re-add) so nothing is lost across the hard reload.
    $session = $request->getSession();
    $flashBag = $session->getFlashBag();
    $flashes = $flashBag->all();  // all() consumes; peekAll() would cause duplication
    foreach ($flashes as $type => $messages) {
        foreach ($messages as $message) {
            $flashBag->add($type, $message);
        }
    }

return new Response('', 409, [
        'X-Inertia-Location' => $request->getUri(), // absolute URL — browser navigates here
    ]);
}
// Only consume 'errors' flash AFTER the 409 path has been handled.
```

## 302 → 303 Redirect Conversion

Only on PUT, PATCH, DELETE — **not** POST. Spec: "When redirecting after a PUT, PATCH, or DELETE request, you must use a 303 response code."

```php
// In kernel.response listener:
if (302 === $response->getStatusCode()
    && \in_array($request->getMethod(), ['PUT', 'PATCH', 'DELETE'], true)) {
    $response->setStatusCode(303);
}
```

## Partial Reload Filtering Rules

```
isPartial = (X-Inertia-Partial-Data OR X-Inertia-Partial-Except) AND component matches

On full render (not partial):
  - errors: always resolved
  - OptionalProp / optional(): SKIP
  - DeferProp: SKIP (goes to deferredProps metadata)
  - OnceProp: resolve UNLESS key in X-Inertia-Except-Once-Props
  - AlwaysProp: always resolve
  - MergeProp, ScrollProp, Closure, plain value: resolve

On partial reload:
  - errors: always included (ignores $only/$except)
  - AlwaysProp: always included (ignores $only/$except)
  - $except wins over $only when both present
  - OptionalProp: if key in $only, or $only empty (except-only reload) and key not in $except
  - DeferProp: same rule (deferred fetch = key in $only)
  - OnceProp: X-Inertia-Except-Once-Props is IGNORED on partials (only full XHR visits)
  - OnceProp: follows normal $only/$except filtering
  - MergeProp, ScrollProp: follows $only/$except filtering
```

## 409 Responses — Two Distinct Types (v3)

v3 distinguishes two kinds of 409 responses by header:

| Header | Client behaviour | Use case |
|--------|-----------------|----------|
| `X-Inertia-Location: <absolute-url>` | Hard browser redirect (`window.location.href`) | External URL, `Inertia::location()` |
| `X-Inertia-Redirect: <absolute-url>` | Soft SPA navigation (`router.visit()`) | Redirect with URL fragment (hash) |

**NEW v3 — `X-Inertia-Redirect`** (in Laravel Middleware `onRedirectWithFragment()`):
```php
// When a redirect contains a URL fragment (#hash):
return response('', 409, ['X-Inertia-Redirect' => $response->headers->get('Location')]);
```
The client performs a soft navigation, preserving the fragment correctly.

`X-Inertia-Location` (existing, hard redirect):
```php
return response('', 409, ['X-Inertia-Location' => $request->getUri()]);
```

## HTTP Status Codes

| Code | When |
|------|------|
| 200  | Normal response (HTML or JSON) |
| 302  | Redirect (GET requests) |
| 303  | Redirect after PUT/PATCH/DELETE |
| 409  | Version mismatch OR external redirect (`X-Inertia-Location`) OR fragment redirect (`X-Inertia-Redirect`) |

## Props Types Summary

| Type | PHP API | Standard visit | Partial reload | Page object key |
|------|---------|----------------|----------------|-----------------|
| Direct value | `mixed` | ✅ Always | ✅ Optionally | `props` |
| Closure | `fn() => ...` | ✅ Always | ✅ Optionally | `props` |
| OptionalProp | `optional()` | ❌ Never | ✅ If in `$only`, or not excluded on an except-only reload | `props` |
| AlwaysProp | `always()` | ✅ Always | ✅ Always | `props` |
| DeferProp | `defer($cb, $group)` | ❌ Never | ✅ Separate XHR | `deferredProps` |
| OnceProp | `once()` | ✅ First time | ❌ Skip if in `X-Inertia-Except-Once-Props` | `props` + `onceProps` |
| MergeProp | `merge()` / `deepMerge()` *(our bundle: `merge(deep:true)`)* | ✅ Yes | ✅ Yes | `props` + `mergeProps`/`prependProps`/`deepMergeProps` |
| ScrollProp | `scroll()` | ✅ Yes | ✅ Yes | `props` + `mergeProps` + `scrollProps` |

**Note on our bundle API:**
- `optional()` is the only name; `lazy()` was removed in v3 (calling it is a fatal error)
- `Inertia::deepMerge()` → our bundle: `$inertia->merge($cb, deep: true)` (no separate factory)
- `Inertia::defer()->once()` → not yet supported in our bundle

Inertia.js v3 Protocol Reference

Request Detection

// Detect an Inertia XHR request
$isInertia = $request->headers->has('X-Inertia')
    && 'true' === $request->headers->get('X-Inertia');

// Asset versioning
$clientVersion = $request->headers->get('X-Inertia-Version');

// Partial reload detection
$isPartial    = $request->headers->has('X-Inertia-Partial-Component');
$partialComp  = $request->headers->get('X-Inertia-Partial-Component'); // component name
$onlyProps    = $request->headers->get('X-Inertia-Partial-Data');       // CSV: include list
$exceptProps  = $request->headers->get('X-Inertia-Partial-Except');     // CSV: exclude list
$resetProps   = $request->headers->get('X-Inertia-Reset');              // CSV: keys omitted from merge metadata → client replaces instead of merging
$onceProps    = $request->headers->get('X-Inertia-Except-Once-Props');  // CSV: already-loaded once keys
$scrollIntent = $request->headers->get('X-Inertia-Infinite-Scroll-Merge-Intent'); // 'append'|'prepend'

// Other headers sent by client (no server action required unless noted):
// X-Requested-With: XMLHttpRequest
// Purpose: prefetch               (prefetch request — server may optimize, but not required)
// Cache-Control: no-cache         (reload requests)
// X-Inertia-Error-Bag: {bag}      (validation error bag name override)
// Precognition: true              (Precognition validation — out of scope for this bundle)

Response Types

HTML (first visit — no X-Inertia header)

// v3: JSON embedded in a <script> tag (prevents </script> injection without extra escaping)
$json = json_encode($pageObject, JSON_HEX_TAG | JSON_THROW_ON_ERROR);
$html = '<script data-page="app" type="application/json">' . $json . '</script>' . "\n" . '<div id="app"></div>';
// Headers: Vary: X-Inertia
// Note: JSON_HEX_TAG alone is sufficient inside a <script> tag — JSON_HEX_APOS/AMP/QUOT are NOT needed.

JSON (XHR visit — X-Inertia: true header present)

$response = new JsonResponse($pageObject);
$response->headers->set('X-Inertia', 'true');
$response->headers->set('Vary', 'X-Inertia');
$response->headers->set('Content-Type', 'application/json');

Page Object (v3) — Canonical Structure

$pageObject = [
    // ALWAYS present:
    'component'      => 'User/Edit',          // string
    'props'          => ['errors' => new \stdClass(), ...], // always has 'errors' key — empty must encode as {} not []
    'url'            => '/user/123',           // RELATIVE path + query (no scheme/host)
    'version'        => '6b16b94d7c51',        // string|null

    // CONDITIONAL — omit entirely when false/empty:
    // v3 history flags: only present when true (omit when false — v3 change from v2)
    'clearHistory'   => true,
    'encryptHistory' => true,
    'preserveFragment' => true,              // NEW v3: preserve URL hash on redirect; omit when false

// Flash data: TOP-LEVEL field (NOT inside props) — v3 change from v2
    // Omit when empty; client defaults to {} when absent
    'flash'          => ['message' => 'Saved!'],

    // Shared prop keys metadata (NEW v3): list of keys that come from Inertia::share()
    // Allows client to distinguish shared vs page-specific props. Omit when empty.
    'sharedProps'    => ['auth', 'appName'],

// Other conditional fields (omit when empty):
    'deferredProps'  => ['default' => ['comments'], 'sidebar' => ['related']],
    'mergeProps'     => ['posts'],             // or ['posts.data'] for sub-path
    'prependProps'   => ['notifications'],
    'deepMergeProps' => ['conversations'],
    'matchPropsOn'   => ['posts.id', 'notifications.id'],
    'onceProps'      => ['plans' => ['prop' => 'plans', 'expiresAt' => null]],
    'scrollProps'    => ['posts' => ['pageName' => 'page', 'nextPage' => 2,
                                     'previousPage' => null, 'currentPage' => 1]],
];

Official page object examples (v3):

Basic (clearHistory/encryptHistory absent when false):

{ "component": "User/Edit", "props": { "errors": {}, "user": { "name": "Jonathan" } },
  "url": "/user/123", "version": "6b16b94d7c51cbe5b1fa42aac98241d5" }

With clearHistory:

{ "component": "User/Edit", "props": { "errors": {} }, "url": "/users", "version": null,
  "clearHistory": true }

With flash data (top-level, not in props):

{ "component": "User/Edit", "props": { "errors": {} }, "url": "/users", "version": null,
  "flash": { "message": "User created!" } }

With sharedProps:

{ "component": "Dashboard", "props": { "errors": {}, "auth": {...}, "appName": "MyApp" },
  "url": "/dashboard", "version": null, "sharedProps": ["auth", "appName"] }

With deferred props:

{ "deferredProps": { "default": ["comments", "analytics"], "sidebar": ["relatedPosts"] } }

With merge props:

{ "mergeProps": ["posts"], "prependProps": ["notifications"],
  "deepMergeProps": ["conversations"],
  "matchPropsOn": ["posts.id", "notifications.id", "conversations.data.id"] }

With scroll props:

{ "mergeProps": ["posts.data"],
  "scrollProps": { "posts": { "pageName": "page", "previousPage": null, "nextPage": 2, "currentPage": 1 } } }

With once props:

{ "onceProps": { "plans": { "prop": "plans", "expiresAt": null } } }

Asset Versioning (409)

// In kernel.request listener:
// IMPORTANT: version check must run BEFORE consuming any FlashBag keys (e.g. 'errors'),
// so that all flash data including 'errors' is still present when reflashing for the 409.
if (
    $request->isMethod('GET')
    && $isInertia
    && null !== $serverVersion
    && $clientVersion !== $serverVersion
) {
    // Reflash ALL flash data (consume + re-add) so nothing is lost across the hard reload.
    $session = $request->getSession();
    $flashBag = $session->getFlashBag();
    $flashes = $flashBag->all();  // all() consumes; peekAll() would cause duplication
    foreach ($flashes as $type => $messages) {
        foreach ($messages as $message) {
            $flashBag->add($type, $message);
        }
    }

return new Response('', 409, [
        'X-Inertia-Location' => $request->getUri(), // absolute URL — browser navigates here
    ]);
}
// Only consume 'errors' flash AFTER the 409 path has been handled.

302 → 303 Redirect Conversion

Only on PUT, PATCH, DELETE — not POST. Spec: "When redirecting after a PUT, PATCH, or DELETE request, you must use a 303 response code."

// In kernel.response listener:
if (302 === $response->getStatusCode()
    && \in_array($request->getMethod(), ['PUT', 'PATCH', 'DELETE'], true)) {
    $response->setStatusCode(303);
}

Partial Reload Filtering Rules

isPartial = (X-Inertia-Partial-Data OR X-Inertia-Partial-Except) AND component matches

On full render (not partial):
  - errors: always resolved
  - OptionalProp / optional(): SKIP
  - DeferProp: SKIP (goes to deferredProps metadata)
  - OnceProp: resolve UNLESS key in X-Inertia-Except-Once-Props
  - AlwaysProp: always resolve
  - MergeProp, ScrollProp, Closure, plain value: resolve

On partial reload:
  - errors: always included (ignores $only/$except)
  - AlwaysProp: always included (ignores $only/$except)
  - $except wins over $only when both present
  - OptionalProp: if key in $only, or $only empty (except-only reload) and key not in $except
  - DeferProp: same rule (deferred fetch = key in $only)
  - OnceProp: X-Inertia-Except-Once-Props is IGNORED on partials (only full XHR visits)
  - OnceProp: follows normal $only/$except filtering
  - MergeProp, ScrollProp: follows $only/$except filtering

409 Responses — Two Distinct Types (v3)

v3 distinguishes two kinds of 409 responses by header:

Header Client behaviour Use case
X-Inertia-Location: <absolute-url> Hard browser redirect (window.location.href) External URL, Inertia::location()
X-Inertia-Redirect: <absolute-url> Soft SPA navigation (router.visit()) Redirect with URL fragment (hash)

NEW v3 — X-Inertia-Redirect (in Laravel Middleware onRedirectWithFragment()):

// When a redirect contains a URL fragment (#hash):
return response('', 409, ['X-Inertia-Redirect' => $response->headers->get('Location')]);

The client performs a soft navigation, preserving the fragment correctly.

X-Inertia-Location (existing, hard redirect):

return response('', 409, ['X-Inertia-Location' => $request->getUri()]);

HTTP Status Codes

Code When
200 Normal response (HTML or JSON)
302 Redirect (GET requests)
303 Redirect after PUT/PATCH/DELETE
409 Version mismatch OR external redirect (X-Inertia-Location) OR fragment redirect (X-Inertia-Redirect)

Props Types Summary

Type PHP API Standard visit Partial reload Page object key
Direct value mixed ✅ Always ✅ Optionally props
Closure fn() => ... ✅ Always ✅ Optionally props
OptionalProp optional() ❌ Never ✅ If in $only, or not excluded on an except-only reload props
AlwaysProp always() ✅ Always ✅ Always props
DeferProp defer($cb, $group) ❌ Never ✅ Separate XHR deferredProps
OnceProp once() ✅ First time ❌ Skip if in X-Inertia-Except-Once-Props props + onceProps
MergeProp merge() / deepMerge() (our bundle: merge(deep:true)) ✅ Yes ✅ Yes props + mergeProps/prependProps/deepMergeProps
ScrollProp scroll() ✅ Yes ✅ Yes props + mergeProps + scrollProps

Note on our bundle API:

  • optional() is the only name; lazy() was removed in v3 (calling it is a fatal error)
  • Inertia::deepMerge() → our bundle: $inertia->merge($cb, deep: true) (no separate factory)
  • Inertia::defer()->once() → not yet supported in our bundle
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc