| name | inertia-protocol |
| description | Complete reference for the Inertia.js v3 server-side protocol. Auto-loaded when working on InertiaResponse, InertiaListener, Inertia service, or any HTTP response handling. |
Inertia.js v3 Protocol Reference
Request Detection
$isInertia = $request->headers->has('X-Inertia')
&& 'true' === $request->headers->get('X-Inertia');
$clientVersion = $request->headers->get('X-Inertia-Version');
$isPartial = $request->headers->has('X-Inertia-Partial-Component');
$partialComp = $request->headers->get('X-Inertia-Partial-Component');
$onlyProps = $request->headers->get('X-Inertia-Partial-Data');
$exceptProps = $request->headers->get('X-Inertia-Partial-Except');
$resetProps = $request->headers->get('X-Inertia-Reset');
$onceProps = $request->headers->get('X-Inertia-Except-Once-Props');
$scrollIntent = $request->headers->get('X-Inertia-Infinite-Scroll-Merge-Intent');
Response Types
HTML (first visit — no X-Inertia header)
$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>';
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 = [
'component' => 'User/Edit',
'props' => ['errors' => [], ...],
'url' => '/user/123',
'version' => '6b16b94d7c51',
'clearHistory' => true,
'encryptHistory' => true,
'preserveFragment' => true,
'flash' => ['message' => 'Saved!'],
'sharedProps' => ['auth', 'appName'],
'deferredProps' => ['default' => ['comments'], 'sidebar' => ['related']],
'mergeProps' => ['posts'],
'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)
if (
$request->isMethod('GET')
&& $isInertia
&& null !== $serverVersion
&& $clientVersion !== $serverVersion
) {
$session = $request->getSession();
$flashBag = $session->getFlashBag();
$flashes = $flashBag->all();
foreach ($flashes as $type => $messages) {
foreach ($messages as $message) {
$flashBag->add($type, $message);
}
}
return new Response('', 409, [
'X-Inertia-Location' => $request->getUri(),
]);
}
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."
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
- LazyProp / 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
- LazyProp: only if key explicitly in $only
- DeferProp: only if key explicitly in $only (deferred fetch)
- 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()):
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 |
| LazyProp | optional() (alias: lazy()) | ❌ Never | ✅ Only if in $only | 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 canonical name; lazy() is a deprecated alias — both work
Inertia::deepMerge() → our bundle: $inertia->merge($cb, deep: true) (no separate factory)
Inertia::defer()->once() → not yet supported in our bundle