tinymvc / inertia-php
An Inertia.js v3 server adapter for the TinyMVC framework.
Requires
- php: >=8.2
- ext-json: *
- tinymvc/tinycore: ^3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A server-side Inertia.js v3 adapter for TinyMVC. It implements client-side rendering only; SSR is intentionally not included.
The adapter requires PHP 8.2 or newer and TinyCore 3.x.
Installation
composer require tinymvc/inertia-php
Register the provider:
// bootstrap/providers.php return [ \Inertia\InertiaServiceProvider::class, ];
The provider registers the Inertia singleton, the @inertia Blade directive,
and the Route::inertia() macro.
Root template
<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @vite(['app.tsx', 'app.css']) </head> <body> @inertia </body> </html>
In v3, the initial page object is stored in a JSON script element:
<script data-page="app" type="application/json">{"component":"Home", ...}</script> <div id="app"></div>
Use @inertia('portal') for a custom mount id. The root Blade view and DOM
mount id are configured independently:
Inertia::setRootView('layouts.admin'); Inertia::setRootElementId('portal');
Your client-side createInertiaApp() configuration must use the same mount id.
Frontend setup
This guide uses React with TypeScript and Vite, with Inertia v3 on the client.
Install packages
npm install @inertiajs/react react react-dom npm install -D @vitejs/plugin-react
// package.json { "dependencies": { "@inertiajs/react": "^3.0.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.4.1" } }
Vite configuration
// vite.config.ts import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig(({ mode }) => ({ plugins: [ react(), // ...your existing plugins (e.g. Tailwind, fullReload(...)) ], // ... }));
Make sure the build writes its manifest to public/build/.vite/manifest.json
(Vite's default with build.manifest: true), since the adapter hashes this file
for asset versioning.
Client entry point
The entry file must match the one referenced in your root template's
@vite([...]) call (app.tsx in the example above).
// app.tsx import { createInertiaApp } from "@inertiajs/react"; import { createRoot } from "react-dom/client"; import AppLayout from "@/layouts/AppLayout"; interface PageComponent { default: React.ComponentType<any> & { layout?: (page: React.ReactNode) => React.ReactNode; }; } createInertiaApp({ resolve: async (name) => { // Lazy import: each page becomes its own chunk (code splitting) const pages = import.meta.glob<PageComponent>("./pages/**/*.tsx"); const resolver = pages[`./pages/${name}.tsx`]; if (!resolver) { throw new Error(`Page not found: ${name}`); } const page = await resolver(); // Persistent default layout for every page page.default.layout = (pageContent: React.ReactNode) => ( <AppLayout>{pageContent}</AppLayout> ); return page; }, setup({ el, App, props }) { createRoot(el).render(<App {...props} />); }, });
The adapter emits the v3 initial page format (a JSON script element), which
@inertiajs/react v3 reads by default, so no extra client configuration is
needed.
The component name passed to Inertia::render('Users/Index') maps to
./pages/Users/Index.tsx.
Custom mount id
If you use @inertia('portal') or Inertia::setRootElementId('portal'), pass
the same id to the client:
createInertiaApp({ id: "portal", // ... });
Responses
use Inertia\Facades\Inertia; return Inertia::render('Users/Index', [ 'users' => User::all(), ]); // Equivalent helper: return inertia('Users/Index', ['users' => User::all()]);
Simple routes may use the router macro:
Route::inertia('/about', 'About');
The adapter automatically adds an empty errors prop, performs partial prop
resolution, checks asset versions, emits v3 page metadata, and returns the
required X-Inertia and Vary response headers.
Shared data
Inertia::share('appName', config('app.name')); Inertia::share([ 'auth' => fn () => [ 'user' => request()->user(), ], 'locale' => 'en', ]);
Shared keys are exposed in the v3 page object's sharedProps metadata for
instant visits. Component props take precedence over shared props.
Authentication data is not shared implicitly. Define it explicitly so each application controls its own shape and authorization boundary.
Prop types
Regular closures are evaluated only when their prop survives partial-reload filtering:
return Inertia::render('Users/Index', [ 'users' => fn () => User::all(), 'companies' => fn () => Company::all(), ]);
Optional and always
return Inertia::render('Reports', [ // Excluded until explicitly requested with `only`. 'details' => Inertia::optional(fn () => Report::details()), // Included even when a partial reload did not request it. 'notifications' => Inertia::always(fn () => Notification::count()), ]);
router.reload({ only: ['details'] })
Inertia v3 removed lazy() and LazyProp; use optional() instead.
Deferred
return Inertia::render('Dashboard', [ 'permissions' => Inertia::defer(fn () => Permission::all()), 'teams' => Inertia::defer(fn () => Team::all(), 'attributes'), 'projects' => Inertia::defer(fn () => Project::all(), 'attributes'), ]);
Deferred failures can be rescued and reported to the client's <Deferred>
rescue slot:
'permissions' => Inertia::defer( fn () => Permission::all(), rescue: true, ),
Merge
return Inertia::render('Feed', [ // Append at the prop root. 'tags' => Inertia::merge($tags), // Append only `data`, replacing the other pagination fields. 'users' => Inertia::merge(fn () => User::paginate()) ->append('data', matchOn: 'id'), // Prepend one nested collection and append another. 'dashboard' => Inertia::merge($dashboard) ->prepend('announcements') ->append('activities'), // Deep merge the whole value and match nested messages by id. 'chat' => Inertia::deepMerge($chat)->matchOn('messages.id'), ]);
Merge metadata is omitted for props named in the X-Inertia-Reset header, as
required by the v3 protocol.
Once
return Inertia::render('Billing', [ 'plans' => Inertia::once(fn () => Plan::all()), 'rates' => Inertia::once(fn () => Rate::all())->until(3600), 'roles' => Inertia::once(fn () => Role::all())->as('shared-roles'), 'features' => Inertia::once(fn () => Feature::all())->fresh($changed), ]);
Once behavior can also be combined with optional, deferred, and merge props:
'report' => Inertia::optional(fn () => Report::make())->once(), 'stats' => Inertia::defer(fn () => Stats::make())->once(), 'activity' => Inertia::merge(fn () => Activity::recent())->once(),
Globally shared once props:
Inertia::shareOnce('countries', fn () => Country::all()) ->until(86400);
Nested props
V3 prop types work inside nested arrays and closures, and partial reload headers support dot notation:
return Inertia::render('Dashboard', [ 'auth' => [ 'user' => request()->user(), 'notifications' => Inertia::defer(fn () => Notification::all()), 'invoices' => Inertia::optional(fn () => Invoice::all()), ], ]);
router.reload({ only: ['auth.notifications'] })
For reusable prop objects, implement ProvidesInertiaProperties to contribute
multiple props or ProvidesInertiaProperty to resolve one contextual value.
Both are resolved at any nesting depth and receive the current TinyMVC request.
Infinite scroll
scroll() emits scrollProps and merge metadata and honors
X-Inertia-Infinite-Scroll-Merge-Intent:
'posts' => Inertia::scroll($paginator),
TinyMVC's Spark\Utils\Paginator is detected automatically. For a custom
paginator, provide metadata:
'posts' => Inertia::scroll( $result, wrapper: 'data', metadata: fn ($result) => [ 'pageName' => 'page', 'previousPage' => $result['previous'], 'nextPage' => $result['next'], 'currentPage' => $result['current'], ], ),
Flash data
V3 flash data lives at page.flash and is not persisted in browser history:
Inertia::flash('message', 'User created.'); return inertia()->redirect('/users'); // Or: return Inertia::flash([ 'message' => 'User created.', 'userId' => $user->id, ])->back();
TinyMVC's conventional info, success, and error flash keys are also moved
to page.flash automatically.
History
return Inertia::render('Account/Settings', $props) ->withEncryptedHistory(); return Inertia::render('Auth/Login') ->withClearedHistory();
withEncryptedHistory and withClearedHistory are only included in the page object when
true, as required by Inertia v3.
Redirects
return Inertia::redirect('/users'); return Inertia::back(); return Inertia::location('https://example.com');
Redirects after PUT, PATCH, and DELETE become 303 responses. External
Inertia redirects use 409 plus X-Inertia-Location; fragment redirects use
the v3 X-Inertia-Redirect header.
To preserve the fragment from the original URL across a redirect:
return Inertia::redirect('/article/new-slug') ->preserveFragment();
Asset versioning
The adapter hashes public/build/.vite/manifest.json by default:
Inertia::setBuildDirectory('build');
You may provide a fixed or lazy version:
Inertia::version(config('app.deploy_version')); Inertia::version(fn () => config('app.deploy_version'));
On a mismatched Inertia GET, the adapter returns 409 with the current URL in
X-Inertia-Location before resolving page props.
License
MIT