Skip to main content

Devlog · Phase 0 · 2/7

The aubia.dev Tech Stack

I built aubia.dev on a single Laravel application, with no database. Octane and FrankenPHP keep it in memory between requests, and React renders its pages on the server with Inertia.

Published on Updated on 10 min read

  • laravel
  • octane
  • frankenphp
  • inertia
  • vite

When your browser requests a page from aubia.dev, Cloudflare relays the request to the server hosted by Laravel Cloud, where FrankenPHP hands it to Laravel. Laravel prepares the page data and gets the HTML of its React components from a Node process. In your browser, React hydrates that HTML to make the page interactive.

The Laravel Framework at the Core of the Project

The site is a Laravel 13 application running on PHP 8.5. Laravel reads the language from the URL prefix, /fr or /en for example, before preparing the requested page. I use the same framework for aubia.cloud and for the desktop application, built with NativePHP.

I put the whole site in a single repository: the PHP code, the React pages, the six languages' text in JSON and the blog articles in Markdown are deployed together. During the build, the Artisan command blog:compile turns the articles into a catalogue that already contains their HTML.

No SQL connection is declared in config/database.php. Waitlist signups are stored by aubia.cloud: the public site forwards them with Laravel's HTTP client, in a signed request. The article on collecting email addresses without a local table describes this exchange and its dependence on the network in detail. Redis keeps the rest of the state, that is, sessions and the cache, rate limiter counters included.

The PHP code is organized into five modules: Blog, Cloud, Locale, Seo and Waitlist. Their Actions, their Queries and the gateway to aubia.cloud are described in the article on the five modules.

Performance Optimization with Laravel Octane

With PHP-FPM, Laravel is initialized for every request, even when a PHP process handles several requests before being recycled under settings such as pm.max_requests.

One initialization per request. PHP-FPM boots Laravel again before every response, even when the worker keeps its PID. The initialization takes 87 ms on my development machine.

On my development machine, a MacBook Pro M5 Pro running PHP 8.5 with opcache enabled, Laravel's initialization takes 87 ms. Every duration I recorded there is the median of at least ten measurements taken on September 8, 2026: an order of magnitude, specific to that machine.

Laravel Octane boots Laravel once per worker, then each worker handles the following requests with the application already loaded. Workers are sometimes recycled or restarted, during a deployment for example.

In production, Octane serves the aubia.dev home page with a median of 51 ms, according to Laravel Cloud's access logs from September 5 to 8, 2026, with bots excluded. A quarter of the requests take less than 48 ms, three quarters less than 64 ms.

On aubia.dev, Octane uses FrankenPHP, a PHP application server written in Go that embeds the Caddy web server.

Once FrankenPHP is installed and configured, OCTANE_SERVER selects it as the driver:

dotenv
# .env: the active driver on aubia.dev
OCTANE_SERVER=frankenphp
One initialization per worker. The worker loads Laravel once, then each request pays only for its own handling, until the worker is recycled. In production, the home page is served with a median of 51 ms, according to Laravel Cloud's access logs from September 5 to 8, 2026, with bots excluded.

Octane resets the framework's state between requests, without automatically clearing every global variable and static property in the application code. Data specific to one visitor can be reused for the next if it stays in a singleton or a static property. I therefore declare the server-side rendering gateway as scoped, which keeps state specific to the current request: the container recreates it for every request.

The Choice of FrankenPHP

The config/octane.php file defaults to FrankenPHP when no environment variable is set:

php
// config/octane.php
'server' => env('OCTANE_SERVER', 'frankenphp'),

RoadRunner is another server compatible with Octane workers. Swoole is also available, but requires a compiled PHP extension.

I chose FrankenPHP over RoadRunner for Caddy, the web server it embeds, and because its development remains active in the Laravel ecosystem. It is also easier to operate. My architecture decision record highlighted Caddy's HTTP/3 support and automatic certificates. On aubia.dev, FrankenPHP does not handle them: Cloudflare establishes the TLS and HTTP/3 connections with the browser, upstream of the server.

This binary only covers PHP: server-side rendering runs in a separate Node process.

Switching to RoadRunner would require its Composer packages and its binary, in addition to changing OCTANE_SERVER.

Inertia and React, from the Route to the Bundle

The pages are React 19 components written in TypeScript 7, memoized at build time by the React Compiler. Inertia 3 displays the component that a route or a controller designates by name, with its props. I wrote no API of my own for the website: apart from the confirmation and unsubscribe pages, which query aubia.cloud directly from the browser, the interface's data goes through these props.

The home page and the legal pages have no controller: in routes/web.php, Route::inertia('/', 'welcome') maps the URL to the page component. The blog goes through a controller, which looks up the requested article before calling Inertia::render('blog/show', ...).

A middleware adds shared props to every page, including the current language and the text for that language. Components read that text with a useT hook.

Wayfinder generates TypeScript functions from the Laravel routes. The card for an article gets its link by calling one of them with the language and the slug, instead of building the URL by hand. If the route is renamed, type checking fails.

Inertia's Server-Side Rendering

A React page rendered only in the browser depends on JavaScript loading and executing before it can display its content. Not all crawlers execute it, and those that do may defer that work. I want search engines to be able to read the content without having to execute the site's JavaScript.

