Inertia.js mit Symfony: Der pragmatische Mittelweg zwischen Twig und einem vollständig entkoppelten SPA
In Symfony-Projekten gibt es einen typischen Wendepunkt: Das Admin-Panel ist über das hinausgewachsen, was Twig komfortabel rendern kann, die Frontend-Entwickler wünschen sich komponentenbasiertes React, aber niemand möchte ein vollständig entkoppeltes SPA pflegen - mit eigenem Routing, eigenem Auth-Status, eigenem API-Vertrag und eigenem Deployment-Prozess. Die übliche Reaktion ist entweder, weiterhin mit immer komplexerem Twig zu arbeiten, oder sich zu einem vollständigen architektonischen Rewrite zu verpflichten, der sechs Monate dauert und alles kaputtmacht.
Die Inertia.js-Symfony-Integration bietet einen dritten Weg. Sie ermöglicht es, React-Seitenkomponenten zu schreiben, während Symfony weiterhin für Routing, Authentifizierung und serverseitige Daten zuständig bleibt. Es gibt keine REST-API zu entwerfen, keine JSON-Serialisierungsschicht zu pflegen, keinen clientseitigen Router zu konfigurieren. Symfony-Controller geben Seitenkomponenten statt HTML zurück, und Inertia kümmert sich um den Rest.
Dieser Beitrag behandelt eine echte Symfony 7 + Inertia + React-Integration: Wie das Controller-zu-Komponenten-Mapping funktioniert, wie CSRF und Authentifizierung ohne API-Schicht ablaufen, wie die serverseitige Navigation in PHP verbleibt, wie SSR mit einem Node.js-Sidecar funktioniert und wie man ein Twig-Admin-Panel Seite für Seite ohne Big-Bang-Rewrite migriert.
Was Inertia.js eigentlich tut
Bevor wir zu den Implementierungsdetails kommen, hilft es, präzise zu beschreiben, was Inertia ist und was nicht. Inertia ist kein Frontend-Framework, kein Build-Tool und keine State-Management-Bibliothek. Es ist ein Adapterprotokoll - eine dünne Schicht, die zwischen einem serverseitigen Framework (Symfony, Laravel, Rails) und einem clientseitigen Komponentenframework (React, Vue, Svelte) sitzt.
Wenn ein Benutzer eine Seite besucht, gibt Inertia beim ersten Aufruf eine vollständige HTML-Antwort zurück - eine Standard-Symfony-Antwort mit einem einzigen Root-Element, das ein data-page-Attribut enthält, das den Komponentennamen und seine Props als JSON kodiert. Bei nachfolgenden Navigationen sendet der Browser eine XHR-Anfrage mit einem X-Inertia-Header, und Symfony gibt eine JSON-Antwort mit dem neuen Komponentennamen und neuen Props zurück. Der clientseitige Adapter von Inertia fängt die Antwort ab, tauscht die Komponente aus und aktualisiert den Browser-Verlauf. Aus Nutzerperspektive verhält es sich wie ein SPA. Aus Entwicklerperspektive hat die Routing-Logik PHP nie verlassen.
Die wichtigste Konsequenz: Deine Symfony-Routen definieren weiterhin die URL-Struktur. Deine Symfony-Controller übernehmen weiterhin Authentifizierung, Autorisierung und Datenladen. Deine Doctrine-Abfragen und deine Geschäftslogik bleiben unberührt. Das Einzige, was sich ändert, ist das, was der Controller zurückgibt - statt einer Twig-Antwort gibt er eine Inertia-Antwort zurück, die eine React-Komponente benennt und ihr Props übergibt.
Den Symfony-Inertia-Adapter installieren
Das community-gepflegte rompetomp/inertia-bundle ist der Standard-Symfony-Adapter. Installiere es zusammen mit dem Vite-Bundle und dem Inertia-React-Adapter:
composer require rompetomp/inertia-bundle
npm install @inertiajs/react react react-dom
npm install --save-dev @vitejs/plugin-react
Konfiguriere deine vite.config.ts, um das React-Plugin zu verwenden und das Symfony-Vite-Bundle auf deinen Einstiegspunkt zu richten:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import symfonyPlugin from 'vite-plugin-symfony';
export default defineConfig({
plugins: [react(), symfonyPlugin()],
build: {
rollupOptions: {
input: { app: './assets/app.tsx' },
},
},
});
In assets/app.tsx initialisierst du den Inertia-React-Adapter und verweist ihn auf deine Seitenkomponenten:
import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./pages/**/*.tsx', { eager: true });
return pages[`./pages/${name}.tsx`];
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
});
Dein Twig-Basis-Layout benötigt nur eine einzige Änderung - der inertia()-Helfer ersetzt den Hauptinhalt-Block:
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
{{ vite_entry_link_tags('app') }}
</head>
<body>
{{ inertia() }}
{{ vite_entry_script_tags('app') }}
</body>
</html>
Controller-zu-Komponenten-Mapping
Das Inertia-Response-Objekt ersetzt $this->render() in deinen Controllern. Das erste Argument ist der Komponentenpfad relativ zu deinem pages/-Verzeichnis; das zweite ist das Props-Array, das zu den getypten Props der Komponente wird.
// src/Controller/ProjectController.php
use Rompetomp\InertiaBundle\Service\InertiaInterface;
class ProjectController extends AbstractController
{
public function __construct(private InertiaInterface $inertia) {}
#[Route('/projects', name: 'project_index')]
public function index(ProjectRepository $repo): Response
{
return $this->inertia->render('Projects/Index', [
'projects' => $repo->findActiveForUser($this->getUser()),
'canCreate' => $this->isGranted('PROJECT_CREATE'),
]);
}
}
Auf der React-Seite empfängt die Komponente diese Props direkt:
// assets/pages/Projects/Index.tsx
interface Project {
id: number;
name: string;
status: string;
}
interface Props {
projects: Project[];
canCreate: boolean;
}
export default function ProjectIndex({ projects, canCreate }: Props) {
return (
<div>
{canCreate && <CreateProjectButton />}
{projects.map((p) => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
Beachte, was fehlt: kein fetch()-Aufruf, kein Ladezustand, kein API-Endpunkt zum Pflegen. Die Daten kommen von Symfony als Props. Die Komponente rendert sie. Das ist der grundlegende Unterschied zwischen Inertia und einem entkoppelten SPA - der Datenvertrag lebt in deinem PHP-Controller, nicht in einem HTTP-API-Schema.
Für Services, die eine benutzerdefinierte Serialisierung oder eine Zugriffskontrollprüfung auf Datenebene benötigen, hält das Umhüllen der Doctrine-Entität in ein dediziertes DTO vor der Übergabe an Inertia die Komponentenschnittstelle sauber und verhindert die versehentliche Exposition von Feldern, die das Frontend nie benötigt.
CSRF und Authentifizierung ohne API-Schicht
Eine der häufigsten Bedenken, wenn Teams ein entkoppeltes SPA in Betracht ziehen, ist die Auth-Komplexität. Bei einem konventionellen SPA muss man Token-basierte Auth implementieren (JWT oder OAuth), Token-Refresh-Logik pflegen, CORS behandeln und das Token außer Reichweite von XSS halten. Inertia umgeht all das, weil es kein entkoppeltes SPA ist - es läuft innerhalb einer Standard-Symfony-Session.
CSRF-Schutz funktioniert automatisch. Der clientseitige Adapter von Inertia liest das CSRF-Token aus dem Cookie, das Symfony setzt, und sendet es als X-XSRF-TOKEN-Header bei jeder mutierenden Anfrage. Die Symfony-CSRF-Middleware prüft es wie gewohnt. Keine spezielle Konfiguration erforderlich.
Authentifizierung wird durch Symfonys Sicherheitssystem gehandhabt. Du konfigurierst Firewalls, Zugriffskontrollregeln und Voter genau wie bei einer Twig-Anwendung. Wenn ein Benutzer nicht authentifiziert ist, leitet Symfony ihn zur Login-Seite um - Inertia folgt der Umleitung und rendert die Login-Komponente. Wenn einem Benutzer die Berechtigung für eine Ressource fehlt, wirft dein Controller AccessDeniedException und Symfonys Exception-Subscriber behandelt es. Das Frontend muss nie etwas über Authentifizierungslogik wissen; es empfängt einfach die Daten, die der Controller zu teilen entschieden hat.
Zum Teilen gemeinsamer Daten auf allen Seiten - Name des authentifizierten Benutzers, Rolle, Benachrichtigungsanzahl, globale Flash-Meldungen - bietet Inertia einen shared-Datenmechanismus. Konfiguriere ihn in einem Service oder Event-Listener:
// src/EventListener/InertiaShareListener.php
class InertiaShareListener
{
public function onKernelRequest(RequestEvent $event): void
{
$user = $this->security->getUser();
$this->inertia->share('auth', [
'user' => $user ? ['name' => $user->getName(), 'role' => $user->getRole()] : null,
]);
$this->inertia->share('flash', $this->session->getFlashBag()->all());
}
}
Jede Seitenkomponente erhält auth und flash, ohne dass der Controller sie explizit übergeben muss. Das ersetzt das Muster globaler Twig-Variablen sauber.
Wenn du Wolf-Techs Hilfe bei der Einrichtung von Symfony-Sicherheit für eine bestehende Anwendung oder der Migration von einem JWT-basierten Setup benötigst, sieh dir unsere Beratungsleistungen an - das ist Arbeit, die wir regelmäßig durchführen.
Servergesteuertes Routing hält die Routing-Logik in PHP
In einem entkoppelten SPA besitzt der clientseitige Router die URL-Struktur. Du definierst Routen in React Router oder Next.js, der Browser navigiert sie ohne den Server zu berühren, und das Backend liefert Daten via API. Das schafft ein Wartungsproblem: Deine URLs sind an zwei Stellen definiert - dem PHP-Backend, das sie absichert und validiert, und dem JavaScript-Router, der sie rendert. Wenn sich eine Route ändert, müssen beide aktualisiert werden.
Mit Inertia besitzt Symfony die URL-Struktur vollständig. Jede Navigation - Link-Klicks, Formular-Übermittlungen, programmatische Weiterleitungen - sendet eine Anfrage an Symfony. Symfonys Router löst sie auf, der Controller wird ausgeführt, und Inertia gibt entweder eine neue Komponente (für Navigation) oder eine Umleitung (für Formular-Übermittlungen) zurück. Das Frontend hat keine Kenntnis darüber, welche URL zu welcher Komponente gehört; dieses Mapping lebt in Symfonys Routing-Konfiguration.
In React-Komponenten verwendest du Inertias Link-Komponente für Navigation und router.visit() oder useForm() für programmatische Aktionen:
import { Link, useForm } from '@inertiajs/react';
function ProjectActions({ projectId }: { projectId: number }) {
const { post, processing } = useForm({});
return (
<>
<Link href={`/projects/${projectId}/edit`}>Bearbeiten</Link>
<button
onClick={() => post(`/projects/${projectId}/archive`)}
disabled={processing}
>
Archivieren
</button>
</>
);
}
Die URL /projects/${projectId}/edit wird einmalig in config/routes.yaml oder als PHP-Attribut definiert. Symfony validiert den Zugriff bei jedem Besuch. Es gibt keinen parallel zu pflegenden clientseitigen Routen-Guard.
SSR-Setup mit einem Node.js-Sidecar
Für Admin-Panels, die größtenteils hinter Authentifizierung liegen, ist serverseitiges Rendering optional. Die erste Seitenladung wird von Symfony serverseitig gerendert (Inertia gibt bei der ersten Anfrage immer ein vollständiges HTML-Dokument zurück), und nachfolgende Navigationen sind clientseitig. Suchmaschinen crawlen in der Regel nicht hinter Authentifizierung, daher ist SEO kein Thema.
Für öffentlich zugängliche Inertia-Seiten ist SSR relevant. Inertias SSR-Unterstützung funktioniert, indem ein Node.js-Prozess neben deiner Symfony-Anwendung läuft, der eine JSON-Darstellung der Komponente und ihrer Props empfängt, sie mit ReactDOMServer in einen HTML-String rendert und diesen String an Symfony zurückgibt, um ihn in die erste Antwort einzubetten.
Konfiguriere den SSR-Server in deiner inertia.yaml:
rompetomp_inertia:
ssr:
enabled: true
url: 'http://127.0.0.1:13714'
Erstelle das SSR-Bundle separat in deiner Vite-Konfiguration:
// vite.ssr.config.ts
export default defineConfig({
build: {
ssr: true,
rollupOptions: {
input: 'assets/ssr.tsx',
},
},
});
Führe den Node-Sidecar als überwachten Prozess (systemd-Unit, Docker-Sidecar-Container oder Supervisor) neben deinem PHP-FPM-Prozess aus. Wenn der SSR-Server nicht verfügbar ist, fällt Inertia graceful auf clientseitiges Rendering zurück - die Seite lädt noch, nur ohne server-gerendertes HTML in der ersten Antwort.
Für die meisten Symfony-Admin-Panels lohnt sich das SSR-Setup den operativen Aufwand nicht. Für Marketing-Seiten, öffentliche Dashboards oder SEO-kritische Inhalte, die du zu Inertia migrierst, ist es die Investition wert.
Ein Twig-Admin-Panel Seite für Seite migrieren
Der Migrationspfad von Twig zu Inertia erfordert keinen Big-Bang-Rewrite. Twig und Inertia koexistieren in derselben Anwendung - einige Controller geben Twig-Antworten zurück, andere Inertia-Antworten. Du migrierst eine Seite nach der anderen.
Eine praktische Migrationsreihenfolge: Beginne mit den Seiten, die in Twig am schwierigsten zu pflegen sind - typischerweise Seiten mit viel bereits eingebettetem JavaScript über <script>-Blöcke, oder Seiten, auf denen deine Frontend-Entwickler schon nach React-Komponenten-Isolation gefragt haben. Diese Seiten haben am meisten zu gewinnen und haben bereits den meisten Kontext verfügbar.
Bei jeder Seitenmigration ist die Controller-Änderung minimal. Ersetze $this->render('admin/projects/index.html.twig', [...]) durch $this->inertia->render('Admin/Projects/Index', [...]). Konvertiere die Datenweitergabe-Logik des Twig-Templates in ein getyptes Props-Array. Erstelle die React-Komponente. Betreibe beide während einer Übergangszeit parallel, wenn nötig - Symfonys Routing ermöglicht es, die alte Twig-URL und eine neue /v2/-Inertia-URL gleichzeitig für Tests bereitzustellen.
Die Datenschicht ändert sich während der Migration überhaupt nicht. Deine Doctrine-Repositories, deine Service-Klassen, deine Sicherheits-Voter - nichts davon wird berührt. Du änderst nur die Präsentationsschicht, was genau das ist, was eine View-Migration sein sollte.
Für Formulare ersetzt du das Rendern von Symfony-Form-Komponenten durch Inertias useForm()-Hook. Die Validierungsfehler, die Symfony bei einer fehlgeschlagenen Formular-Übermittlung zurückgibt, sind automatisch als errors in der Inertia-Antwort verfügbar, ohne spezielle Fehlerbehandlung auf der PHP-Seite:
if ($form->isSubmitted() && !$form->isValid()) {
return back()->withErrors(
collect($form->getErrors(true))->mapWithKeys(fn($e) => [
$e->getOrigin()->getName() => $e->getMessage()
])->all()
);
}
Auf der React-Seite enthält errors.fieldName die serverseitige Validierungsmeldung, bereit zur Inline-Anzeige.
Wann Inertia die richtige Wahl ist - und wann nicht
Inertias Kernbereich sind servergerenderte Anwendungen, bei denen die Frontend-Komplexität über Twigs Komfortzone hinausgewachsen ist, aber ein separates Frontend-Deployment, ein entworfener API-Vertrag und clientseitige Auth mehr Komplexität hinzufügen würden, als sie lösen. Admin-Panels, interne Tools, SaaS-Dashboards und B2B-Anwendungsinterfaces passen typischerweise gut in dieses Profil.
Inertia ist nicht die richtige Wahl für Anwendungen, die sowieso eine öffentliche API benötigen - wenn mobile Apps, Drittanbieter-Integrationen oder öffentliche Entwickler-APIs auf der Roadmap stehen, baue zuerst die API und lass das React-Frontend sie wie jeden anderen Client konsumieren. Inertias eng gekoppelte Server-zu-Komponenten-Bindung ist eine Schwäche, wenn der Datenvertrag von mehreren Clients konsumiert werden muss.
Es ist auch keine gute Wahl für umfangreichen clientseitigen Status - Anwendungen, bei denen der Großteil des interessanten Zustands im Browser lebt (kollaborative Bearbeitungstools, Echtzeit-Dashboards mit WebSocket-Daten, komplexe clientseitige Workflows) profitieren von einer Frontend-Architektur, die den Server als Datenquelle behandelt, nicht als Seitenorchestrator.
Für die Symfony-Teams, mit denen wir bei Wolf-Tech arbeiten, war Inertia ungefähr dann die richtige Antwort, wenn die Frage lautet: "Wie machen wir dieses Admin-Panel wartbar, ohne das gesamte Backend neu zu bauen?" Wenn diese Frage vertraut klingt, melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io, um deine spezifische Situation zu besprechen.
Der Mittelweg ist oft der pragmatischste.

