Devlog · Phase 0 · 2/7
Der Tech-Stack von aubia.dev
Ich habe aubia.dev auf einer einzigen Laravel-Anwendung ohne Datenbank gebaut. Octane und FrankenPHP halten sie zwischen den Anfragen im Speicher, React rendert die Seiten mit Inertia auf dem Server.
von Mike, Gründer von Aubia Veröffentlicht am Aktualisiert am 10 Min. Lesezeit
- laravel
- octane
- frankenphp
- inertia
- vite



Wenn Ihr Browser eine Seite von aubia.dev anfordert, leitet Cloudflare die Anfrage an den bei Laravel Cloud gehosteten Server weiter, wo FrankenPHP sie an Laravel übergibt. Laravel bereitet die Seitendaten vor und erhält von einem Node-Prozess das HTML seiner React-Komponenten. In Ihrem Browser hydriert React dieses HTML, damit die Seite interaktiv wird.
Das Laravel-Framework im Kern des Projekts
Die Website ist eine Anwendung mit Laravel 13 unter PHP 8.5. Laravel liest die Sprache aus dem URL-Präfix, zum Beispiel /fr oder /en, bevor es die angeforderte Seite vorbereitet. Dasselbe Framework verwende ich für aubia.cloud und für die Desktop-Anwendung, die mit NativePHP gebaut ist.
Ich habe die gesamte Website in einem einzigen Repository zusammengefasst: Der PHP-Code, die React-Seiten, die Texte der sechs Sprachen in JSON und die Blogartikel in Markdown werden gemeinsam deployt. Beim Build wandelt der Artisan-Befehl blog:compile die Artikel in einen Katalog um, der bereits ihr HTML enthält.
In config/database.php ist keine SQL-Verbindung deklariert. Die Anmeldungen zur Warteliste speichert aubia.cloud: Die öffentliche Website leitet sie mit dem HTTP-Client von Laravel in einer signierten Anfrage weiter. Der Artikel über das Sammeln von E-Mail-Adressen ohne lokale Tabelle beschreibt diesen Austausch und seine Abhängigkeit vom Netzwerk im Detail. Redis hält den übrigen Zustand, also Sitzungen und Cache, einschließlich der Zähler des Rate Limiters.
Der PHP-Code ist in fünf Module gegliedert: Blog, Cloud, Locale, Seo und Waitlist. Ihre Actions, ihre Queries und das Gateway zu aubia.cloud beschreibt der Artikel über die fünf Module.
Leistungsoptimierung mit Laravel Octane
Mit PHP-FPM wird Laravel bei jeder Anfrage initialisiert, selbst wenn ein PHP-Prozess mehrere Anfragen bearbeitet, bevor er gemäß Einstellungen wie pm.max_requests ersetzt wird.
Auf meinem Entwicklungsrechner, einem MacBook Pro M5 Pro mit PHP 8.5 und aktivem Opcache, dauert die Initialisierung von Laravel 87 ms. Jede dort erfasste Dauer ist der Median aus mindestens zehn Messungen vom 8. September 2026: eine Größenordnung, die nur für diesen Rechner gilt.
Laravel Octane startet Laravel einmal pro Worker, danach bearbeitet jeder Worker die folgenden Anfragen mit der bereits geladenen Anwendung. Worker werden gelegentlich erneuert oder neu gestartet, etwa bei einem Deployment.
In Produktion liefert Octane die Startseite von aubia.dev mit einem Median von 51 ms aus, laut den Zugriffsprotokollen von Laravel Cloud vom 5. bis 8. September 2026, Bots ausgenommen. Ein Viertel der Anfragen dauert weniger als 48 ms, drei Viertel weniger als 64 ms.
Auf aubia.dev nutzt Octane FrankenPHP, einen in Go geschriebenen PHP-Anwendungsserver mit eingebettetem Caddy-Webserver.
Nach der Installation und Konfiguration von FrankenPHP wählt OCTANE_SERVER es als Treiber aus:
# .env: der aktive Treiber auf aubia.dev
OCTANE_SERVER=frankenphp
Octane setzt den Zustand des Frameworks zwischen den Anfragen zurück, ohne automatisch jede globale Variable und statische Eigenschaft im Anwendungscode zu leeren. Daten eines Besuchers können für den nächsten wiederverwendet werden, wenn sie in einem Singleton oder einer statischen Eigenschaft verbleiben. Ich deklariere deshalb das Gateway für das Server-Rendering, das einen Zustand der aktuellen Anfrage hält, als scoped: Der Container erzeugt es bei jeder Anfrage neu.
Die Entscheidung für FrankenPHP
Die Datei config/octane.php verwendet standardmäßig FrankenPHP, wenn keine Umgebungsvariable gesetzt ist:
// config/octane.php
'server' => env('OCTANE_SERVER', 'frankenphp'),
RoadRunner ist ein weiterer Server, der mit Octane-Workern kompatibel ist. Auch Swoole steht zur Verfügung, benötigt aber eine kompilierte PHP-Erweiterung.
Ich habe mich für FrankenPHP statt RoadRunner entschieden, wegen Caddy, dem Webserver, den es einbettet, und weil seine Entwicklung im Laravel-Ökosystem aktiv bleibt. Es ist außerdem einfacher zu betreiben. Mein Architekturentscheidungsprotokoll hob Caddys Unterstützung für HTTP/3 und automatische Zertifikate hervor. Auf aubia.dev übernimmt FrankenPHP diese nicht: Cloudflare baut die TLS- und HTTP/3-Verbindungen mit dem Browser auf, dem Server vorgeschaltet.
Diese Binärdatei deckt nur PHP ab: Das Server-Rendering läuft in einem separaten Node-Prozess.
Ein Wechsel zu RoadRunner würde zusätzlich zur Änderung von OCTANE_SERVER dessen Composer-Pakete und dessen Binärdatei erfordern.
Inertia und React, von der Route bis zum Bundle
Die Seiten sind Komponenten in React 19, geschrieben in TypeScript 7 und beim Build vom React Compiler memoisiert. Inertia 3 zeigt die Komponente an, die eine Route oder ein Controller per Namen bestimmt, zusammen mit ihren Props. Ich habe keine eigene API für die Website geschrieben: Abgesehen von den Bestätigungs- und Abmeldeseiten, die aubia.cloud direkt aus dem Browser abfragen, laufen die Daten der Benutzeroberfläche über diese Props.
Die Startseite und die rechtlichen Seiten haben keinen Controller: In routes/web.php ordnet Route::inertia('/', 'welcome') die URL der Seitenkomponente zu. Der Blog läuft über einen Controller, der den angeforderten Artikel sucht, bevor er Inertia::render('blog/show', ...) aufruft.
Eine Middleware ergänzt jede Seite um gemeinsame Props, darunter die aktuelle Sprache und die Texte dieser Sprache. Die Komponenten lesen diese Texte mit einem useT-Hook.
Wayfinder erzeugt TypeScript-Funktionen aus den Laravel-Routen. Die Karte eines Artikels erhält ihren Link, indem sie eine davon mit der Sprache und dem Slug aufruft, statt die URL von Hand zusammenzusetzen. Wird die Route umbenannt, schlägt die Typprüfung fehl.
Das Server-Rendering von Inertia
Eine React-Seite, die nur im Browser gerendert wird, hängt davon ab, dass JavaScript geladen und ausgeführt wird, bevor sie ihren Inhalt anzeigen kann. Nicht alle Crawler führen es aus, und diejenigen, die es tun, können diese Arbeit aufschieben. Ich möchte, dass Suchmaschinen den Inhalt lesen können, ohne das JavaScript der Website ausführen zu müssen.
Mit dem SSR von Inertia sendet Laravel die Seite und ihre Daten an einen Node-Prozess. Node rendert die React-Komponenten zu HTML und gibt es an Laravel zurück, das es über die Blade-View in die Antwort einfügt.
Die initiale HTML-Antwort enthält daher den Seiteninhalt und die Metadaten. Laravel muss auf das Ergebnis von Node warten, bevor es die Antwort sendet. Das Rendering durch Node dauert auf meinem Entwicklungsrechner 6 ms.
Im Browser hydriert React das empfangene HTML und macht damit die Komponenten der Seite interaktiv, das Formular eingeschlossen.
In der Entwicklung übernimmt das Vite-Plugin von Inertia das SSR, ohne dass ein separater Rendering-Server gestartet werden muss. In Produktion läuft der Node-Prozess neben dem PHP-Server: Er ist ein zweiter Prozess, der betrieben werden muss.
Die Konfiguration des SSR ist in config/inertia.php deklariert:
// config/inertia.php, Auszug
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
'url' => env('INERTIA_SSR_URL'), // interne Standardadresse ausgelassen
'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],
Fehlt das SSR-Bundle, überspringt ensure_bundle_exists den Aufruf an Node, und Laravel fällt auf clientseitiges Rendering zurück. Die Einstellung throw_on_error auf false erlaubt denselben Fallback, wenn das Rendering fehlschlägt. Die View app.blade.php behält einen seitenspezifischen Titel und eine Beschreibung, Robots-Anweisungen sowie bei indexierbaren Seiten die kanonische URL und die verfügbaren hreflang-Links.
Bei einem vorübergehenden SSR-Ausfall ziehe ich diesen Fallback einer Fehlerseite vor, auch wenn die Anzeige verzögert erfolgen kann. Ein Crawler, der kein JavaScript ausführt, erhält dann den Artikelinhalt nicht mehr.
Das native HTTP-Gateway von Inertia habe ich durch ObservedSsrGateway ersetzt, eine Klasse, die es erweitert. Wenn SSR für die Anfrage aktiviert ist, meldet dieses Gateway Fehler sowie fehlende, leere oder ungültige Antworten. Das Protokoll enthält nur die Komponente und eine Fehlerkategorie. Nicht leeres, aber unvollständiges HTML wird dagegen ohne Meldung durchgelassen.
Der Zähler nach dem initialen Rendering
Der Wartelistenzähler ruft seine Gesamtzahl von aubia.cloud ab und hält sie in einem serverseitigen Cache. Ist der Cache leer, kann dieser Netzwerkaufruf die Antwort verzögern.
Die Middleware HandleInertiaRequests stellt den Zähler nur auf der Startseite, die ihn anzeigt, als verzögert geladene Prop bereit. Der Browser fordert seinen Wert nach dem initialen Rendering an:
// app/Http/Middleware/HandleInertiaRequests.php
...($request->routeIs('home') ? ['waitlist' => [
'count' => Inertia::defer(fn(): ?int => app(FetchWaitlistCountQuery::class)->run(), rescue: true),
]] : []),
Fällt der Cache selbst aus, lässt rescue: true die Prop im Payload weg, ohne die verzögerte Anfrage scheitern zu lassen. Der Zähler fehlt dann auf der Seite.
Die verzögerte Anfrage dauert auf meinem Entwicklungsrechner 3 ms, bei warmem Server-Cache.
Der Build mit Vite 8 und Rolldown
Vite 8 integriert seinen Bundler Rolldown, der in Rust geschrieben ist, nativ. Die JavaScript-Abhängigkeiten installiert Bun, das auch die Build-Skripte ausführt; das Server-Rendering führt weiterhin Node aus. Der Produktions-Build fügt ein dediziertes SSR-Bundle hinzu, das mit vite build --ssr erzeugt wird.
Code Splitting ist in der Build-Konfiguration ausdrücklich eingerichtet:
// vite.config.ts
build: {
rolldownOptions: {
output: {
codeSplitting: {
minSize: 20_000,
groups: [
{
name: 'react',
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 50,
},
{
name: 'motion',
test: /[\\/]node_modules[\\/](motion|motion-dom|motion-utils)[\\/]/,
priority: 40,
},
{
name: 'inertia',
test: /[\\/]node_modules[\\/]@inertiajs[\\/]/,
priority: 20,
},
],
},
},
},
},
Ich habe drei Gruppen deklariert: React und seinen Scheduler, die Motion-Animationen und den Inertia-Client. Der Browser kann ihre Dateien aus dem Cache von einem Deployment zum nächsten wiederverwenden, solange der Build ihren Inhalt nicht ändert.
Während des Produktions-Builds erstellt Sharp WebP- und AVIF-Varianten der Titelbilder und des Avatars, ohne die Quelldateien zu vergrößern. Vite schreibt sie unter URLs, die den Hash jeder Datei enthalten, nach /build/assets. Die angezeigten Bilder verwenden srcset, sizes und explizite Abmessungen; die ursprünglichen JPEGs bleiben für das Teilen in sozialen Netzwerken verfügbar. Das Titelbild eines Artikels hat Priorität, während Karten unterhalb des Viewports bei Bedarf geladen werden.
Tailwind CSS 4, im Stylesheet konfiguriert
Das Styling erfolgt mit Tailwind CSS 4, kompiliert von dessen Vite-Plugin. Die Konfiguration ist in CSS geschrieben, ohne Datei tailwind.config.js: resources/css/app.css importiert Tailwind und danach die Stylesheets des Design-Systems, zu denen ein @theme-Block gehört, der unter anderem die Farben und die Eckenradien deklariert. Diese Werte werden zu Klassen, die in den Komponenten verwendet werden können.
Buttons, Menüs und Tooltips stammen aus dem Design-System von Aubia, das als shadcn-Registry verteilt wird. Ihr Code wird in das Repository kopiert, unter resources/js/components, und baut auf Base UI auf, das Fokus und Tastaturnavigation übernimmt.
Das Design-System definiert außerdem die beiden Themes, den Glaseffekt, die Schriften und die Animationen. Seine Einstellungen und Grenzen behandle ich in dem ihm gewidmeten Artikel.
Laravel Cloud für das Hosting, Pest für die Tests
Laravel Cloud hostet die Website, mit Daten, die in der Europäischen Union gespeichert werden, und betreibt ihre beiden Prozesse, FrankenPHP für Laravel und Node für das SSR.
Laravel Nightwatch meldet Fehler und langsame Anfragen in der Produktion. Die Datenschutzrichtlinie benennt die Daten, die Laravel Cloud, Cloudflare und Nightwatch verarbeiten.
Die Testsuite ist mit Pest 5 geschrieben. Die Unit- und Feature-Tests decken unter anderem die Actions, die Controller und die Inertia-Antworten ab; die Architekturtests prüfen Coderegeln, etwa declare(strict_types=1) in jeder für das Projekt geschriebenen PHP-Datei oder die Übereinstimmung der Schlüssel zwischen den Sprachdateien.
Ein Feature-Test startet das echte SSR-Bundle unter Node und prüft, dass die Sätze der Startseite im HTML erscheinen, bevor im Browser JavaScript ausgeführt wird. Die Browser-Tests laufen über das Browser-Plugin von Pest, das Chromium mit Playwright steuert.
Bei jedem Pull Request prüft GitHub Actions das PHP mit Pint, das TypeScript mit Oxlint und Oxfmt, anschließend die Typen. Parallel dazu läuft die Pest-Suite in vier Batches, die Browser-Tests in einem separaten Workflow.
In den Tests schlägt eine HTTP-Anfrage an einen externen Dienst fehl, wenn sie nicht simuliert wurde, und die Antworten von aubia.cloud werden mit Http::fake erzeugt. Ändert sich der Vertrag der aubia.cloud-API, erkennt keiner der Tests der öffentlichen Website dies.
Die Beta von Aubia ist kostenlos und zeitlich unbegrenzt. Melden Sie sich an, um Ihre Einladung zu erhalten.
Auf die Warteliste