Search by

tinymvc / inertia-php

dev.shahin

An Inertia.js v3 server adapter for the TinyMVC framework.

Package info

github.com/tinymvc/inertia-php

pkg:composer/tinymvc/inertia-php

Statistics

Installs: 34

Dependents: 1

Suggesters: 0

Stars: 2

Open Issues: 0

v2.0.2 2026-09-30 15:37 UTC

This package is auto-updated.

Last update: 2026-10-02 15:09:22 UTC


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