With Inertia's SSR, Laravel sends the page and its data to a Node process. Node renders the React components to HTML and returns it to Laravel, which includes it in the response through the Blade view.

The initial HTML response therefore contains the page content and metadata. Laravel has to wait for Node's result before sending it. Node's rendering takes 6 ms on my development machine.

In the browser, React hydrates the received HTML to make the page's components interactive, the form included.

The HTML before the JavaScript. Laravel supplies the data, Node renders the page to HTML and Laravel includes it in the Blade view: the browser can read the content before hydrating it. Node's rendering takes 6 ms on my development machine.

In development, Inertia's Vite plugin handles SSR without a separate rendering server to start. In production, the Node process runs alongside the PHP server: it is a second process to operate.

The SSR configuration is declared in config/inertia.php:

php
// config/inertia.php, excerpt
'ssr' => [
    'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
    'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
    'url' => env('INERTIA_SSR_URL'), // default internal address omitted
    'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
    'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],

When the SSR bundle is missing, ensure_bundle_exists skips the call to Node, and Laravel falls back to client-side rendering. Setting throw_on_error to false allows the same fallback when rendering fails. The app.blade.php view keeps a title and description tailored to the page, the robots directives and, for indexable pages, the canonical URL and the available hreflang links.

During a transient SSR outage, I prefer this fallback to an error page, even if the display may be delayed. A crawler that does not execute JavaScript then no longer receives the article's content.

I replaced Inertia's native HTTP gateway with ObservedSsrGateway, a class that extends it. When SSR is enabled for the request, this gateway reports failures and missing, empty or invalid responses. The log keeps only the component and an error category. Non-empty but incomplete HTML, however, goes through without being reported.

The Counter After the Initial Render

The waitlist counter fetches its total from aubia.cloud and keeps it in a server-side cache. If the cache is empty, that network call can delay the response.

The HandleInertiaRequests middleware shares the counter as a deferred prop only on the home page, which displays it. The browser requests its value after the initial render:

php
// app/Http/Middleware/HandleInertiaRequests.php
...($request->routeIs('home') ? ['waitlist' => [
    'count' => Inertia::defer(fn(): ?int => app(FetchWaitlistCountQuery::class)->run(), rescue: true),
]] : []),

If the cache itself goes down, rescue: true omits the prop from the payload without failing the deferred request. The counter then stays absent from the page.

The deferred request takes 3 ms on my development machine, with a warm server cache.

The counter after the page. A deferred JSON request reads the total from Laravel's server cache, fed by aubia.cloud, without going through Node or server-side rendering.

The Build with Vite 8 and Rolldown

Vite 8 natively integrates its bundler, Rolldown, which is written in Rust. JavaScript dependencies are installed by Bun, which also runs the build scripts; server-side rendering is still executed by Node. The production build adds a dedicated SSR bundle, produced by vite build --ssr.

Code splitting is configured explicitly in the build configuration:

typescript
// 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,
                    },
                ],
            },
        },
    },
},

I declared three groups: React and its scheduler, the Motion animations and the Inertia client. The browser can reuse their cached files from one deployment to the next, as long as the build does not change their content.

During the production build, Sharp prepares WebP and AVIF variants of the covers and the avatar, without enlarging the sources. Vite writes them to /build/assets, under URLs that contain each file's hash. Displayed images use srcset, sizes and explicit dimensions; the original JPEGs remain available for social sharing. An article's cover has priority, while cards below the viewport are loaded on demand.

Tailwind CSS 4 Configured in the Stylesheet

Styling is done with Tailwind CSS 4, compiled by its Vite plugin. Its configuration is written in CSS, with no tailwind.config.js file: resources/css/app.css imports Tailwind, then the design system stylesheets, which include an @theme block that declares, among other things, the colors and the border radii. These values become classes that can be used in the components.

Buttons, menus and tooltips come from Aubia's design system, distributed as a shadcn registry. Their code is copied into the repository, under resources/js/components, and built on Base UI, which handles focus and keyboard navigation.

The design system also defines the two themes, the glass effect, the fonts and the animations. I cover its settings and limits in the article devoted to it.

Laravel Cloud for Hosting, Pest for Testing

Laravel Cloud hosts the site, with data stored in the European Union, and runs its two processes, FrankenPHP for Laravel and Node for SSR.

Laravel Nightwatch reports production errors and slow requests. The privacy policy specifies the data processed by Laravel Cloud, Cloudflare and Nightwatch.

The test suite is written with Pest 5. The unit and feature tests cover, among other things, the Actions, the controllers and the Inertia responses; the architecture tests check code rules, such as declare(strict_types=1) in every PHP file written for the project or the consistency of keys across the language files.

A feature test starts the real SSR bundle on Node and checks that the sentences of the home page appear in the HTML before any JavaScript runs in the browser. Browser tests go through Pest's Browser plugin, which drives Chromium with Playwright.

On every pull request, GitHub Actions checks the PHP with Pint, the TypeScript with Oxlint and Oxfmt, then the types. In parallel, the Pest suite runs in four batches, and the browser tests in a separate workflow.

In the tests, an HTTP request to an external service fails if it has not been faked, and the aubia.cloud responses are built with Http::fake. If the contract of the aubia.cloud API changes, none of the public site's tests detects it.

Aubia's beta is free, with no time limit. Sign up to receive your invitation.

Join the waitlist

Back to blog