creativspeed/inertia-bundle
Modern Inertia.js integration for Symfony 8 and PHP 8.2+, letting you build Vue or React single-page apps without an API. Includes Inertia v2 protocol support, server-side rendering, Vite integration, Twig helpers, testing assertions, and automatic flash data sharing.
You already know the protocol; almost everything here is a rename plus DI instead of facades.
| Laravel (inertia-laravel) | This bundle |
|---|---|
Inertia::render('Users/Index', $props) |
$inertia->render('Users/Index', $props) (inject InertiaInterface) |
Inertia::share('auth', ...) in HandleInertiaRequests middleware |
$inertia->share(...) from a kernel event listener |
$request->user() shaped in middleware |
InertiaAuthUserInterface on your User entity, shared automatically |
with('success', ...) → auto-shared flash |
Flash bag shared automatically |
$errors shared by middleware |
Not automatic — use redirectWithErrors() |
to_route('dashboard') / back() |
$inertia->redirect('/dashboard') / $inertia->back() |
Inertia::lazy() / optional() / defer() / merge() |
fn () => ... / $inertia->defer() / $inertia->merge() / $inertia->deepMerge() |
assertInertia(fn (Assert $page) => ...) |
InertiaTestTrait::assertInertia() |
@inertia, @inertiaHead, @vite Blade directives |
inertia() / inertiaHead() (or inertia_body/inertia_head) Twig functions + vite_entry_*_tags |
php artisan inertia:middleware |
Nothing to scaffold — bundle uses AbstractBundle with zero config |
Install the bundle and the Vite bundle (the root template calls its Twig functions — the README omits this):
composer require creativspeed/inertia-bundle:^2.0 pentatrion/vite-bundle
Verify the bundle registered: bin/console debug:container | grep -i inertia.
Configure config/packages/inertia.yaml:
inertia:
root_view: 'inertia/app' # templates/inertia/app.html.twig
ssr:
enabled: false
url: 'http://127.0.0.1:13714'
# version: omitted — v2 derives it from the Vite manifest
Check the real Twig function names before writing the layout — the README says inertia()/inertiaHead(), the release notes say inertia_body()/inertia_head():
bin/console debug:twig | grep -i inertia
Root template templates/inertia/app.html.twig — the escaping on data-page is non-negotiable:
<!DOCTYPE html>
<html>
<head>
{{ inertiaHead(page) }}
{{ vite_entry_link_tags('app') }}
</head>
<body>
<div id="app" data-page="{{ page|json_encode|e('html_attr') }}">{{ inertiaBody(page) }}</div>
{{ vite_entry_script_tags('app') }}
</body>
</html>
First controller — same shape as the Laravel version, but with constructor/method injection:
#[Route('/', name: 'home')]
public function index(InertiaInterface $inertia): Response
{
return $inertia->render('Home', ['title' => 'Hello']);
}
Port one of your existing Laravel Inertia pages — a dashboard — and notice what you don't write: no HandleInertiaRequests::share() for auth.user, no flash plumbing, no version string.
return $inertia->render('Dashboard/Index', [
'stats' => $statsService->summary(),
'recentOrders' => fn () => $orderRepo->recentFor($this->getUser()),
]);
The fn () => prop is a lazy prop: skipped on the first load, evaluated only on ?only=recentOrders. That is the same contract as Laravel's Inertia::lazy().
HandleInertiaRequestsThis is the single biggest architectural shift. Laravel shares once per request via middleware; in Symfony you register a listener:
final class InertiaShareListener
{
public function __construct(private InertiaInterface $inertia) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->inertia->share('locale', $event->getRequest()->getLocale());
$this->inertia->share('features', ['billing' => true]);
}
}
Register it in services.yaml with kernel.event_listener. Keep manual shares to genuinely cross-cutting data — auth and flash are already handled by the bundle.
Implement InertiaAuthUserInterface on your User so the bundle knows what to send. This replaces the 'auth' => ['user' => $request->user()->only(...)] block you'd maintain by hand — and it's the place to enforce “never send password_hash”:
class User implements InertiaAuthUserInterface
{
public function toInertiaArray(): array
{
return ['id' => $this->id, 'name' => $this->name, 'roles' => $this->roles];
}
}
return $inertia->render('Feed', [
'title' => 'Feed', // always, every request
'tags' => fn () => $this->tags(), // lazy: partial reload only
'stats' => $inertia->defer(fn () => $this->stats(), 'analytics'),
'suggestions'=> $inertia->defer(fn () => $this->suggest(), 'social'),
'posts' => $inertia->merge($this->pageOfPosts()), // append on partial reload
'filters' => $inertia->deepMerge($this->filters()), // recursive merge
]);
Rules of thumb:
defer() with a group name; two groups render in parallel on the client.merge() or the list resets to page 1 on the next partial reload. This is the bug you will hit if you skip it.deepMerge() for nested filter objects, never for lists.return $inertia->redirect('/dashboard'); // 303 under the hood
return $inertia->back();
return $inertia->location('https://billing.example.com/portal'); // 409 + external
return $inertia->redirectWithErrors($request->getUri(), [
'email' => 'That address is already taken.',
]);
redirectWithErrors() is your substitute for Laravel's withErrors() — it writes into the flash bag in the shape useForm expects. You must call it yourself: Symfony's validator puts errors on the Form object, not in the session, so nothing arrives in $page.props.errors for free.
return $inertia->render('Admin/Dashboard', $props, ['root_view' => 'admin']);
Same idea as Laravel's separate Blade layout per section, just per-request instead of per-route-group.
Set ssr.enabled: true and run your Vue/React SSR server on the configured port. The Twig inertiaHead/inertiaBody pair is the hand-off: head content in <head>, rendered body inside #app, with data-page as the hydration payload. If the SSR process is down, confirm whether the bundle fails open (client-side render) or hard-errors — test by killing the node process and loading a page.
WebTestCaseuse CreativSpeed\InertiaBundle\Testing\InertiaTestTrait;
class DashboardTest extends WebTestCase
{
use InertiaTestTrait;
public function test_dashboard_props(): void
{
$client = static::createClient();
$client->loginUser($user = $this->createUser());
$client->request('GET', '/dashboard');
$this->assertInertia($client->getResponse(), function ($page) {
$page->component('Dashboard/Index')
->has('stats.visits')
->where('title', 'Dashboard');
});
}
}
Note what you can't do that you could in Laravel: no $this->actingAs() helper chain, and partial-reload assertions (->reloadOnly(), ->missing()) may not exist. For partial reload coverage, hit the endpoint with the real headers instead — see the curl trick below.
Maturity is the headline risk. 0 stars, 0 dependents, one release, one maintainer, and it targets Symfony 8 (with v2 release notes dated 2026). Pin an exact version, commit composer.lock, and budget time to read vendor/creativspeed/inertia-bundle/src — the docs are thin enough that the source is the real spec. Have a fallback plan: the protocol is simple enough to hand-roll a response factory if you must.
Docs drift in three places — all worth verifying against source on day one:
inertia()/inertiaHead() vs inertia_body()/inertia_head().render()'s third argument: README shows ['root_view' => 'admin'] as options; the assessment shows a two-arg form.No Ziggy equivalent. Laravel hands you route() in JS. Here you either pass URLs as props, install fos-routing-js, or hardcode paths in components. Decide before you write 30 components with hardcoded URLs.
CSRF will bite you. Inertia's default axios config reads XSRF-TOKEN from a cookie and sends X-XSRF-TOKEN — Symfony does not set that cookie. Either emit the token into the Twig shell as a <meta name="csrf-token"> and configure axios, or use Symfony's stateless CSRF. Symptom: every form.post() returns 419-ish and onError never fires.
Don't pass Doctrine entities straight into props. Laravel developers are used to JsonResource normalization. Here props are json_encoded, so a managed entity gives you circular-reference errors from lazy proxies or a leaked passwordHash field. Always map to arrays/DTOs, or implement JsonSerializable explicitly.
Validation errors are manual. See above — redirectWithErrors() or nothing. Also check whether flash data arrives under the key your Vue components expect; inspect the payload rather than guessing.
The Vite Twig functions come from pentatrion/vite-bundle. Missing it means a Unknown "vite_entry_script_tags" function error that looks like an Inertia bug.
Version mismatch → 409, not a page load. If you see a full-page reload instead of an XHR after a deploy, the manifest-derived version changed and the client's X-Inertia-Version is stale. That's expected (the bundle converts it), but it will surprise you during hot-reload — clear the Vite dev cache and rebuild.
Debug by inspecting the protocol directly. Send the headers Inertia sends and read the JSON:
curl -s -H 'X-Inertia: true' -H 'X-Inertia-Version: <manifest-hash>' \
-H 'X-Requested-With: XMLHttpRequest' http://localhost:8000/dashboard | jq .
curl -s -H 'X-Inertia: true' -H 'X-Inertia-Partial-Data: recentOrders' \
-H 'X-Inertia-Partial-Component: Dashboard/Index' http://localhost:8000/dashboard | jq .
The second call is how you verify lazy/deferred/merge props actually behave — far faster than clicking around, and it doubles as your partial-reload test.
Long-running workers. Symfony under FrankenPHP/RoadRunner keeps services alive between requests. If share() accumulates on a singleton, data leaks across users — catastrophic for an auth prop. Test with a two-request sequence as different users; if it leaks, move sharing into a request-scoped listener that resets, or decorate the service with ResetInterface.
Escaping. data-page="{{ page|json_encode|e('html_attr') }}" — never |raw. A </script> in a user-supplied prop is stored XSS otherwise.
Extension points, in order of usefulness: decorate InertiaInterface (cleanest way to add cross-cutting props or tracing); implement InertiaAuthUserInterface for auth shaping; a kernel.controller listener for per-controller sharing; per-request root_view for multi-layout apps. There's no middleware stack to hook, so decoration plus listeners is the whole toolbox.
Budget for missing Laravel ergonomics. No inertia:start-ssr, no dev-tooling, no AssertableInertia parity, no documented upgrade path. Treat v1 → v2 as a rename-and-verify exercise (the v2 notes flag two behaviour changes: automatic 302→303 conversion and asset-version conflict handling) and re-read the source diff rather than trusting the migration prose.
How can I help you explore Laravel packages today?