Journal de bord · Phase 0 · 2/7
La stack technique d'aubia.dev
J'ai bâti aubia.dev sur une seule application Laravel, sans base de données. Octane et FrankenPHP la gardent en mémoire entre les requêtes, et React génère ses pages côté serveur avec Inertia.
par Mike, fondateur d'Aubia Publié le Mis à jour le 11 min de lecture
- laravel
- octane
- frankenphp
- inertia
- vite



Quand votre navigateur demande une page d'aubia.dev, Cloudflare relaie la requête au serveur hébergé par Laravel Cloud, où FrankenPHP la remet à Laravel. Laravel prépare les données de la page et obtient d'un processus Node le HTML de ses composants React. Dans votre navigateur, React hydrate ce HTML pour rendre la page interactive.
Le framework Laravel au cœur du projet
Le site est une application Laravel 13 sous PHP 8.5. Laravel lit la langue dans le préfixe de l'URL, /fr ou /en par exemple, avant de préparer la page demandée. J'utilise le même framework pour aubia.cloud et pour l'application desktop, construite avec NativePHP.
J'ai réuni tout le site dans un seul dépôt : le code PHP, les pages React, les textes des six langues en JSON et les articles du blog en Markdown sont déployés ensemble. Pendant le build, la commande Artisan blog:compile transforme les articles en un catalogue qui contient déjà leur HTML.
Aucune connexion SQL n'est déclarée dans config/database.php. Les inscriptions à la liste d'attente sont enregistrées par aubia.cloud : la vitrine les lui transmet avec le client HTTP de Laravel, dans une requête signée. L'article sur la collecte sans table locale détaille cet échange et sa dépendance au réseau. Redis conserve le reste de l'état, soit les sessions et le cache, compteurs du rate limiter compris.
Le code PHP est rangé en cinq modules, Blog, Cloud, Locale, Seo et Waitlist. Leurs Actions, leurs Queries et la passerelle vers aubia.cloud sont décrites dans l'article sur les cinq modules.
Optimisation des performances avec Laravel Octane
Avec PHP-FPM, Laravel est initialisé à chaque requête, même quand un processus PHP en traite plusieurs avant d'être recyclé selon des réglages comme pm.max_requests.
Sur ma machine de développement, un MacBook Pro M5 Pro sous PHP 8.5, opcache actif, l'initialisation de Laravel prend 87 ms. Chaque durée que j'y ai relevée est la médiane d'au moins dix mesures faites le 8 septembre 2026 : un ordre de grandeur, propre à cette machine.
Laravel Octane démarre Laravel une fois par worker, puis chaque worker traite les requêtes suivantes avec l'application déjà chargée. Les workers sont parfois recyclés ou redémarrés, lors d'un déploiement par exemple.
En production, la page d'accueil d'aubia.dev est servie par Octane avec une médiane de 51 ms, d'après les journaux d'accès de Laravel Cloud du 5 au 8 septembre 2026, robots exclus. Un quart des requêtes prend moins de 48 ms, trois quarts moins de 64 ms.
Sur aubia.dev, Octane utilise FrankenPHP, un serveur d'applications PHP écrit en Go qui intègre le serveur web Caddy.
Une fois FrankenPHP installé et configuré, OCTANE_SERVER le sélectionne comme driver :
# .env : le driver actif sur aubia.dev
OCTANE_SERVER=frankenphp
Octane réinitialise l'état du framework entre les requêtes, sans nettoyer automatiquement toutes les variables globales et propriétés statiques du code applicatif. Une donnée propre à un visiteur peut être réutilisée pour le suivant si elle reste dans un singleton ou une propriété statique. Je déclare donc en scoped la passerelle du rendu serveur, qui garde un état propre à la requête en cours : le conteneur la recrée à chaque requête.
Le choix de FrankenPHP
Le fichier config/octane.php prévoit FrankenPHP en l'absence de variable d'environnement :
// config/octane.php
'server' => env('OCTANE_SERVER', 'frankenphp'),
RoadRunner est un autre serveur compatible avec les workers d'Octane. Swoole est aussi disponible, mais exige une extension PHP compilée.
J'ai retenu FrankenPHP plutôt que RoadRunner pour Caddy, le serveur web qu'il embarque, et parce que son développement reste actif dans l'écosystème Laravel. Il est aussi plus simple à exploiter. Ma note de décision d'architecture mettait en avant HTTP/3 et les certificats automatiques de Caddy. Sur aubia.dev, FrankenPHP ne s'en charge pas : Cloudflare établit les connexions TLS et HTTP/3 avec le navigateur, en amont du serveur.
Ce binaire ne concerne que PHP : le rendu serveur s'exécute dans un processus Node séparé.
Passer à RoadRunner demanderait ses paquets Composer et son binaire, en plus de changer OCTANE_SERVER.
Inertia et React, de la route au bundle
Les pages sont des composants React 19 écrits en TypeScript 7, mémoïsés au build par le React Compiler. Inertia 3 affiche le composant qu'une route ou un controller désigne par son nom, avec ses props. Je n'ai écrit aucune API propre à la vitrine : hors des pages de confirmation et de désinscription, qui interrogent directement aubia.cloud depuis le navigateur, les données de l'interface passent par ces props.
L'accueil et les pages légales n'ont pas de controller : dans routes/web.php, Route::inertia('/', 'welcome') associe l'URL au composant de la page. Le blog passe par un controller, qui cherche l'article demandé avant d'appeler Inertia::render('blog/show', ...).
Un middleware ajoute à chaque page des props partagées, dont la langue courante et les textes de cette langue. Les composants lisent ces textes avec un hook useT.
Wayfinder génère des fonctions TypeScript à partir des routes Laravel. La carte d'un article obtient son lien en appelant l'une d'elles avec la langue et le slug, au lieu d'assembler l'URL à la main. Si la route change de nom, la vérification des types échoue.
Le rendu serveur d'Inertia
Une page React générée uniquement dans le navigateur dépend du chargement et de l'exécution du JavaScript pour afficher son contenu. Les robots ne l'exécutent pas tous, et ceux qui le font peuvent différer ce travail. Je veux que les moteurs de recherche puissent lire le contenu sans devoir exécuter le JavaScript du site.
Avec le SSR d'Inertia, Laravel transmet la page et ses données à un processus Node. Celui-ci génère le HTML des composants React et le renvoie à Laravel, qui l'intègre à la réponse avec la vue Blade.
La réponse HTML initiale contient ainsi le contenu et les métadonnées de la page. Laravel doit attendre le résultat de Node avant de l'envoyer. Le rendu par Node prend 6 ms sur ma machine de développement.
Dans le navigateur, React hydrate le HTML reçu pour rendre interactifs les composants de la page, dont le formulaire.
En développement, le plugin Vite d'Inertia prend en charge le SSR sans serveur de rendu à lancer séparément. En production, le processus Node tourne à côté du serveur PHP : c'est un second processus à exploiter.
La configuration du SSR est déclarée dans config/inertia.php :
// config/inertia.php, extrait
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
'url' => env('INERTIA_SSR_URL'), // adresse interne par défaut omise
'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],
Quand le bundle SSR est absent, ensure_bundle_exists évite l'appel à Node, et Laravel se rabat sur le rendu client. Le réglage throw_on_error à false permet le même repli quand le rendu échoue. La vue app.blade.php conserve un titre et une description adaptés à la page, les directives robots et, pour les pages indexables, le canonical et les hreflang disponibles.
En cas de panne SSR passagère, je préfère ce repli à une page d'erreur, même si l'affichage peut être retardé. Un robot qui n'exécute pas le JavaScript ne reçoit alors plus le contenu de l'article.
J'ai remplacé la passerelle HTTP native d'Inertia par ObservedSsrGateway, une classe qui l'étend. Lorsque le SSR est activé pour la requête, cette passerelle signale les échecs et les réponses absentes, vides ou invalides. Le journal ne conserve que le composant et une catégorie d'erreur. Un HTML non vide mais incomplet passe en revanche sans signalement.
Le compteur après le rendu initial
Le compteur de la liste d'attente récupère son total depuis aubia.cloud et le garde en cache côté serveur. Si le cache est vide, cet appel réseau peut retarder la réponse.
Le middleware HandleInertiaRequests partage le compteur en différé uniquement sur l'accueil, qui l'affiche. Le navigateur en demande la valeur après le rendu initial :
// app/Http/Middleware/HandleInertiaRequests.php
...($request->routeIs('home') ? ['waitlist' => [
'count' => Inertia::defer(fn(): ?int => app(FetchWaitlistCountQuery::class)->run(), rescue: true),
]] : []),
Si le cache lui-même tombe en panne, rescue: true omet la prop du payload sans faire échouer la requête différée. Le compteur reste alors absent de la page.
La requête différée prend 3 ms sur ma machine de développement, cache serveur chaud.
Le build avec Vite 8 et Rolldown
Vite 8 intègre nativement son bundler, Rolldown, écrit en Rust. Les dépendances JavaScript sont installées par Bun, qui lance aussi les scripts du build ; le rendu serveur reste exécuté par Node. Le build de production ajoute un bundle SSR dédié, produit par vite build --ssr.
Le code splitting est réglé explicitement dans la configuration de build :
// 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,
},
],
},
},
},
},
J'ai déclaré trois groupes : React et son scheduler, les animations Motion et le client Inertia. Le navigateur peut réutiliser leurs fichiers en cache d'un déploiement à l'autre, tant que le build ne modifie pas leur contenu.
Pendant le build de production, Sharp prépare les variantes WebP et AVIF des couvertures et de l'avatar, sans agrandir les sources. Vite les écrit dans /build/assets, sous des URL qui contiennent l'empreinte de chaque fichier. Les images affichées utilisent srcset, sizes et des dimensions explicites ; les JPEG d'origine restent disponibles pour le partage social. La couverture d'un article est prioritaire, tandis que les cartes sous la zone visible sont chargées à la demande.
Tailwind CSS 4 configuré dans la feuille de style
Le style passe par Tailwind CSS 4, compilé par son plugin Vite. Sa configuration est écrite en CSS, sans fichier tailwind.config.js : resources/css/app.css importe Tailwind, puis les feuilles du design system, dont un bloc @theme déclare entre autres les couleurs et les rayons. Ces valeurs deviennent des classes utilisables dans les composants.
Les boutons, les menus et les infobulles viennent du design system d'Aubia, distribué sous la forme d'un registre shadcn. Leur code est copié dans le dépôt, sous resources/js/components, et construit sur Base UI, qui gère le focus et la navigation au clavier.
Le design system définit aussi les deux thèmes, l'effet de verre, les polices et les animations. Je détaille ses réglages et ses limites dans l'article qui lui est consacré.
Laravel Cloud pour l'hébergement, Pest pour les tests
Laravel Cloud héberge le site, avec des données stockées dans l'Union européenne, et exécute ses deux processus, FrankenPHP pour Laravel et Node pour le SSR.
Laravel Nightwatch signale les erreurs et les requêtes lentes de la production. La politique de confidentialité précise les données que traitent Laravel Cloud, Cloudflare et Nightwatch.
La suite de tests est écrite avec Pest 5. Les tests unitaires et feature couvrent notamment les Actions, les controllers et les réponses Inertia ; ceux d'architecture contrôlent des règles de code, comme declare(strict_types=1) dans chaque fichier PHP écrit pour le projet ou la concordance des clés entre les fichiers de langue.
Un test feature démarre le vrai bundle SSR sous Node et vérifie que les phrases de l'accueil figurent dans le HTML avant toute exécution de JavaScript dans le navigateur. Les tests navigateur passent par le plugin Browser de Pest, qui pilote Chromium avec Playwright.
À chaque pull request, GitHub Actions contrôle le PHP avec Pint, le TypeScript avec Oxlint et Oxfmt, puis les types. En parallèle, la suite Pest s'exécute en quatre lots, et les tests navigateur dans un workflow à part.
Dans les tests, une requête HTTP vers un service externe échoue si elle n'a pas été simulée, et les réponses d'aubia.cloud sont fabriquées avec Http::fake. Si le contrat de l'API d'aubia.cloud change, aucun test de la vitrine ne le détecte.
La bêta d'Aubia est gratuite et sans limite de temps. Inscrivez-vous pour recevoir votre invitation.
Rejoindre la liste d'attente