Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

composer require for Inertia and Wayfinder, then the middleware Python · install_session.py
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:

The fee page: controller, React page, routes, root view, client entry, middleware and build config

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:

The browser tab, played by browser.php, and the two guardians it runs against

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');
        });
    }
};
A production build, a first visit and an Inertia visit Python · visits_session.py
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.

Interactive · message by message A first visit, an Inertia visit and a visit after a deploy Step through one of Sunita's tabs, with the sizes and versions the examples printed.
A first visit, an Inertia visit and a visit after a deploy, step 8 of 8. Messages so far: Browser to Laravel: GET the page; Laravel to Browser: HTML document; Browser to Laravel: X-Inertia visit; Laravel to Browser: JSON only; Laravel to Browser: 409 + address; Browser to Laravel: full page load.Browserpage drawnLaravelmiddlewareBuildnew versionGET the pageHTML documentX-Inertia visitJSON only409 + addressfull page load

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Typing the address. GET /students/1/fees leaves as a plain page load, with no Inertia headers.
  2. 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.
  3. The client starts. createInertiaApp() reads the page object, loads the Fees/Show chunk and draws the instalments table into the empty div.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

A tab left open while the front end is rebuilt Python · deploy_session.py
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

  1. Question 1 of 2 Ravi, another guardian, opens /students/1/fees, the fee page of Sunita's son. In this lesson's Inertia app, what stops him?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Gate::authorize('view', $student) in the Laravel controller, which answers 403 before any props are built.

    An Inertia app keeps Laravel's routing, middleware, policies and validation. The controller authorises the request and only then returns Inertia::render() with the page component and its props.

  2. Question 2 of 2 Sunita's tab was loaded before a deploy and holds version 308fc413…. The new build's manifest hashes to 2f1c39bf…. She clicks a link. What does Laravel answer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 409 Conflict with an empty body and X-Inertia-Location, so the client loads that address in full.

    The version is the xxh128 hash of the build manifest. A mismatch on a GET gets 409 and X-Inertia-Location, and the full page load that follows brings the new chunks and the new version.

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:

The lab's weak copy (never ship it), the student model and its policy

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;
    }
}
The fee page and the lab copy, each searched for the staff note Python · props_session.py
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.

Interactive · answer and compare How a controller builds the student prop Answer the two questions about your record; the shapes that do not fit are marked, with the reason on each.
Does the table hold columns the person viewing the page must not read, such as staff notes?
Do several pages or an API send this record with the same fields?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to build a prop from the student record, against four needs
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:

The props allowlist check and the fee page's tests

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.'));
    }
}
php artisan test: the fee page's tests Python · tests_session.py
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

php artisan test: the allowlist check on both controllers Python · allowlist_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

  1. One question The lab copy passes 'student' => $student, and the React page draws only the student's name and course. What can Sunita read with View Source?

    Choose one answer.

    Show the answer: One question

    Answer: Every column of the student row, staff_note included, inside the page object of the HTML.

    Props travel to the browser in full: in the first visit's HTML, in every visit's JSON and in the history entry. Build them from the fields the page draws, for example with only().

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:

The contact form: Laravel controller and React page

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:

The contact form without the token, with a bad number, with a good one Python · forms_session.py
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.

Interactive · stage by stage One submit of the contact form Step through the three submits the example made; each step names the check and what the run saw.
One submit of the contact form, step 7 of 7. Showing Form submit, CSRF check, Phone rule, Inertia middleware, GET the form. In focus: Phone rule, Inertia middleware, GET the form.419: not InertiaForm submitPUT, sent as JSONCSRF check419 without tokenPhone rulein update()Inertia middleware302 becomes 303GET the formerrors or flash

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. The token matches. With the header, the token decrypts to the session's own token, and the request reaches ContactController@update.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. One question Sunita submits the contact form with 12345, through Inertia, with a valid token. What does the browser receive first?

    Choose one answer.

    Show the answer: One question

    Answer: A 303 redirect to the form, whose GET then returns the page object with errors.phone set.

    Inertia forms post to ordinary routes. A failed rule redirects back with the errors in the session, the redirect is a 303, and the page object of the form carries them in the errors prop.

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:

A visit, the deferred reload and the optional reload Python · partial_session.py
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.

Interactive · answer and compare When each kind of prop is computed and sent Answer the two questions about one piece of data on the page; the kinds that do not fit are marked.
Must the page show this data as soon as it opens?
Is it shown only after the person asks for it, with a button or a tab?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to pass a prop to Inertia::render(), against four questions
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

  1. One question After the first visit, the fee page asks for its deferred prop with X-Inertia-Partial-Data: reminders. Which tables did that request read in the run?

    Choose one answer.

    Show the answer: One question

    Answer: students and reminders: the controller body still ran, but the instalments closure did not.

    A partial reload resolves the props it names plus the always-sent errors. Closures of other props never run, which is why the work a reload should save belongs inside closures.

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:

