Building Web Apps with Laravel Module 4 – Blade, components and the front end
Single-page apps with Inertia 3
Run Inertia 3 on Laravel 13 with React, Vue or Svelte pages, read the JSON each visit sends, build safe props and forms, and type routes with Wayfinder.
What you will learn
- Explain how an Inertia app keeps Laravel's routes, controllers and policies while React, Vue or Svelte draws each page
- Distinguish the HTML of a first visit from the JSON of an Inertia visit, and the 409 a stale asset version gets
- Decide which fields may go into props and shared data, given that every prop is readable in the page
- Handle a form's CSRF token, validation errors and success message with an ordinary Laravel route
- Use deferred and optional props and Wayfinder's generated TypeScript to keep pages fast and links correct
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how an Inertia app keeps Laravel's routes, controllers and policies while React, Vue or Svelte draws each page
- Distinguish the HTML of a first visit from the JSON of an Inertia visit, and the 409 a stale asset version gets
- Decide which fields may go into props and shared data, given that every prop is readable in the page
- Handle a form's CSRF token, validation errors and success message with an ordinary Laravel route
- Use deferred and optional props and Wayfinder's generated TypeScript to keep pages fast and links correct
Before you start
On this page
Sunita opens her son Aarav’s fee page at Pathshala, and the HTML her browser receives already holds the page’s data: 531 bytes of JSON with his three instalments. A first version of the same controller, built from whole models, sent 831 bytes, and the extra 300 included a staff note about the family that the page never draws but View Source shows. Inertia: A library that lets a server-side app such as Laravel keep its routes and controllers while React, Vue or Svelte draws each page; every response names a page component and the props it gets. 3 works this way by design: Laravel keeps its routes, controllers, policies and validation, and each response names a React, Vue or Svelte page component and the props it receives.
This lesson runs Pathshala’s guardian pages on Laravel 13.34 with the Inertia Laravel adapter 3.5.1, React 19 and Wayfinder 0.1.21, without a browser: the HTML of a first visit and the JSON of every later one, the 409 a deploy causes, a form’s errors, props that load on demand, and the TypeScript check that fails when a controller method is renamed.
Inertia keeps Laravel’s routes and controllers and replaces Blade with page components
An Inertia app still routes requests in routes/web.php, loads data in controllers, checks policies and validates input. What changes is the last line of each controller: instead of view('fees.show', …) it returns Inertia::render('Fees/Show', $props), the name of a JavaScript page component and the data that component receives as its props. Inertia’s documentation calls the library a client-side router at its core: its <Link> component and its router object turn clicks and form submits into XHR requests, and the server answers each one with a page component and its props instead of a new HTML document.
The server half is one Composer package, inertiajs/inertia-laravel, plus a middleware class that php artisan inertia:middleware writes into the app. Wayfinder, installed here as well, is the subject of the last section:
from session import run
# Pathshala as `composer create-project` leaves it, then Inertia's Laravel adapter and Wayfinder added with Composer,
# and the Inertia middleware made with Artisan. Nothing here needs npm: the React side comes later.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder
php artisan inertia:middleware
cat app/Http/Middleware/HandleInertiaRequests.php
""",
app=True,
repos=("repo-frontend",),
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder
./composer.json has been updated
Running composer update inertiajs/inertia-laravel laravel/wayfinder
Loading composer repositories with package information
Updating dependencies
Lock file operations: 3 installs, 0 updates, 0 removals
- Locking inertiajs/inertia-laravel (v3.5.1)
- Locking laravel/wayfinder (v0.1.21)
- Locking phpstan/phpdoc-parser (2.3.6)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 3 installs, 0 updates, 0 removals
- Installing inertiajs/inertia-laravel (v3.5.1): Extracting archive
- Installing phpstan/phpdoc-parser (2.3.6): Extracting archive
- Installing laravel/wayfinder (v0.1.21): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
INFO Discovering packages.
inertiajs/inertia-laravel ............................................. DONE
laravel/pail .......................................................... DONE
laravel/pao ........................................................... DONE
laravel/tinker ........................................................ DONE
laravel/wayfinder ..................................................... DONE
nesbot/carbon ......................................................... DONE
nunomaduro/collision .................................................. DONE
nunomaduro/termwind ................................................... DONE
79 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan vendor:publish --tag=laravel-assets --ansi --force
INFO No publishable resources for tag [laravel-assets].
No security vulnerability advisories found.
Using version ^3.5 for inertiajs/inertia-laravel
Using version ^0.1.21 for laravel/wayfinder
$ php artisan inertia:middleware
INFO Middleware [app/Http/Middleware/HandleInertiaRequests.php] created successfully.
$ cat app/Http/Middleware/HandleInertiaRequests.php
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
//
];
}
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 install_session.py
version() returns the asset version that the next section puts to work, and share() adds props to every page. The class goes at the end of the web group in bootstrap/app.php, after the session and CSRF middleware, and a first page load renders one root Blade view, resources/views/app.blade.php, which holds the @vite tags and <x-inertia::app />. On the browser side, React, Inertia’s React adapter @inertiajs/react and Inertia’s Vite plugin @inertiajs/vite come from npm, and createInertiaApp() in resources/js/app.tsx starts the client; the plugin finds the page components in resources/js/pages.
Pathshala’s fee page in this setup has a controller that authorises and loads the data, and a React component that draws it:
inertia-probe/app/Http/Controllers/FeeController.php
<?php
namespace App\Http\Controllers;
use App\Models\Instalment;
use App\Models\Student;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class FeeController extends Controller
{
// The fee page of one student, for that student's guardian: a Laravel route, policy and queries, as for any page.
public function show(Student $student): Response
{
Gate::authorize('view', $student);
return Inertia::render('Fees/Show', [
// Only the fields the page draws: the staff notes never leave the server.
'student' => $student->only('id', 'name', 'course'),
// A closure runs only when the response includes this prop.
'instalments' => fn () => $student->instalments()
->where('course', $student->course)
->get()
->map(fn (Instalment $i) => $i->only('id', 'label', 'amount_paise', 'receipt_no')),
// Left out of the first response; the page asks for it in a second request once it has drawn.
'reminders' => Inertia::defer(fn () => $student->reminders()->get()->map->only('channel', 'message')),
// Sent only when the page asks for it by name, after a click on "Earlier courses".
'earlierCourses' => Inertia::optional(fn () => $student->instalments()
->where('course', '!=', $student->course)
->get()
->map(fn (Instalment $i) => $i->only('course', 'label', 'amount_paise', 'receipt_no'))),
]);
}
} pages/Fees/Show.tsx · inertia-probe/resources/js/pages/Fees/Show.tsx
import { Deferred, Head, Link, router } from '@inertiajs/react';
import { edit } from '@/actions/App/Http/Controllers/ContactController';
type Instalment = { id: number; label: string; amount_paise: number; receipt_no: string | null };
type EarlierInstalment = Omit<Instalment, 'id'> & { course: string };
type Props = {
student: { id: number; name: string; course: string };
instalments: Instalment[];
reminders?: { channel: string; message: string }[]; // deferred: arrives after the first paint
earlierCourses?: EarlierInstalment[]; // optional: arrives only when asked for
};
const rupees = (paise: number) =>
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(paise / 100);
export default function Show({ student, instalments, reminders, earlierCourses }: Props) {
return (
<main>
<Head title={`Fees of ${student.name}`} />
<h1>Fees of {student.name}</h1>
<table>
<caption>{student.course}</caption>
<thead>
<tr>
<th scope="col">Instalment</th>
<th scope="col">Amount</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{instalments.map((i) => (
<tr key={i.id}>
<th scope="row">{i.label}</th>
<td>{rupees(i.amount_paise)}</td>
<td>{i.receipt_no ? `Paid, receipt ${i.receipt_no}` : 'Due'}</td>
</tr>
))}
</tbody>
</table>
<h2>Reminders sent</h2>
<Deferred data="reminders" fallback={<p>Loading reminders…</p>}>
<ul>
{reminders?.map((r, n) => (
<li key={n}>
{r.channel}: {r.message}
</li>
))}
</ul>
</Deferred>
<h2>Earlier courses</h2>
{earlierCourses ? (
<ul>
{earlierCourses.map((i, n) => (
<li key={n}>
{i.course}, {i.label}: {rupees(i.amount_paise)}
</li>
))}
</ul>
) : (
<button type="button" onClick={() => router.reload({ only: ['earlierCourses'] })}>
Show earlier courses
</button>
)}
<p>
<Link href={edit()}>Update your contact number</Link>
</p>
</main>
);
} routes/web.php · inertia-probe/routes/web.php
<?php
use App\Http\Controllers\ContactController;
use App\Http\Controllers\FeeController;
use App\Http\Controllers\Lab\LabFeeController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// Pathshala's guardian pages: Laravel routes, middleware and controllers, with React drawing each page.
Route::middleware('auth')->group(function () {
Route::get('/students/{student}/fees', [FeeController::class, 'show'])->name('fees.show');
Route::get('/guardian/contact', [ContactController::class, 'edit'])->name('contact.edit');
Route::put('/guardian/contact', [ContactController::class, 'update'])->name('contact.update');
// The lab's weak copy (folder props-lab) exists only in sessions that copy that folder in.
if (class_exists(LabFeeController::class)) {
Route::get('/lab/students/{student}/fees', [LabFeeController::class, 'show']);
}
}); views/app.blade.php · inertia-probe/resources/views/app.blade.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- The app's entry and the chunk of the page being visited, so the browser fetches both at once. --}}
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
<x-inertia::head>
<title>Pathshala</title>
</x-inertia::head>
</head>
<body>
<x-inertia::app />
</body>
</html> js/app.tsx · inertia-probe/resources/js/app.tsx
import { createInertiaApp } from '@inertiajs/react';
// @inertiajs/vite finds the page components in resources/js/pages and mounts the app on <div id="app">.
void createInertiaApp({
title: (title) => (title ? `${title} - Pathshala` : 'Pathshala'),
strictMode: true,
}); inertia-probe/app/Http/Middleware/HandleInertiaRequests.php
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
// Made by `php artisan inertia:middleware`, then edited: every page gets the signed-in user's id and name, and nothing
// else of the user row.
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function version(Request $request): ?string
{
return parent::version($request);
}
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => fn () => $request->user()?->only('id', 'name'),
],
];
}
} bootstrap/app.php · inertia-probe/bootstrap/app.php
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Inertia's middleware runs on every web route, after the session and CSRF middleware of the group.
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create(); inertia-probe/vite.config.js
import inertia from '@inertiajs/vite';
import react from '@vitejs/plugin-react';
import laravel from 'laravel-vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.tsx'],
refresh: true,
}),
inertia(),
react(),
],
build: {
rolldownOptions: {
// No warning about how long plugins took: its numbers change from one build to the next.
checks: { bundlerTimings: false },
},
},
}); | Job | Where it lives in an Inertia app |
|---|---|
| Routing, middleware, sessions, CSRF | Laravel, unchanged |
| Loading data, policies, validation | the controller, as for a Blade page |
| HTML for the page | the page component in resources/js/pages |
| Moving between pages | Inertia’s <Link>, <Form> and router |
The Laravel side is the same whichever adapter draws the pages; only the page files and one npm package change. React pages, through @inertiajs/react, are function components that receive the props as their argument, and Inertia 3 needs React 19 or later. Vue pages, through @inertiajs/vue3, are single-file components that declare their props with defineProps(), and the Vue starter kit writes them with the Composition API. Svelte pages, through @inertiajs/svelte, read their props with the $props() rune, because Inertia 3 requires Svelte 5.
Laravel’s Inertia starter kits come in the same three flavours, each built on Inertia 3 with TypeScript and Tailwind: React 19 with shadcn/ui, Vue 3’s Composition API with shadcn-vue, and Svelte 5 with shadcn-svelte. This lesson’s pages are React with TypeScript, and every request and response it shows would be identical with Vue or Svelte pages.
The first visit returns an HTML document, every later visit the same page object as JSON
A page reached by typing its address, or reloaded, arrives as a full HTML document: the root view’s asset tags, a <script data-page="app" type="application/json"> element that holds the page object: The JSON Inertia sends for every page: the component's name, its props, the URL and the asset version. A first visit embeds it in the HTML; later visits receive it alone., and an empty <div id="app"> for the client to mount on. Every later navigation is an Inertia visit: A navigation that Inertia's client sends with XHR and the X-Inertia header instead of loading a new document; the server answers with the page object as JSON.: an XHR request with the header X-Inertia: true, which Laravel’s adapter answers with the page object alone, as JSON. The client then swaps the page component and updates the browser’s history.
The session builds the React side with the pinned Vite and Inertia packages, then browser.php plays one of Sunita’s tabs through Laravel’s HTTP kernel: a full page load of Aarav’s fee page, then the same page again the way a <Link> click asks for it:
inertia-probe/browser.php
<?php
// Plays one browser tab with Inertia's client in it, through Laravel's HTTP kernel (as public/index.php does for a web
// server), signed in as the guardian whose email comes first:
// php browser.php <email> [--tab=<file>] [--brief] [--find=<text>] '<step>' ['<step>' ...]
// Steps:
// GET <path> a full page load (typing the address): Laravel answers with the HTML document
// VISIT <path> an Inertia visit (a <Link> click): X-Inertia and the tab's asset version
// RELOAD <prop,...> a partial reload of the page on screen that asks for those props only
// PUT <path> <json> a <Form> submit: the XSRF-TOKEN cookie's value goes in the X-XSRF-TOKEN header
// PUT! <path> <json> the same request without that header
// Redirects are followed the way the browser follows them for Inertia's request: same headers, method GET.
// --tab=<file> keeps the tab's cookies and page between runs, as a tab left open during a deploy keeps them.
// Each response prints its status, its Inertia headers, the tables the request read or wrote and the page object
// (--brief: only the names of its props); --find=<text> says whether the body holds that text.
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$kernel->bootstrap();
$args = array_slice($argv, 1);
$email = array_shift($args);
$tabFile = null;
$brief = false;
$find = null;
while (str_starts_with($args[0] ?? '', '--')) {
$option = array_shift($args);
if (str_starts_with($option, '--tab=')) {
$tabFile = substr($option, 6);
} elseif ($option === '--brief') {
$brief = true;
} elseif (str_starts_with($option, '--find=')) {
$find = substr($option, 7);
}
}
$tab = $tabFile && is_file($tabFile) ? json_decode(file_get_contents($tabFile), true) : ['cookies' => [], 'page' => null];
$tables = [];
DB::listen(function ($query) use (&$tables) {
if (preg_match('/^select\b.*?\bfrom\s+"(\w+)"/is', $query->sql, $m)) {
$table = $m[1];
} elseif (preg_match('/^(insert into|update|delete from)\s+"(\w+)"/i', $query->sql, $m)) {
$table = strtok(strtolower($m[1]), ' ').' '.$m[2];
} else {
return;
}
if (! str_ends_with($table, 'sessions')) { // the session store's own reads and writes are left out
$tables[] = $table;
}
});
$first = true;
$send = function (string $method, string $path, array $headers, ?string $body) use ($app, $kernel, $email, &$tab, &$tables, &$first) {
$server = [];
foreach ($headers as $name => $value) {
$server['HTTP_'.strtoupper(str_replace('-', '_', $name))] = $value;
}
if ($body !== null) {
$server['CONTENT_TYPE'] = 'application/json';
}
$request = Request::create('https://pathshala.example'.$path, $method, [], $tab['cookies'], [], $server, $body);
$app->instance('request', $request);
if ($first) {
Auth::setUser(User::where('email', $email)->firstOrFail()); // signed in, as after a login
$first = false;
}
$tables = [];
$response = $kernel->handle($request);
foreach ($response->headers->getCookies() as $cookie) {
$tab['cookies'][$cookie->getName()] = $cookie->getValue();
}
$kernel->terminate($request, $response);
return $response;
};
$lastJson = null;
$show = function (string $label, $response) use (&$tab, &$tables, &$lastJson, $brief, $find) {
$status = $response->getStatusCode();
$content = (string) $response->getContent();
$line = "{$label} -> HTTP {$status}";
foreach (['Content-Type', 'X-Inertia', 'Vary', 'Location', 'X-Inertia-Location', 'X-Inertia-Version'] as $h) {
if ($response->headers->has($h)) {
$line .= ", {$h}: ".$response->headers->get($h);
}
}
echo $line."\n body ".number_format(strlen($content)).' bytes; tables read or written: '.($tables ? implode(', ', $tables) : 'none')."\n";
if ($find !== null) {
echo " \"{$find}\" in the body: ".(str_contains($content, $find) ? 'yes' : 'no')."\n";
}
$json = null;
if (str_starts_with((string) $response->headers->get('Content-Type'), 'text/html')) {
// Every <script> and <link> of the document that loads a file: Vite's preloads, entry scripts and styles.
preg_match_all('~<(script|link)\b([^>]*)>~', $content, $tags, PREG_SET_ORDER);
foreach ($tags as [, $tag, $attrs]) {
if (preg_match('~\b(?:src|href)="([^"]+)"~', $attrs, $url) && preg_match('~\b(?:rel|type)="[^"]+"~', $attrs, $kind)) {
echo " <{$tag} {$kind[0]}> ".preg_replace('~^https://pathshala\.example~', '', $url[1])."\n";
}
}
if (preg_match('~<script data-page="app" type="application/json">(.*?)</script>\s*<div id="app"></div>~s', $content, $m)) {
echo ' page object in <script data-page="app" type="application/json">, '.strlen($m[1])." bytes, then <div id=\"app\"></div>\n";
$json = $m[1];
}
} elseif ($response->headers->get('X-Inertia') === 'true') {
echo " page object: the whole body\n";
$json = $content;
}
if ($json === null) {
return;
}
$tab['page'] = json_decode($json, true);
$page = json_decode($json); // objects stay objects, so an empty {} prints as {}
if ($brief) {
echo ' props: '.implode(', ', array_keys((array) $page->props))."\n";
foreach (['deferredProps', 'flash'] as $key) {
if (isset($page->{$key})) {
echo " {$key}: ".json_encode($page->{$key}, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n";
}
}
if ((array) $page->props->errors) {
echo ' errors: '.json_encode($page->props->errors, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n";
}
echo " version: {$page->version}\n";
} elseif ($json === $lastJson) {
echo " (the same page object as above, byte for byte)\n";
} else {
echo preg_replace('/^/m', ' ', json_encode($page, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))."\n";
}
$lastJson = $json;
};
$inertiaHeaders = function () use (&$tab) {
$headers = [
'Accept' => 'text/html, application/xhtml+xml',
'X-Requested-With' => 'XMLHttpRequest',
'X-Inertia' => 'true',
'X-Inertia-Version' => $tab['page']['version'] ?? '',
];
if (isset($tab['page']['url'])) {
$headers['Referer'] = 'https://pathshala.example'.$tab['page']['url']; // the page on screen, as browsers send it
}
return $headers;
};
foreach ($args as $step) {
[$verb, $rest] = explode(' ', $step, 2) + [1 => ''];
if ($verb === 'GET') {
$show("GET {$rest}", $send('GET', $rest, ['Accept' => 'text/html,application/xhtml+xml'], null));
continue;
}
if ($verb === 'VISIT') {
$headers = $inertiaHeaders();
$show("VISIT {$rest} (X-Inertia-Version: ".($headers['X-Inertia-Version'] ?: 'empty').')', $send('GET', $rest, $headers, null));
continue;
}
if ($verb === 'RELOAD') {
$headers = $inertiaHeaders() + [
'X-Inertia-Partial-Component' => $tab['page']['component'],
'X-Inertia-Partial-Data' => $rest,
];
$show("RELOAD {$tab['page']['url']} (X-Inertia-Partial-Data: {$rest})", $send('GET', $tab['page']['url'], $headers, null));
continue;
}
// PUT or PUT!: a form submit, then the redirect it answers with, followed as GET.
[$path, $body] = explode(' ', $rest, 2);
$headers = $inertiaHeaders();
if ($verb === 'PUT') {
$headers['X-XSRF-TOKEN'] = $tab['cookies']['XSRF-TOKEN'] ?? '';
}
$label = "{$verb} {$path} {$body}".($verb === 'PUT' ? ' (X-XSRF-TOKEN from the cookie)' : ' (no X-XSRF-TOKEN)');
$response = $send('PUT', $path, $headers, $body);
$show($label, $response);
while ($response->isRedirection()) {
$next = parse_url($response->headers->get('Location'), PHP_URL_PATH);
$response = $send('GET', $next, $inertiaHeaders(), null);
$show(" followed: GET {$next}", $response);
}
}
if ($tabFile) {
file_put_contents($tabFile, json_encode($tab));
} inertia-probe/database/seeders/FeeSeeder.php
<?php
namespace Database\Seeders;
use App\Models\Instalment;
use App\Models\Reminder;
use App\Models\Student;
use App\Models\User;
use Illuminate\Database\Seeder;
// Two fictional guardians, a student each, and the student's instalments and reminders.
class FeeSeeder extends Seeder
{
public function run(): void
{
$sunita = User::create(['name' => 'Sunita Rao', 'email' => 'sunita@pathshala.example', 'password' => 'unused', 'phone' => '9876543210']);
$ravi = User::create(['name' => 'Ravi Menon', 'email' => 'ravi@pathshala.example', 'password' => 'unused']);
$aarav = Student::create([
'guardian_id' => $sunita->id,
'name' => 'Aarav Rao',
'course' => 'Python, evening batch',
'staff_note' => 'Sibling discount in place; fees often late, phone the guardian first',
]);
Student::create(['guardian_id' => $ravi->id, 'name' => 'Kabir Menon', 'course' => 'Spoken English, Saturday']);
foreach ([['Term 1', 'PSL-2041'], ['Term 2', null], ['Term 3', null]] as [$label, $receipt]) {
Instalment::create([
'student_id' => $aarav->id, 'course' => 'Python, evening batch', 'label' => $label,
'amount_paise' => 850000, 'receipt_no' => $receipt,
'staff_note' => $label === 'Term 2' ? 'Promised to pay in cash at the front desk' : null,
]);
}
Instalment::create([
'student_id' => $aarav->id, 'course' => 'Spoken English, Saturday', 'label' => 'Full course',
'amount_paise' => 600000, 'receipt_no' => 'PSL-1877',
]);
Reminder::create(['student_id' => $aarav->id, 'channel' => 'sms', 'message' => 'Term 2 fee of Rs 8,500 is due.']);
Reminder::create(['student_id' => $aarav->id, 'channel' => 'email', 'message' => 'Term 2 fee of Rs 8,500 is due.']);
}
} the migration · inertia-probe/database/migrations/0001_01_01_000003_create_fee_tables.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
// Pathshala's fee records: a guardian (a user) has students, a student has instalments and gets fee reminders.
// The two staff_note columns are for the centre's staff only: a guardian's page must never carry them.
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 10)->nullable();
});
Schema::create('students', function (Blueprint $table) {
$table->id();
$table->foreignId('guardian_id')->constrained('users');
$table->string('name');
$table->string('course');
$table->text('staff_note')->nullable();
});
Schema::create('instalments', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained();
$table->string('course');
$table->string('label');
$table->unsignedInteger('amount_paise');
$table->string('receipt_no')->nullable(); // set when the instalment is paid
$table->text('staff_note')->nullable();
});
Schema::create('reminders', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained();
$table->string('channel');
$table->string('message');
});
}
}; from session import run
# Pathshala with Inertia and the fee page (inertia-probe). Wayfinder writes the TypeScript the pages import, vite
# builds the React side with the course's pinned packages, then browser.php plays one tab of Sunita's: she types the
# address of her son Aarav's fee page, then comes back to it the way a <Link> click does. The build's time changes from
# run to run, so it is shown as "…".
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan migrate --quiet
php artisan db:seed --class=FeeSeeder --quiet
php artisan wayfinder:generate --path=resources/js
npx vite build
php browser.php sunita@pathshala.example 'GET /students/1/fees' 'VISIT /students/1/fees'
""",
app=True,
overlay=("inertia-probe",),
repos=("repo-frontend",),
tools=("node",),
node_modules="inertia-react-3.9.1",
masks=[(r"built in (?:\d+m )?(?:[\d.]+m?s|…s)", "built in …")],
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan migrate --quiet
$ php artisan db:seed --class=FeeSeeder --quiet
$ php artisan wayfinder:generate --path=resources/js
[Wayfinder] Generated actions in resources/js/actions
[Wayfinder] Generated routes in resources/js/routes
$ npx vite build
vite v8.3.4 building client environment for production...
transforming...
✓ 569 modules transformed.
rendering chunks...
computing gzip size...
public/build/manifest.json 0.96 kB │ gzip: 0.28 kB
public/build/assets/Edit-Bqq2fqNa.js 0.82 kB │ gzip: 0.45 kB
public/build/assets/Show-CZci4581.js 1.55 kB │ gzip: 0.66 kB
public/build/assets/jsx-runtime-Bj1xau3R.js 1.57 kB │ gzip: 0.83 kB
public/build/assets/app-DCX-Qj4m.js 349.36 kB │ gzip: 109.38 kB
✓ built in …
$ php browser.php sunita@pathshala.example 'GET /students/1/fees' 'VISIT /students/1/fees'
GET /students/1/fees -> HTTP 200, Content-Type: text/html; charset=utf-8, Vary: X-Inertia
body 1,456 bytes; tables read or written: students, instalments
<link rel="modulepreload"> /build/assets/app-DCX-Qj4m.js
<link rel="modulepreload"> /build/assets/Show-CZci4581.js
<link rel="modulepreload"> /build/assets/jsx-runtime-Bj1xau3R.js
<script type="module"> /build/assets/app-DCX-Qj4m.js
<script type="module"> /build/assets/Show-CZci4581.js
page object in <script data-page="app" type="application/json">, 531 bytes, then <div id="app"></div>
{
"component": "Fees/Show",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"student": {
"id": 1,
"name": "Aarav Rao",
"course": "Python, evening batch"
},
"instalments": [
{
"id": 1,
"label": "Term 1",
"amount_paise": 850000,
"receipt_no": "PSL-2041"
},
{
"id": 2,
"label": "Term 2",
"amount_paise": 850000,
"receipt_no": null
},
{
"id": 3,
"label": "Term 3",
"amount_paise": 850000,
"receipt_no": null
}
]
},
"url": "/students/1/fees",
"version": "308fc413a6bfcfcfa55befa637e37788",
"sharedProps": [
"errors",
"auth"
],
"deferredProps": {
"default": [
"reminders"
]
}
}
VISIT /students/1/fees (X-Inertia-Version: 308fc413a6bfcfcfa55befa637e37788) -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 531 bytes; tables read or written: students, instalments
page object: the whole body
(the same page object as above, byte for byte)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 visits_session.py
The build split the client into an app chunk of 349.36 kB, 109.38 kB gzipped, which holds React and Inertia’s client, one small chunk per page, 1.55 kB for Fees/Show and 0.82 kB for Contact/Edit, and a 1.57 kB chunk of React’s JSX runtime that both pages import. The first visit’s HTML is 1,456 bytes. It runs two of those files as module scripts, the app chunk and the chunk of the page it shows, because the root view passes the page’s own file to @vite, and Laravel’s Vite helper adds a modulepreload link for each of them and for the JSX runtime, so the browser fetches all three at once. The page object inside it is 531 bytes, which browser.php prints indented, and the Inertia visit’s whole body is the same 531 bytes, byte for byte.
Four fields of that object are on every response: component, props, url and version. errors is in the props although the controller never set it: Inertia’s middleware shares it with every page, so a form can always read it. sharedProps lists the props that came from the middleware, and deferredProps announces one prop that this response left out, which the section on partial reloads follows up. Both responses carry Vary: X-Inertia, so a browser or a cache keeps the HTML and the JSON of one URL apart.
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Typing the address. GET /students/1/fees leaves as a plain page load, with no Inertia headers.
- An HTML document comes back. 1,456 bytes: preload links and module scripts for the app and Fees/Show chunks, a script element holding the 531-byte page object, and an empty div with the id app.
- The client starts. createInertiaApp() reads the page object, loads the Fees/Show chunk and draws the instalments table into the empty div.
- A link click. Inertia sends the next request with XHR, with the X-Inertia header and the version the page holds, 308fc413 and so on.
- JSON only. The body is the same 531-byte page object, byte for byte, and nothing else. The client swaps the component and updates the history.
- A deploy. A one-word change and a new build rename all four chunks. The xxh128 hash of the new manifest, and so the new version, starts 2f1c39bf.
- The old tab clicks again. The visit still carries version 308fc413. Laravel answers 409 with an empty body and X-Inertia-Location, the address to load in full.
- A full page load. The client sets window.location to that address. The new HTML names the new chunks and carries version 2f1c39bf.
In Inertia 3 the page object always travels in that script element; the data-page attribute of earlier versions is gone, as the upgrade guide notes. The protocol requires every / in the embedded JSON to be escaped as \/, so a </script> inside a prop cannot end the element early. The empty <div> stays empty until the JavaScript runs, unless server-side rendering: Running a JavaScript page component on the server to produce the first visit's HTML, so the page shows content before its scripts run; with Inertia it needs a separate Node process in production. is switched on: then a Node process renders the first visit’s markup, which in production php artisan inertia:start-ssr starts and in development Inertia 3’s Vite plugin handles by itself.
The version field is what makes deploys safe. Laravel’s adapter takes the xxh128 hash of public/build/manifest.json, the client sends the version of the page on screen in X-Inertia-Version, and on a GET that differs the middleware answers 409 Conflict with X-Inertia-Location, the address for a full page load that fetches the new files. A tab left open across a deploy shows it:
from session import run
# A deploy while Sunita's tab is open. The first build's manifest gives the asset version her tab holds (tab.json keeps
# the tab's cookies and page between the two browser.php runs). Then one word of the fee page changes, a second build
# writes a new manifest (ls shows the files each build wrote), and her next click on a link carries the old version.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan migrate --quiet
php artisan db:seed --class=FeeSeeder --quiet
php artisan wayfinder:generate --path=resources/js --quiet
npx vite build --logLevel=warn
ls public/build/assets
php -r "echo 'xxh128 of the manifest: ', hash_file('xxh128', 'public/build/manifest.json'), PHP_EOL;"
php browser.php sunita@pathshala.example --tab=tab.json --brief 'GET /students/1/fees'
perl -pi -e 's/Update your contact number/Change your contact number/' resources/js/pages/Fees/Show.tsx
npx vite build --logLevel=warn
ls public/build/assets
php -r "echo 'xxh128 of the manifest: ', hash_file('xxh128', 'public/build/manifest.json'), PHP_EOL;"
php browser.php sunita@pathshala.example --tab=tab.json --brief 'VISIT /guardian/contact' 'GET /guardian/contact'
""",
app=True,
overlay=("inertia-probe",),
repos=("repo-frontend",),
tools=("node",),
node_modules="inertia-react-3.9.1",
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan migrate --quiet
$ php artisan db:seed --class=FeeSeeder --quiet
$ php artisan wayfinder:generate --path=resources/js --quiet
$ npx vite build --logLevel=warn
$ ls public/build/assets
app-DCX-Qj4m.js
Edit-Bqq2fqNa.js
jsx-runtime-Bj1xau3R.js
Show-CZci4581.js
$ php -r "echo 'xxh128 of the manifest: ', hash_file('xxh128', 'public/build/manifest.json'), PHP_EOL;"
xxh128 of the manifest: 308fc413a6bfcfcfa55befa637e37788
$ php browser.php sunita@pathshala.example --tab=tab.json --brief 'GET /students/1/fees'
GET /students/1/fees -> HTTP 200, Content-Type: text/html; charset=utf-8, Vary: X-Inertia
body 1,456 bytes; tables read or written: students, instalments
<link rel="modulepreload"> /build/assets/app-DCX-Qj4m.js
<link rel="modulepreload"> /build/assets/Show-CZci4581.js
<link rel="modulepreload"> /build/assets/jsx-runtime-Bj1xau3R.js
<script type="module"> /build/assets/app-DCX-Qj4m.js
<script type="module"> /build/assets/Show-CZci4581.js
page object in <script data-page="app" type="application/json">, 531 bytes, then <div id="app"></div>
props: errors, auth, student, instalments
deferredProps: {"default":["reminders"]}
version: 308fc413a6bfcfcfa55befa637e37788
$ perl -pi -e 's/Update your contact number/Change your contact number/' resources/js/pages/Fees/Show.tsx
$ npx vite build --logLevel=warn
$ ls public/build/assets
app-DqGWnJ1d.js
Edit-C9e15Xqi.js
jsx-runtime-CVvp3GFX.js
Show-DTat5MpE.js
$ php -r "echo 'xxh128 of the manifest: ', hash_file('xxh128', 'public/build/manifest.json'), PHP_EOL;"
xxh128 of the manifest: 2f1c39bfb90b95859a81c49c0a2e2240
$ php browser.php sunita@pathshala.example --tab=tab.json --brief 'VISIT /guardian/contact' 'GET /guardian/contact'
VISIT /guardian/contact (X-Inertia-Version: 308fc413a6bfcfcfa55befa637e37788) -> HTTP 409, Content-Type: text/html; charset=utf-8, X-Inertia-Location: https://pathshala.example/guardian/contact, X-Inertia-Version: 2f1c39bfb90b95859a81c49c0a2e2240
body 0 bytes; tables read or written: none
GET /guardian/contact -> HTTP 200, Content-Type: text/html; charset=utf-8, Vary: X-Inertia
body 1,147 bytes; tables read or written: none
<link rel="modulepreload"> /build/assets/app-DqGWnJ1d.js
<link rel="modulepreload"> /build/assets/Edit-C9e15Xqi.js
<link rel="modulepreload"> /build/assets/jsx-runtime-CVvp3GFX.js
<script type="module"> /build/assets/app-DqGWnJ1d.js
<script type="module"> /build/assets/Edit-C9e15Xqi.js
page object in <script data-page="app" type="application/json">, 222 bytes, then <div id="app"></div>
props: errors, auth, phone
version: 2f1c39bfb90b95859a81c49c0a2e2240
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 deploy_session.py
One word changed in the fee page, and the second build renamed all four files. Show changed, so its name changed; the app chunk loads each page by file name, so it changed too; and Edit and the JSX runtime import the app chunk by name, so they followed. The manifest’s hash moved from 308fc413… to 2f1c39bf…, the open tab’s next click got a 409 with an empty body, and the full page load that followed brought the new chunks and the new version. A version that changes in a background request, such as polling or a reload of props, does not force that full load, so a person does not lose what they were typing.
Checkpoint
Check: What Laravel keeps in an Inertia app · First visits, Inertia visits and versions
Every prop is readable in the page, so build props from an allowlist
The component that receives a prop does not hide it. Whatever a controller passes to Inertia::render() goes into the page object, so it sits in the first visit’s HTML, in the JSON of every visit and in the page’s entry in the browser’s history, where View Source, the network panel or window.history.state shows it. A field the React page never draws is delivered all the same.
The lab folder holds a weak copy of the fee controller that passes whole Eloquent models, the way a first version is often written. The session loads both pages as Sunita and searches each HTML document for the words of the staff note on Aarav’s record:
LAB: LabFeeController.php · props-lab/app/Http/Controllers/Lab/LabFeeController.php
<?php
namespace App\Http\Controllers\Lab;
use App\Http\Controllers\Controller;
use App\Models\Student;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
// LAB ONLY, never ship it: the same page fed with whole models, as a first version often is. The page draws the same
// table, but every column travels to the browser, the two staff notes among them.
class LabFeeController extends Controller
{
public function show(Student $student): Response
{
Gate::authorize('view', $student);
return Inertia::render('Fees/Show', [
'student' => $student,
'instalments' => $student->instalments()->where('course', $student->course)->get(),
]);
}
} inertia-probe/app/Models/Student.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
// A student of Pathshala and the course they are in now; guardian_id is the user who pays the fees.
#[Fillable(['guardian_id', 'name', 'course', 'staff_note'])]
class Student extends Model
{
public $timestamps = false;
public function instalments(): HasMany
{
return $this->hasMany(Instalment::class);
}
public function reminders(): HasMany
{
return $this->hasMany(Reminder::class);
}
} inertia-probe/app/Policies/StudentPolicy.php
<?php
namespace App\Policies;
use App\Models\Student;
use App\Models\User;
// A guardian sees the fee page of their own students only.
class StudentPolicy
{
public function view(User $user, Student $student): bool
{
return $student->guardian_id === $user->id;
}
} from session import run
# The fee page and the lab's weak copy of its controller (props-lab), each loaded by typing its address, as Sunita
# would; --find looks for the words of the staff note on Aarav's record in each HTML document. The build's time changes
# from run to run, so it is shown as "…".
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan migrate --quiet
php artisan db:seed --class=FeeSeeder --quiet
php artisan wayfinder:generate --path=resources/js --quiet
npx vite build --logLevel=warn
php browser.php sunita@pathshala.example '--find=phone the guardian first' 'GET /students/1/fees' 'GET /lab/students/1/fees'
""",
app=True,
overlay=("inertia-probe", "props-lab"),
repos=("repo-frontend",),
tools=("node",),
node_modules="inertia-react-3.9.1",
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan migrate --quiet
$ php artisan db:seed --class=FeeSeeder --quiet
$ php artisan wayfinder:generate --path=resources/js --quiet
$ npx vite build --logLevel=warn
$ php browser.php sunita@pathshala.example '--find=phone the guardian first' 'GET /students/1/fees' 'GET /lab/students/1/fees'
GET /students/1/fees -> HTTP 200, Content-Type: text/html; charset=utf-8, Vary: X-Inertia
body 1,456 bytes; tables read or written: students, instalments
"phone the guardian first" in the body: no
<link rel="modulepreload"> /build/assets/app-DCX-Qj4m.js
<link rel="modulepreload"> /build/assets/Show-CZci4581.js
<link rel="modulepreload"> /build/assets/jsx-runtime-Bj1xau3R.js
<script type="module"> /build/assets/app-DCX-Qj4m.js
<script type="module"> /build/assets/Show-CZci4581.js
page object in <script data-page="app" type="application/json">, 531 bytes, then <div id="app"></div>
{
"component": "Fees/Show",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"student": {
"id": 1,
"name": "Aarav Rao",
"course": "Python, evening batch"
},
"instalments": [
{
"id": 1,
"label": "Term 1",
"amount_paise": 850000,
"receipt_no": "PSL-2041"
},
{
"id": 2,
"label": "Term 2",
"amount_paise": 850000,
"receipt_no": null
},
{
"id": 3,
"label": "Term 3",
"amount_paise": 850000,
"receipt_no": null
}
]
},
"url": "/students/1/fees",
"version": "308fc413a6bfcfcfa55befa637e37788",
"sharedProps": [
"errors",
"auth"
],
"deferredProps": {
"default": [
"reminders"
]
}
}
GET /lab/students/1/fees -> HTTP 200, Content-Type: text/html; charset=utf-8, Vary: X-Inertia
body 1,756 bytes; tables read or written: students, instalments
"phone the guardian first" in the body: yes
<link rel="modulepreload"> /build/assets/app-DCX-Qj4m.js
<link rel="modulepreload"> /build/assets/Show-CZci4581.js
<link rel="modulepreload"> /build/assets/jsx-runtime-Bj1xau3R.js
<script type="module"> /build/assets/app-DCX-Qj4m.js
<script type="module"> /build/assets/Show-CZci4581.js
page object in <script data-page="app" type="application/json">, 831 bytes, then <div id="app"></div>
{
"component": "Fees/Show",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"student": {
"id": 1,
"guardian_id": 1,
"name": "Aarav Rao",
"course": "Python, evening batch",
"staff_note": "Sibling discount in place; fees often late, phone the guardian first"
},
"instalments": [
{
"id": 1,
"student_id": 1,
"course": "Python, evening batch",
"label": "Term 1",
"amount_paise": 850000,
"receipt_no": "PSL-2041",
"staff_note": null
},
{
"id": 2,
"student_id": 1,
"course": "Python, evening batch",
"label": "Term 2",
"amount_paise": 850000,
Text longer than 4,000 characters is cut here; run the example yourself to see all of it.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 props_session.py
Both pages draw the same table, yet the lab copy’s page object grew from 531 to 831 bytes. student gained guardian_id and the note “Sibling discount in place; fees often late, phone the guardian first”, and each instalment gained student_id, course and a staff_note of its own, one of which records that the Term 2 fee was promised in cash at the front desk. Gate::authorize('view', $student) ran in both controllers, so the visitor is the right guardian: what leaked is the centre’s internal notes, which no guardian should read. Pasting the two page objects into the JSON diff tool lists every key the lab copy added.
The shipped controller builds each prop from the fields its page draws: $student->only('id', 'name', 'course'), and only('id', 'label', 'amount_paise', 'receipt_no') for each instalment. An API resource: A class that converts an Eloquent model into the array an API returns, hiding fields the client does not need and reshaping the ones it does. More in “API resources, pagination and JSON:API” does the same job for a record that many pages send. Eloquent’s hidden list, $hidden or the #[Hidden] attribute that this app’s User model uses, works the other way round, as a denylist: A list of what is refused, which accepts everything nobody thought to list. Useful as a second layer, unsafe as the only check on input. More in “Validation rules and error messages”: it keeps password out of a user’s array, but a column added later reaches the page unless someone remembers to list it.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Prop built from | Internal notes stay out | A column added later | Same fields on many pages | Code to write and keep |
|---|---|---|---|---|
| the whole model What reaches the page: every column, staff_note too | Poor sent: 831 bytes, the staff note in the HTML | Poor reaches the page at once | Mixed the same everywhere, because it is everything | Good none |
| the model with $hidden What reaches the page: every column not on the list | Mixed out only if someone listed the column | Poor sent until it joins the list | Mixed one list for every page and API reply | Good one list on the model |
| $student->only(…) What reaches the page: only the fields it names | Good out unless named: 531 bytes | Good stays out until a controller names it | Mixed each controller repeats its list | Good one call per prop |
| an API resource What reaches the page: what its toArray() returns | Good out unless toArray() returns it | Good stays out until the class adds it | Good one class for every page that sends it | Mixed a class to write and keep |
How the answers decide
- “Does the table hold columns the person viewing the page must not read, such as staff notes?” Yes, some columns: rules out the whole model and adds a caveat to the model with $hidden. The whole model sends every column, as the lab copy did. A hidden list keeps out only the columns someone remembered to add to it.
- “Does the table hold columns the person viewing the page must not read, such as staff notes?” No, all of it is theirs: adds a caveat to the whole model. With nothing private today, a whole model still sends every column added later; an allowlist keeps the page object to what the page draws.
- “Do several pages or an API send this record with the same fields?” Yes, several: adds a caveat to $student->only(…). Several controllers that repeat the same only() list drift apart; one API resource keeps the fields in one place.
- “Do several pages or an API send this record with the same fields?” No, one page: adds a caveat to an API resource. For a single page, a resource class is extra code to keep; an only() call beside the render says the same.
Key idea
A prop is not a private input to a component: it is data sent to the browser, in the HTML, in every visit’s JSON and in the history. Build each prop from the fields its page draws, and anything the person may not read stays on the server.
shared data: Props that Inertia's middleware adds to every page's response, such as the signed-in user's name or the validation errors, defined in its share() method. needs the same care, because share() adds its props to every page. Pathshala’s middleware shares the signed-in user as only('id', 'name'). The React starter kit’s middleware shares $request->user() whole, so in an app made from it the user model’s $hidden list decides what every page carries, and each new column of the users table deserves a look.
A test keeps the allowlist in place. assertInertia() reads the page object of a test response, and has('student', fn …) with a closure fails when that scope holds a field the closure never named:
inertia-tests/tests/Feature/PropsAllowlistTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\FeeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
// One check, run on the shipped controller and on the lab's weak copy: the fee page's student and instalment props
// hold exactly the fields the page draws. A closure given to has() fails when its scope holds a field it never named.
class PropsAllowlistTest extends TestCase
{
use RefreshDatabase;
public static function pages(): array
{
return ['shipped controller' => ['/students/1/fees'], 'lab weak copy' => ['/lab/students/1/fees']];
}
#[DataProvider('pages')]
public function test_fee_page_sends_only_the_fields_it_draws(string $url): void
{
$this->seed(FeeSeeder::class);
$this->withoutVite();
$this->actingAs(User::where('email', 'sunita@pathshala.example')->firstOrFail())
->get($url)
->assertInertia(fn (Assert $page) => $page
->has('student', fn (Assert $student) => $student->hasAll(['id', 'name', 'course']))
->has('instalments.0', fn (Assert $row) => $row->hasAll(['id', 'label', 'amount_paise', 'receipt_no'])));
}
} inertia-tests/tests/Feature/FeePageTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\FeeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
// The fee page and the contact form, tested on the Laravel side: the component, its props, the deferred reload,
// the policy and the error prop. No browser and no build: withoutVite() leaves the root view's @vite tags out.
class FeePageTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(FeeSeeder::class);
$this->withoutVite();
}
private function guardian(string $email): User
{
return User::where('email', $email)->firstOrFail();
}
public function test_fee_page_renders_its_component_and_props(): void
{
$this->actingAs($this->guardian('sunita@pathshala.example'))
->get('/students/1/fees')
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Fees/Show')
->where('student.name', 'Aarav Rao')
->has('instalments', 3)
->missing('reminders')
->missing('earlierCourses'));
}
public function test_deferred_reminders_arrive_in_the_follow_up_request(): void
{
$this->actingAs($this->guardian('sunita@pathshala.example'))
->get('/students/1/fees')
->assertInertia(fn (Assert $page) => $page
->loadDeferredProps(fn (Assert $reload) => $reload->has('reminders', 2)));
}
public function test_another_guardians_student_is_forbidden(): void
{
$this->actingAs($this->guardian('ravi@pathshala.example'))
->get('/students/1/fees')
->assertForbidden();
}
public function test_a_bad_number_comes_back_as_an_error_prop(): void
{
$this->actingAs($this->guardian('sunita@pathshala.example'))
->from('/guardian/contact')
->put('/guardian/contact', ['phone' => '12345'])
->assertRedirect('/guardian/contact');
$this->get('/guardian/contact')
->assertInertia(fn (Assert $page) => $page
->component('Contact/Edit')
->where('errors.phone', 'Enter a 10-digit mobile number that starts with 6, 7, 8 or 9.'));
}
} from session import run
# The fee page's tests (inertia-tests), run with the skeleton's PHPUnit 12 through `php artisan test`. The props
# allowlist check on both controllers is its own session (allowlist_session.py), so each stays well inside its time limit.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan test tests/Feature/FeePageTest.php
""",
app=True,
overlay=("inertia-probe", "props-lab", "inertia-tests"),
repos=("repo-frontend",),
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet $ php artisan test tests/Feature/FeePageTest.php PASS Tests\Feature\FeePageTest ✓ fee page renders its component and props …s ✓ deferred reminders arrive in the follow up request …s ✓ another guardians student is forbidden …s ✓ a bad number comes back as an error prop …s Tests: 4 passed (44 assertions) Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tests_session.py
from session import run
# The props allowlist check (inertia-tests), run on the shipped controller and on the lab's weak copy (props-lab).
# The run is meant to fail: the weak copy sends fields the page never draws.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
! php artisan test tests/Feature/PropsAllowlistTest.php
""",
app=True,
overlay=("inertia-probe", "props-lab", "inertia-tests"),
repos=("repo-frontend",),
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan test tests/Feature/PropsAllowlistTest.php
FAIL Tests\Feature\PropsAllowlistTest
✓ fee page sends only the fields it draws with data set "shipped cont… …s
⨯ fee page sends only the fields it draws with data set "lab weak cop… …s
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\PropsAllowlistTest > fee page sends only the fields…
Unexpected properties were found in scope [student].
Failed asserting that two arrays are identical.
-Array &0 []
+Array &0 [
+ 1 => 'guardian_id',
+ 4 => 'staff_note',
+]
at tests/Feature/PropsAllowlistTest.php:32
28▕
29▕ $this->actingAs(User::where('email', 'sunita@pathshala.example')->firstOrFail())
30▕ ->get($url)
31▕ ->assertInertia(fn (Assert $page) => $page
➜ 32▕ ->has('student', fn (Assert $student) => $student->hasAll(['id', 'name', 'course']))
33▕ ->has('instalments.0', fn (Assert $row) => $row->hasAll(['id', 'label', 'amount_paise', 'receipt_no'])));
34▕ }
35▕ }
36▕
+2 vendor frames
3 tests/Feature/PropsAllowlistTest.php:31
Tests: 1 failed, 1 passed (31 assertions)
Duration: …s
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 allowlist_session.py
FeePageTest’s four tests pass with 44 assertions: the component’s file exists in resources/js/pages, which Inertia’s testing config checks by default, the deferred reminders arrive in the follow-up request, Ravi gets 403 for Aarav’s page, and a bad phone number comes back as an error prop. The allowlist check passes on the shipped controller and fails on the lab copy with Unexpected properties were found in scope [student], naming guardian_id and staff_note, so it tests the rule itself and not only that the page renders.
Leaving a field out of the component
Not drawing a field, or drawing it only for staff, hides nothing: the page object holds every prop the controller sent. Take the field out where the props are built, in the controller or in share().
Props also live on in the browser’s history after the page is gone. Inertia can encrypt the page data it stores there, with Inertia::encryptHistory() or history.encrypt in config/inertia.php, and Inertia::clearHistory() at sign-out discards the key, so a Back press afterwards cannot decrypt the old page and Inertia asks the server for it again; the feature needs HTTPS, because it uses the browser’s crypto API.
Checkpoint
Check: Props are readable in the page
Forms post to ordinary routes, and errors come back as a prop
An Inertia form submits to a Laravel route like any other: ContactController@update validates the number, saves it and redirects. On the page, Inertia’s <Form> component sends the fields with XHR instead of a full page post, and its errors and processing values drive the markup. Here action={update()} takes the URL and the PUT method from Wayfinder’s generated function, which the last section looks at; useForm() is the other documented way, for forms that keep their state in code:
inertia-probe/app/Http/Controllers/ContactController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ContactController extends Controller
{
public function edit(Request $request): Response
{
return Inertia::render('Contact/Edit', [
'phone' => $request->user()->phone,
]);
}
// An ordinary Laravel form handler: validate, save, redirect. A failed rule redirects back with the errors.
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'phone' => ['required', 'regex:/^[6-9][0-9]{9}$/'],
], [
'phone.regex' => 'Enter a 10-digit mobile number that starts with 6, 7, 8 or 9.',
]);
$request->user()->update($data);
Inertia::flash('status', 'Contact number saved.');
return to_route('contact.edit');
}
} pages/Contact/Edit.tsx · inertia-probe/resources/js/pages/Contact/Edit.tsx
import { Form, Head, usePage } from '@inertiajs/react';
import { update } from '@/actions/App/Http/Controllers/ContactController';
export default function Edit({ phone }: { phone: string | null }) {
const { flash } = usePage();
return (
<main>
<Head title="Contact number" />
<h1>Contact number</h1>
<p role="status">{flash.status}</p>
{/* Wayfinder's update() carries the URL and the method (PUT) of ContactController@update. */}
<Form action={update()}>
{({ errors, processing }) => (
<>
<label htmlFor="phone">Mobile number</label>
<input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
defaultValue={phone ?? ''}
aria-invalid={errors.phone ? true : undefined}
aria-describedby={errors.phone ? 'phone-error' : undefined}
/>
{errors.phone && <p id="phone-error">{errors.phone}</p>}
<button type="submit" disabled={processing}>
Save
</button>
</>
)}
</Form>
</main>
);
} types/global.d.ts · inertia-probe/resources/js/types/global.d.ts
import '@inertiajs/core';
// The props HandleInertiaRequests shares with every page, and the flash data the controllers send.
declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
auth: { user: { id: number; name: string } | null };
};
flashDataType: {
status?: string;
};
}
} Two Laravel mechanisms meet every submit. The CSRF check: Laravel puts the token in an encrypted XSRF-TOKEN cookie on every response, and Inertia’s HTTP client copies that cookie into an X-XSRF-TOKEN header on each request, so the page needs no token field. Before the token is even read, Laravel 13 lets through a request that the browser itself marks Sec-Fetch-Site: same-origin, a header browsers add over HTTPS only; any other request needs the token. browser.php sends no Sec-Fetch-Site, so in this run the token alone decides.
The validation redirect: an Inertia form never receives a 422 response. A failed rule redirects back with the errors in the session, as for a Blade form, and the next page object carries them in errors. The session submits the form three times:
from session import run
# Sunita's contact form, submitted three times the way Inertia's <Form> sends it: without the X-XSRF-TOKEN header,
# with a number that fails the rule, and with a good number. Each redirect is followed the way the browser follows it
# for Inertia, and the page object of the page it lands on is printed. The tab starts with an Inertia visit, so no
# build is needed: every response here is JSON or a redirect.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan migrate --quiet
php artisan db:seed --class=FeeSeeder --quiet
php browser.php sunita@pathshala.example 'VISIT /guardian/contact' 'PUT! /guardian/contact {"phone":"9123456780"}' 'PUT /guardian/contact {"phone":"12345"}' 'PUT /guardian/contact {"phone":"9123456780"}'
""",
app=True,
overlay=("inertia-probe",),
repos=("repo-frontend",),
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan migrate --quiet
$ php artisan db:seed --class=FeeSeeder --quiet
$ php browser.php sunita@pathshala.example 'VISIT /guardian/contact' 'PUT! /guardian/contact {"phone":"9123456780"}' 'PUT /guardian/contact {"phone":"12345"}' 'PUT /guardian/contact {"phone":"9123456780"}'
VISIT /guardian/contact (X-Inertia-Version: empty) -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 190 bytes; tables read or written: none
page object: the whole body
{
"component": "Contact/Edit",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"phone": "9876543210"
},
"url": "/guardian/contact",
"version": "",
"sharedProps": [
"errors",
"auth"
]
}
PUT! /guardian/contact {"phone":"9123456780"} (no X-XSRF-TOKEN) -> HTTP 419, Content-Type: text/html; charset=utf-8
body 6,592 bytes; tables read or written: none
PUT /guardian/contact {"phone":"12345"} (X-XSRF-TOKEN from the cookie) -> HTTP 303, Content-Type: text/html; charset=utf-8, Vary: X-Inertia, Location: https://pathshala.example/guardian/contact
body 414 bytes; tables read or written: none
followed: GET /guardian/contact -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 261 bytes; tables read or written: none
page object: the whole body
{
"component": "Contact/Edit",
"props": {
"errors": {
"phone": "Enter a 10-digit mobile number that starts with 6, 7, 8 or 9."
},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"phone": "9876543210"
},
"url": "/guardian/contact",
"version": "",
"sharedProps": [
"errors",
"auth"
]
}
PUT /guardian/contact {"phone":"9123456780"} (X-XSRF-TOKEN from the cookie) -> HTTP 303, Content-Type: text/html; charset=utf-8, Vary: X-Inertia, Location: https://pathshala.example/guardian/contact
body 414 bytes; tables read or written: update users
followed: GET /guardian/contact -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 233 bytes; tables read or written: none
page object: the whole body
{
"component": "Contact/Edit",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"phone": "9123456780"
},
"url": "/guardian/contact",
"version": "",
"sharedProps": [
"errors",
"auth"
],
"flash": {
"status": "Contact number saved."
}
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forms_session.py
Without the header, Laravel answered 419 with its “Page Expired” HTML page. That is not an Inertia response, so the client would show it in a modal; Inertia’s CSRF page suggests turning a 419 into a redirect back with a flash message in bootstrap/app.php. With the header and the number 12345, the rule failed inside update() before anything was saved, and the redirect came back as 303, not 302, because Inertia’s middleware changes a 302 that answers a PUT, PATCH or DELETE into 303, which the browser follows with a GET. That GET returned the form’s page object with errors.phone set to the rule’s message, one message per field by default.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The submit. The Form component sends PUT /guardian/contact with the fields as JSON and the X-Inertia headers, and copies the XSRF-TOKEN cookie into X-XSRF-TOKEN.
- No token, no write. The run's first request had no X-XSRF-TOKEN and no Sec-Fetch-Site header. Laravel answered 419 Page Expired, an HTML page the client would show in a modal.
- The token matches. With the header, the token decrypts to the session's own token, and the request reaches ContactController@update.
- The rule fails. 12345 is not a 10-digit mobile number starting with 6 to 9. Laravel redirects back to the form with the message in the session, and nothing is saved.
- 302 becomes 303. Inertia's middleware turns the 302 that answers a PUT into a 303, so the browser follows it with a GET instead of a second PUT.
- The error comes back as a prop. The GET returns the Contact/Edit page object with errors.phone set to the rule's message, which the page draws under the field.
- A good number. 9123456780 passes, one users row is updated, the same 303 follows, and the page object holds the new phone, empty errors and flash.status.
The good number passed, one update users query saved it, the redirect was the same 303, and the page object it led to has the new phone, an empty errors object and, at the top level beside props, flash with “Contact number saved.”. Inertia::flash() puts that message in the session for the next response only, and Inertia leaves flash data out of the page’s history entry, so pressing Back does not show the message again.
The React page reads both: errors.phone from the <Form> component and flash.status from usePage(). It ties the message to its field with aria-describedby and marks the field with aria-invalid, as an accessible Blade form does, and it shows the flash in a role="status" paragraph that screen readers announce.
Checkpoint
Check: Forms, CSRF and error props
Partial reloads, deferred and optional props fetch data when the page needs it
A partial reload: An Inertia request for the page already on screen that asks the server for some of its props by name, with the only or except option, and leaves the others as they are. asks the server for some props of the page on screen and leaves the others as they are. The client sends two extra headers, X-Inertia-Partial-Component with the page’s component and X-Inertia-Partial-Data with the props it wants; the adapter resolves only those, plus the always-sent errors. A request for a different component gets the full page instead, since the props of one page mean nothing to another.
The fee controller wraps three props in closures, and a closure runs only when its prop is in the response. instalments is a plain closure, sent on every full visit. reminders is a deferred prop: An Inertia prop wrapped in Inertia::defer(): it is left out of the first response and the page fetches it in a follow-up request once it has drawn.: Inertia::defer() leaves it out of the first response, and the page fetches it in a second request once it has drawn. earlierCourses is an optional prop: An Inertia prop wrapped in Inertia::optional(): it is never sent unless a partial reload names it, for data shown only on request. It replaces Inertia::lazy(), removed in Inertia 3.: Inertia::optional() keeps it out until a partial reload names it, here after a click on “Show earlier courses”. Inertia 3 removed Inertia::lazy(), the older name for the same thing, so v2 code and tutorials that use it need optional() now.
The session sends a full visit, the reload the page makes by itself for reminders, and the one the button makes, and lists the tables each request read:
from session import run
# Aarav's fee page and the two requests that follow it: the reload the page makes by itself for its deferred prop
# (reminders), and the one a click on "Show earlier courses" makes (router.reload({ only: ['earlierCourses'] })).
# Each response lists the tables its request read, so the closures that ran can be counted. There is no build in
# this session, so the asset version is an empty string on both sides.
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan migrate --quiet
php artisan db:seed --class=FeeSeeder --quiet
php browser.php sunita@pathshala.example 'VISIT /students/1/fees' 'RELOAD reminders' 'RELOAD earlierCourses'
""",
app=True,
overlay=("inertia-probe",),
repos=("repo-frontend",),
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan migrate --quiet
$ php artisan db:seed --class=FeeSeeder --quiet
$ php browser.php sunita@pathshala.example 'VISIT /students/1/fees' 'RELOAD reminders' 'RELOAD earlierCourses'
VISIT /students/1/fees (X-Inertia-Version: empty) -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 499 bytes; tables read or written: students, instalments
page object: the whole body
{
"component": "Fees/Show",
"props": {
"errors": {},
"auth": {
"user": {
"id": 1,
"name": "Sunita Rao"
}
},
"student": {
"id": 1,
"name": "Aarav Rao",
"course": "Python, evening batch"
},
"instalments": [
{
"id": 1,
"label": "Term 1",
"amount_paise": 850000,
"receipt_no": "PSL-2041"
},
{
"id": 2,
"label": "Term 2",
"amount_paise": 850000,
"receipt_no": null
},
{
"id": 3,
"label": "Term 3",
"amount_paise": 850000,
"receipt_no": null
}
]
},
"url": "/students/1/fees",
"version": "",
"sharedProps": [
"errors",
"auth"
],
"deferredProps": {
"default": [
"reminders"
]
}
}
RELOAD /students/1/fees (X-Inertia-Partial-Data: reminders) -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 259 bytes; tables read or written: students, reminders
page object: the whole body
{
"component": "Fees/Show",
"props": {
"errors": {},
"reminders": [
{
"channel": "sms",
"message": "Term 2 fee of Rs 8,500 is due."
},
{
"channel": "email",
"message": "Term 2 fee of Rs 8,500 is due."
}
]
},
"url": "/students/1/fees",
"version": "",
"sharedProps": [
"errors",
"auth"
]
}
RELOAD /students/1/fees (X-Inertia-Partial-Data: earlierCourses) -> HTTP 200, Content-Type: application/json, X-Inertia: true, Vary: X-Inertia
body 246 bytes; tables read or written: students, instalments
page object: the whole body
{
"component": "Fees/Show",
"props": {
"errors": {},
"earlierCourses": [
{
"course": "Spoken English, Saturday",
"label": "Full course",
"amount_paise": 600000,
"receipt_no": "PSL-1877"
}
]
},
"url": "/students/1/fees",
"version": "",
"sharedProps": [
"errors",
"auth"
]
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 partial_session.py
The full visit read students and instalments, and its page object listed "deferredProps": {"default": ["reminders"]} without the reminders themselves. The deferred reload read students and reminders and returned 259 bytes with just errors and the two reminders; the instalments query did not run, because its closure was not asked for. The optional reload read students and instalments and returned the one earlier course, the Spoken English fee with receipt PSL-1877.
students appears in every request because the controller’s body always runs: route model binding loads Aarav and Gate::authorize() checks Sunita on each one, as they should. Only the closures are skipped, so the work that a partial reload should save belongs inside them. A value passed without a closure, such as 'student' => $student->only(…), is computed on every request, even one that does not send it.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Prop passed as | In the first response | In a partial reload | Computed when | Extra requests |
|---|---|---|---|---|
| a plain value On the fee page: student, from only() | Good yes | Mixed only when asked for | Poor on every request, even when not sent | Good none |
| a closure On the fee page: instalments | Good yes | Mixed only when asked for | Good only when it is sent | Good none |
| Inertia::defer() On the fee page: reminders, after the first paint | Mixed no: listed in deferredProps | Good the page fetches it by itself | Good in the follow-up request | Mixed one per group, right after each visit |
| Inertia::optional() On the fee page: earlierCourses, after a click | Mixed no, and not announced | Good only when named in only | Good only when it is named | Mixed one, when the person asks |
| Inertia::always() On the fee page: errors, shared by the middleware | Good yes | Mixed yes, even when not asked for | Mixed on every request | Good none |
How the answers decide
- “Must the page show this data as soon as it opens?” Yes, at once: rules out Inertia::defer() and Inertia::optional(). A deferred or optional prop is missing from the first response, so the page would draw a gap where this data belongs.
- “Must the page show this data as soon as it opens?” No, it can follow: adds a caveat to a plain value and Inertia::always(). Data that can wait need not hold up the first response, and a plain value or an always prop is computed on every request.
- “Is it shown only after the person asks for it, with a button or a tab?” Yes, on request: rules out Inertia::defer(). A deferred prop is fetched straight after every visit, whether or not the person ever opens it; an optional one waits for the click.
- “Is it shown only after the person asks for it, with a button or a tab?” No, always shown: rules out Inertia::optional(). An optional prop never arrives unless a reload names it, so data that is always shown would never appear.
Deferred props share one follow-up request unless they name a group: Inertia::defer(fn () => …, 'sidebar') puts a prop in its own request, which runs in parallel with the default group. On the page, <Deferred data="reminders" fallback={…}> shows its fallback until the prop arrives, which is how Fees/Show draws “Loading reminders…” first.
Deferring the data the page is about
A deferred prop costs a second request and leaves a gap on screen until it arrives. Defer what sits below the main content, such as reminders or a chart, and send the table the guardian came for in the first response.
Checkpoint
Check: Partial reloads, deferred and optional props
Wayfinder turns routes into TypeScript, so a renamed action fails the type check
A page that writes '/guardian/contact' by hand breaks quietly when the route changes: the mistake surfaces as a 404 or a 405 when someone submits, not when anyone builds. Wayfinder: A Laravel package that generates TypeScript functions for an app's routes and controller actions, so front-end code imports a URL and method instead of writing them as strings. reads Laravel’s router and writes a TypeScript module for each controller and each group of named routes, so pages import functions instead of strings. The session generates them, type-checks the pages with tsc, then changes the Laravel side twice:
from session import run
# Wayfinder writes TypeScript for the app's routes and controller actions, and tsc checks the React pages against it.
# Then two changes on the Laravel side: the contact form's URL changes (regenerate: the new URL is in the module), and
# ContactController's update() is renamed save() (regenerate: the contact page no longer compiles).
run(
"""
composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
php artisan wayfinder:generate --path=resources/js
find resources/js/actions resources/js/routes resources/js/wayfinder -type f | sort
sed -n '/ContactController::update/,$p' resources/js/actions/App/Http/Controllers/ContactController.ts
grep -n 'export const show' resources/js/actions/App/Http/Controllers/FeeController.ts
npx tsc --noEmit && echo 'tsc: no errors'
perl -pi -e "s#'/guardian/contact'#'/guardian/contact-details'#" routes/web.php
php artisan wayfinder:generate --path=resources/js --quiet
grep -n "url: '" resources/js/actions/App/Http/Controllers/ContactController.ts
perl -pi -e "s/'update'\\]/'save']/" routes/web.php
perl -pi -e 's/function update\\(/function save(/' app/Http/Controllers/ContactController.php
php artisan wayfinder:generate --path=resources/js --quiet
! npx tsc --noEmit
""",
app=True,
overlay=("inertia-probe",),
repos=("repo-frontend",),
tools=("node",),
node_modules="inertia-react-3.9.1",
) Output
$ composer require inertiajs/inertia-laravel laravel/wayfinder --quiet
$ php artisan wayfinder:generate --path=resources/js
[Wayfinder] Generated actions in resources/js/actions
[Wayfinder] Generated routes in resources/js/routes
$ find resources/js/actions resources/js/routes resources/js/wayfinder -type f | sort
resources/js/actions/App/Http/Controllers/ContactController.ts
resources/js/actions/App/Http/Controllers/FeeController.ts
resources/js/actions/App/Http/Controllers/index.ts
resources/js/actions/App/Http/index.ts
resources/js/actions/App/index.ts
resources/js/actions/Inertia/DevTools/Http/EntriesController.ts
resources/js/actions/Inertia/DevTools/Http/index.ts
resources/js/actions/Inertia/DevTools/index.ts
resources/js/actions/Inertia/index.ts
resources/js/routes/contact/index.ts
resources/js/routes/fees/index.ts
resources/js/routes/storage/index.ts
resources/js/routes/storage/local/index.ts
resources/js/wayfinder/index.ts
$ sed -n '/ContactController::update/,$p' resources/js/actions/App/Http/Controllers/ContactController.ts
* @see \App\Http\Controllers\ContactController::update
* @see app/Http/Controllers/ContactController.php:20
* @route '/guardian/contact'
*/
export const update = (options?: RouteQueryOptions): RouteDefinition<'put'> => ({
url: update.url(options),
method: 'put',
})
update.definition = {
methods: ["put"],
url: '/guardian/contact',
} satisfies RouteDefinition<["put"]>
/**
* @see \App\Http\Controllers\ContactController::update
* @see app/Http/Controllers/ContactController.php:20
* @route '/guardian/contact'
*/
update.url = (options?: RouteQueryOptions) => {
return update.definition.url + queryParams(options)
}
/**
* @see \App\Http\Controllers\ContactController::update
* @see app/Http/Controllers/ContactController.php:20
* @route '/guardian/contact'
*/
update.put = (options?: RouteQueryOptions): RouteDefinition<'put'> => ({
url: update.url(options),
method: 'put',
})
const ContactController = { edit, update }
export default ContactController
$ grep -n 'export const show' resources/js/actions/App/Http/Controllers/FeeController.ts
7:export const show = (args: { student: string | number | { id: string | number } } | [student: string | number | { id: string | number } ] | string | number | { id: string | number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
$ npx tsc --noEmit && echo 'tsc: no errors'
tsc: no errors
$ perl -pi -e "s#'/guardian/contact'#'/guardian/contact-details'#" routes/web.php
$ php artisan wayfinder:generate --path=resources/js --quiet
$ grep -n "url: '" resources/js/actions/App/Http/Controllers/ContactController.ts
14: url: '/guardian/contact-details',
58: url: '/guardian/contact-details',
$ perl -pi -e "s/'update'\]/'save']/" routes/web.php
$ perl -pi -e 's/function update\(/function save(/' app/Http/Controllers/ContactController.php
$ php artisan wayfinder:generate --path=resources/js --quiet
$ npx tsc --noEmit
resources/js/pages/Contact/Edit.tsx(2,10): error TS2614: Module '"@/actions/App/Http/Controllers/ContactController"' has no exported member 'update'. Did you mean to use 'import update from "@/actions/App/Http/Controllers/ContactController"' instead?
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wayfinder_session.py
php artisan wayfinder:generate wrote 14 files. resources/js/actions holds one module per controller, in folders that mirror the PHP namespace with an index.ts in each, and Inertia’s own development-tools controller is among them; resources/js/routes holds one module per route-name prefix, here contact, fees and the framework’s storage.local; resources/js/wayfinder holds the shared helpers. update() returns { url, method: 'put' }, which is exactly what <Form action={update()}> reads, update.url() gives the URL alone, and update.definition lists the methods the route accepts. show for /students/{student}/fees takes the student as 1, [1], { student: 1 } or an object with an id, and its args parameter is required, so a call that forgets the student does not type-check.
| Change on the Laravel side | What the generated TypeScript does | What the pages need |
|---|---|---|
| A route’s URL changes | same function, new url inside it |
nothing: they import the name |
| A controller method is renamed | the export takes the new name | tsc fails at every old import |
| A route is removed | its function and file are gone | tsc fails at every import |
| A route gains a parameter | the function needs a new argument | tsc fails at calls without it |
The run shows the first two rows. After the URL changed to /guardian/contact-details, both url: lines of the module held the new address and no function name moved. After update() became save(), the same tsc --noEmit that had passed stopped with TS2614 at Contact/Edit.tsx(2,10): the module has no exported member update. That error names the file and the line before anything is deployed, where a hard-coded string would have waited for a guardian to press Save.
The React starter kit runs the generator from a Vite plugin, @laravel/vite-plugin-wayfinder, during npm run dev and every build, so the files never fall behind the routes; Wayfinder’s README says they can be left out of Git for the same reason. Its deploy note matters on servers that run php artisan route:cache: the generator reads the cached route table, so a deploy runs php artisan route:clear before the build. Wayfinder is still in beta and its API may change before version 1.0; this lesson ran 0.1.21.
Checkpoint
Check: Typed routes with Wayfinder
Practise
Build Pathshala’s guardian dashboard on the project from this lesson, or on a new one with composer require inertiajs/inertia-laravel laravel/wayfinder and the React packages:
- A route
/guardiannamedguardian.dashboardrendersGuardian/Dashboardwith each of the guardian’s students asonly('id', 'name', 'course'), and the page links to each fee page through Wayfinder’sshow(). - A deferred prop with the total still due, and an optional prop with the full payment history behind a “Show history” button.
- A contact form for email and phone that shows each field’s error next to it and a success message from
Inertia::flash(). - Tests with
assertInertia(): the component, a scopedhas()that fails when a student prop gains a field, the error prop after a bad submit, andloadDeferredProps()for the total. Rename the controller method behind the form once and watchnpx tsc --noEmitfail.
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what are the two shapes of an Inertia response, and what decides which one the server sends?
A request without the X-Inertia header, such as typing the address or reloading, gets a full HTML document: the root Blade view with the asset tags, the page object as JSON in a <script data-page="app" type="application/json"> element, and an empty <div id="app"> for the client to mount on. A request with X-Inertia: true, which Inertia’s client sends for every link click, form submit and reload of props, gets the page object alone as JSON, with X-Inertia: true and Vary: X-Inertia on the response. The page object is the same in both: component, props, URL and asset version, 531 bytes for Pathshala’s fee page in this lesson’s run. A visit whose X-Inertia-Version no longer matches gets neither shape but a 409 with X-Inertia-Location, and the client reloads the page fully.
Two questions on what props may carry and on choosing between Inertia and Livewire:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A controller returns Inertia::render('Students/Show', ['student' => $student]) with the whole Eloquent model, and the React page draws only the name and the course. A colleague says the other columns are safe because the page never shows them. What does the browser receive, why does it matter, and how do you fix it and keep it fixed?
-
Mid to senior
A team starts a new Laravel 13 app for Pathshala's staff and guardians. Half the team writes PHP and Blade every day, the other half React. How do you choose between Livewire 4 and Inertia 3, and what changes with each for state, security checks, testing and deployment?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does an Inertia app keep from Laravel, and what replaces Blade?
It keeps routes, middleware, policies and validation. Controllers return Inertia::render() with the name of a React, Vue or Svelte page component and that page's props, and the component draws the page.
-
What are the two shapes of an Inertia response?
A visit without the X-Inertia header, such as a typed address, gets a full HTML document with the page object inside a script element. A visit with X-Inertia: true, sent by Inertia's client on link clicks and form submits, gets the same page object alone as JSON.
-
What does an Inertia server answer when a visit carries an outdated asset version?
409 Conflict with an X-Inertia-Location header. The version is a hash of the Vite manifest, so after a deploy the client's version differs, and the 409 makes it do a full page load that fetches the new assets.
-
Why should an Inertia controller build props from an allowlist of fields?
Every prop, shared props too, is readable in the page's HTML, in each visit's JSON and in the history. Send only the fields the page draws, and pin the shape with a scoped has() test, which breaks as soon as a new field slips in.
-
How do Inertia forms send the CSRF token and get validation errors back?
They post to ordinary routes, and the XSRF-TOKEN cookie carries the token. A failed rule redirects back with the errors in the errors prop; after a PUT, PATCH or DELETE, Inertia's middleware turns that 302 into a 303, which the browser follows with a GET. Inertia::flash() sends one-time messages.
-
Which tools decide when an Inertia prop is computed, and what does Wayfinder add?
Closures, Inertia::defer() and Inertia::optional() delay or skip a prop until the page needs it. Wayfinder generates TypeScript functions for your routes, so a renamed or removed route fails the type check instead of becoming a broken link.
Key takeaways
- An Inertia app keeps Laravel’s routes, middleware, policies and validation, and its controllers return
Inertia::render()with a React, Vue or Svelte page component and that page’s props. - A first visit gets an HTML document with the page object in a script element, every later visit gets the same page object as JSON, and a visit with a stale asset version gets a 409 that forces a full page load.
- Every prop and every shared prop is readable in the page and its history, so controllers build props from the fields the page draws, and a scoped
has()test fails when a prop gains a field. - Inertia forms post to ordinary routes: the
XSRF-TOKENcookie carries the CSRF token, failed rules come back in theerrorsprop after a redirect (a 303 after PUT, PATCH or DELETE), andInertia::flash()sends one-time messages. - Closures,
Inertia::defer()andInertia::optional()decide when a prop is computed and sent, and Wayfinder’s generated functions turn a renamed or removed route into a TypeScript error instead of a broken link.
Terms in this lesson
- deferred prop
- An Inertia prop wrapped in Inertia::defer(): it is left out of the first response and the page fetches it in a follow-up request once it has drawn.
- Inertia
- A library that lets a server-side app such as Laravel keep its routes and controllers while React, Vue or Svelte draws each page; every response names a page component and the props it gets.
- Inertia visit
- A navigation that Inertia's client sends with XHR and the X-Inertia header instead of loading a new document; the server answers with the page object as JSON.
- optional prop
- An Inertia prop wrapped in Inertia::optional(): it is never sent unless a partial reload names it, for data shown only on request. It replaces Inertia::lazy(), removed in Inertia 3.
- page object
- The JSON Inertia sends for every page: the component's name, its props, the URL and the asset version. A first visit embeds it in the HTML; later visits receive it alone.
- partial reload
- An Inertia request for the page already on screen that asks the server for some of its props by name, with the only or except option, and leaves the others as they are.
- server-side rendering
- Running a JavaScript page component on the server to produce the first visit's HTML, so the page shows content before its scripts run; with Inertia it needs a separate Node process in production.
- shared data
- Props that Inertia's middleware adds to every page's response, such as the signed-in user's name or the validation errors, defined in its share() method.
- Wayfinder
- A Laravel package that generates TypeScript functions for an app's routes and controller actions, so front-end code imports a URL and method instead of writing them as strings.
References
- How it works (Inertia.js (inertiajs.com))
- The protocol (Inertia.js (inertiajs.com))
- Server-side setup (Inertia.js (inertiajs.com))
- Client-side setup (Inertia.js (inertiajs.com))
- Upgrade guide for v3.0 (Inertia.js (inertiajs.com))
- Pages (Inertia.js (inertiajs.com))
- Forms (Inertia.js (inertiajs.com))
- Validation (Inertia.js (inertiajs.com))
- CSRF protection (Inertia.js (inertiajs.com))
- History encryption (Inertia.js (inertiajs.com))
- Shared data (Inertia.js (inertiajs.com))
- Flash data (Inertia.js (inertiajs.com))
- Partial reloads (Inertia.js (inertiajs.com))
- Deferred props (Inertia.js (inertiajs.com))
- Asset versioning (Inertia.js (inertiajs.com))
- Server-side rendering (Inertia.js (inertiajs.com))
- Testing (Inertia.js (inertiajs.com))
- TypeScript (Inertia.js (inertiajs.com))
- inertiajs/inertia-laravel (releases) (Packagist (packagist.org))
- Laravel Wayfinder (README, v0.1.21) (Laravel (GitHub))
- Frontend (Inertia) (Laravel (laravel.com))
- Starter kits (React, Vue and Svelte) (Laravel (laravel.com))
- CSRF protection (origin verification, X-XSRF-TOKEN) (Laravel (laravel.com))
- laravel/react-starter-kit (package.json, vite.config.ts) (Laravel (GitHub))
Report a problem with this lesson
Kept only in this browser. Your Learn progress