wayfinder:generate and tsc, then a changed URL and a renamed method Python · wayfinder_session.py
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

  1. One question ContactController::update() is renamed save(), the route is updated and Wayfinder regenerates. Contact/Edit.tsx still imports update. What happens?

    Choose one answer.

    Show the answer: One question

    Answer: npx tsc --noEmit fails with TS2614 at Contact/Edit.tsx line 2: the module has no exported member update.

    Wayfinder generates functions from the routes and controller methods. A rename or removal turns every old import into a type error, so the mistake shows up in the build instead of in front of a user.

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:

  1. A route /guardian named guardian.dashboard renders Guardian/Dashboard with each of the guardian’s students as only('id', 'name', 'course'), and the page links to each fee page through Wayfinder’s show().
  2. A deferred prop with the total still due, and an optional prop with the full payment history behind a “Show history” button.
  3. A contact form for email and phone that shows each field’s error next to it and a success message from Inertia::flash().
  4. Tests with assertInertia(): the component, a scoped has() that fails when a student prop gains a field, the error prop after a bad submit, and loadDeferredProps() for the total. Rename the controller method behind the form once and watch npx tsc --noEmit fail.

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.

  1. Question 1 of 13 What does the last line of an Inertia controller action return?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: Inertia::render('Fees/Show', $props): the name of a page component and the props it receives.

    Inertia::render() replaces view(). The adapter turns it into an HTML document on a first visit and into JSON on an Inertia visit.

  2. Question 2 of 13 A team moves Pathshala's guardian pages from React to Vue. Which part of the Laravel side has to change?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: None of it: routes, controllers, Inertia::render() calls and the middleware stay; the page files and the npm adapter change.

    The Laravel side is framework-neutral. The React, Vue and Svelte adapters (@inertiajs/react, @inertiajs/vue3, @inertiajs/svelte) each read the same page objects.

  3. Question 3 of 13 Which request header makes Laravel's Inertia adapter answer with the page object as JSON instead of an HTML document?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: X-Inertia: true

    A request without X-Inertia gets the root view with the page object embedded; with it, the page object alone, plus Vary: X-Inertia so that caches keep the two apart.

  4. Question 4 of 13 In this lesson's run, what changed the asset version from 308fc413… to 2f1c39bf…?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: A one-word edit of the fee page and a new build, which renamed all four chunks and so changed the manifest's hash.

    Each chunk's file name depends on its content, including the names of the chunks it imports. One edit can rename several files, and the manifest's hash, the version, changes with them.

  5. Question 5 of 13 A page polls with router.reload() every minute, and a deploy changes the asset version while Sunita is typing in a form on it. What happens at the next poll?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Nothing visible: a version change found by a background request does not force a full page load.

    The full page load waits for a visit the person starts, so polling and reloads of props never throw away what is on the screen.

  6. Question 6 of 13 The student model lists staff_note in $hidden, and the controller passes 'student' => $student. A migration adds a column concession_reason. What does the next page object hold?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: concession_reason, with every other column not in the hidden list.

    Build props from an allowlist, such as only() or an API resource, so that a new column stays on the server until a page asks for it.

  7. Question 7 of 13 An app made from the React starter kit shares 'user' => $request->user() in share(). The users table gains a home_address column. Where does it appear?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: In the page object of every page the user opens, unless the user model hides it.

    share() feeds every page, so it deserves the strictest allowlist in the app: Pathshala shares the user's id and name and nothing else.

  8. Question 8 of 13 How does the CSRF token reach Laravel when an Inertia form submits?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Laravel sets an encrypted XSRF-TOKEN cookie, and Inertia's client copies its value into an X-XSRF-TOKEN header.

    Laravel 13 first accepts a browser's Sec-Fetch-Site: same-origin over HTTPS and otherwise checks the token, which Inertia sends from the XSRF-TOKEN cookie in the X-XSRF-TOKEN header.

  9. Question 9 of 13 Why does Inertia's middleware turn the 302 that answers a PUT into a 303?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A 303 tells the browser to follow the redirect with a GET; after a 302, some clients repeat the PUT at the new address.

    After PUT, PATCH or DELETE, Inertia's middleware sends 303 See Other, which every client follows with a GET, so the page that follows is loaded rather than updated twice.

  10. Question 10 of 13 Code written for Inertia 2 uses Inertia::lazy(fn () => …). What replaces it in Inertia 3?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Inertia::optional(fn () => …), which is never sent unless a partial reload names it.

    Optional props are skipped on full visits and not announced; the page asks for them with router.reload({ only: […] }).

  11. Question 11 of 13 The fee page gains a chart of a whole year's payments, drawn below the instalments table and slow to compute. How should the controller pass it?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: As a deferred prop, so the table arrives in the first response and the chart follows in a second request.

    Send what the person came for in the first response, defer what is always shown but can follow, and make optional what appears only on request.

  12. Question 12 of 13 The contact route's URL changes from /guardian/contact to /guardian/contact-details, and Wayfinder regenerates. What must change in Contact/Edit.tsx?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Nothing: it imports update(), whose generated url now holds the new address.

    Pages that call generated functions follow URL changes without edits, and fail the type check when a name or a parameter they rely on disappears.

  13. Question 13 of 13 A deploy script ends with php artisan optimize, which caches the routes. The next release adds a route and runs the build with the Wayfinder Vite plugin. What can go wrong?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The generator reads the cached route table from the last release and silently leaves the new route out; run route:clear before the build.

    Wayfinder generates from the router Laravel boots with. Clear a cached route table before regenerating, so the TypeScript matches the release being built.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

  6. 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-TOKEN cookie carries the CSRF token, failed rules come back in the errors prop after a redirect (a 303 after PUT, PATCH or DELETE), and Inertia::flash() sends one-time messages.
  • Closures, Inertia::defer() and Inertia::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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.