Ir al contenido principal

Devlog · Fase 0 · 2/7

La stack técnica de aubia.dev

Construí aubia.dev sobre una sola aplicación Laravel, sin base de datos. Octane y FrankenPHP la mantienen en memoria entre solicitudes, y React renderiza sus páginas en el servidor con Inertia.

Publicado el Actualizado el 11 min de lectura

  • laravel
  • octane
  • frankenphp
  • inertia
  • vite

Cuando su navegador solicita una página de aubia.dev, Cloudflare reenvía la solicitud al servidor alojado en Laravel Cloud, donde FrankenPHP se la entrega a Laravel. Laravel prepara los datos de la página y obtiene de un proceso Node el HTML de sus componentes React. En su navegador, React hidrata ese HTML para que la página sea interactiva.

El framework Laravel en el centro del proyecto

El sitio es una aplicación Laravel 13 sobre PHP 8.5. Laravel lee el idioma en el prefijo de la URL, /fr o /en por ejemplo, antes de preparar la página solicitada. Utilizo el mismo framework para aubia.cloud y para la aplicación de escritorio, desarrollada con NativePHP.

Reuní todo el sitio en un único repositorio: el código PHP, las páginas React, los textos de los seis idiomas en JSON y los artículos del blog en Markdown se despliegan juntos. Durante el build, el comando Artisan blog:compile convierte los artículos en un catálogo que ya contiene su HTML.

En config/database.php no se declara ninguna conexión SQL. Las inscripciones en la lista de espera las almacena aubia.cloud: el sitio público se las reenvía con el cliente HTTP de Laravel, en una solicitud firmada. El artículo sobre la recopilación de direcciones de correo sin tabla local describe en detalle este intercambio y su dependencia de la red. Redis conserva el resto del estado, es decir, las sesiones y la caché, incluidos los contadores del rate limiter.

El código PHP se organiza en cinco módulos: Blog, Cloud, Locale, Seo y Waitlist. Sus Actions, sus Queries y la pasarela hacia aubia.cloud se describen en el artículo sobre los cinco módulos.

Optimización del rendimiento con Laravel Octane

Con PHP-FPM, Laravel se inicializa en cada solicitud, incluso cuando un proceso PHP atiende varias antes de reciclarse según ajustes como pm.max_requests.

Una inicialización por solicitud. PHP-FPM vuelve a inicializar Laravel antes de cada respuesta, aunque el worker conserve su PID. La inicialización tarda 87 ms en mi máquina de desarrollo.

En mi máquina de desarrollo, un MacBook Pro M5 Pro con PHP 8.5 y opcache activado, la inicialización de Laravel tarda 87 ms. Cada duración que registré allí es la mediana de al menos diez mediciones tomadas el 8 de septiembre de 2026: un orden de magnitud, propio de esa máquina.

Laravel Octane arranca Laravel una vez por worker, y después cada worker atiende las solicitudes siguientes con la aplicación ya cargada. Los workers se reciclan o se reinician a veces, durante un despliegue por ejemplo.

En producción, Octane sirve la página de inicio de aubia.dev con una mediana de 51 ms, según los registros de acceso de Laravel Cloud del 5 al 8 de septiembre de 2026, con los robots excluidos. Una cuarta parte de las solicitudes tarda menos de 48 ms y tres cuartas partes, menos de 64 ms.

En aubia.dev, Octane utiliza FrankenPHP, un servidor de aplicaciones PHP escrito en Go que integra el servidor web Caddy.

Una vez instalado y configurado FrankenPHP, OCTANE_SERVER lo selecciona como driver:

dotenv
# .env: el driver activo en aubia.dev
OCTANE_SERVER=frankenphp
Una inicialización por worker. El worker carga Laravel una vez y, después, cada solicitud solo paga su propio procesamiento, hasta el reciclaje. En producción, la página de inicio se sirve con una mediana de 51 ms, según los registros de acceso de Laravel Cloud del 5 al 8 de septiembre de 2026, con los robots excluidos.

Octane restablece el estado del framework entre solicitudes, sin borrar automáticamente todas las variables globales y propiedades estáticas del código de la aplicación. Los datos propios de un visitante pueden reutilizarse para el siguiente si permanecen en un singleton o en una propiedad estática. Por eso declaro como scoped la pasarela del renderizado en el servidor, que conserva un estado propio de la solicitud en curso: el contenedor la recrea en cada solicitud.

La elección de FrankenPHP

El archivo config/octane.php utiliza FrankenPHP por defecto cuando no se define ninguna variable de entorno:

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

RoadRunner es otro servidor compatible con los workers de Octane. Swoole también está disponible, pero requiere una extensión PHP compilada.

Elegí FrankenPHP en lugar de RoadRunner por Caddy, el servidor web que integra, y porque su desarrollo sigue activo en el ecosistema de Laravel. También es más fácil de operar. Mi registro de decisión de arquitectura destacaba la compatibilidad de Caddy con HTTP/3 y los certificados automáticos. En aubia.dev, FrankenPHP no se encarga de ellos: Cloudflare establece las conexiones TLS y HTTP/3 con el navegador, antes de llegar al servidor.

Este binario solo cubre PHP: el renderizado en el servidor se ejecuta en un proceso Node aparte.

Cambiar a RoadRunner exigiría sus paquetes de Composer y su binario, además de modificar OCTANE_SERVER.

Inertia y React, de la ruta al bundle

Las páginas son componentes de React 19 escritos en TypeScript 7, memoizados en el build por el React Compiler. Inertia 3 muestra el componente que una ruta o un controller designa por su nombre, con sus props. No escribí ninguna API propia para el sitio: salvo las páginas de confirmación y de baja, que consultan aubia.cloud directamente desde el navegador, los datos de la interfaz pasan por esas props.

La página de inicio y las páginas legales no tienen controller: en routes/web.php, Route::inertia('/', 'welcome') asocia la URL con el componente de la página. El blog pasa por un controller, que busca el artículo solicitado antes de llamar a Inertia::render('blog/show', ...).

Un middleware añade props compartidas a cada página, entre ellas el idioma actual y los textos de ese idioma. Los componentes leen esos textos con un hook useT.

Wayfinder genera funciones TypeScript a partir de las rutas de Laravel. La tarjeta de un artículo obtiene su enlace llamando a una de ellas con el idioma y el slug, en lugar de construir la URL a mano. Si se cambia el nombre de la ruta, la comprobación de tipos falla.

El renderizado en el servidor de Inertia

Una página React renderizada solo en el navegador depende de que el JavaScript se cargue y se ejecute para poder mostrar su contenido. No todos los rastreadores lo ejecutan, y los que lo hacen pueden aplazar ese trabajo. Quiero que los buscadores puedan leer el contenido sin tener que ejecutar el JavaScript del sitio.

Con el SSR de Inertia, Laravel envía la página y sus datos a un proceso Node. Node renderiza los componentes React a HTML y se lo devuelve a Laravel, que lo incluye en la respuesta mediante la vista Blade.

La respuesta HTML inicial contiene, por tanto, el contenido y los metadatos de la página. Laravel debe esperar el resultado de Node antes de enviarla. El renderizado de Node tarda 6 ms en mi máquina de desarrollo.

En el navegador, React hidrata el HTML recibido para que los componentes de la página, el formulario incluido, sean interactivos.

El HTML antes que el JavaScript. Laravel aporta los datos, Node renderiza la página a HTML y Laravel lo incluye en la vista Blade: el navegador puede leer el contenido antes de hidratarlo. El renderizado de Node tarda 6 ms en mi máquina de desarrollo.

En desarrollo, el plugin Vite de Inertia gestiona el SSR sin un servidor de renderizado aparte que haya que iniciar. En producción, el proceso Node se ejecuta junto al servidor PHP: es un segundo proceso que hay que operar.

La configuración del SSR se declara en config/inertia.php:

php
// config/inertia.php, fragmento
'ssr' => [
    'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
    'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
    'url' => env('INERTIA_SSR_URL'), // dirección interna predeterminada omitida
    'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
    'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],

Cuando falta el bundle SSR, ensure_bundle_exists evita la llamada a Node y Laravel recurre al renderizado en el cliente. Con throw_on_error en false, se permite la misma alternativa cuando falla el renderizado. La vista app.blade.php conserva un título y una descripción propios de la página, las directivas robots y, para las páginas indexables, la URL canónica y los enlaces hreflang disponibles.

Durante una caída transitoria del SSR, prefiero esa alternativa a una página de error, aunque la visualización pueda retrasarse. Un rastreador que no ejecuta JavaScript deja entonces de recibir el contenido del artículo.

Sustituí la pasarela HTTP nativa de Inertia por ObservedSsrGateway, una clase que la extiende. Cuando el SSR está activado para la solicitud, esta pasarela señala los fallos y las respuestas ausentes, vacías o inválidas. El registro solo conserva el componente y una categoría de error. En cambio, un HTML no vacío pero incompleto pasa sin ser señalado.

El contador tras el renderizado inicial

El contador de la lista de espera obtiene su total de aubia.cloud y lo guarda en una caché del servidor. Si la caché está vacía, esa llamada de red puede retrasar la respuesta.

El middleware HandleInertiaRequests comparte el contador como prop diferida solo en la página de inicio, que lo muestra. El navegador solicita su valor después del renderizado inicial:

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

Si la propia caché deja de funcionar, rescue: true omite la prop del payload sin hacer fallar la solicitud diferida. El contador queda entonces ausente de la página.

La solicitud diferida tarda 3 ms en mi máquina de desarrollo, con la caché del servidor caliente.

El contador después de la página. Una solicitud JSON diferida lee el total en la caché del servidor de Laravel, alimentada por aubia.cloud, sin pasar por Node ni por el renderizado en el servidor.

El build con Vite 8 y Rolldown

Vite 8 integra de forma nativa su bundler, Rolldown, escrito en Rust. Las dependencias JavaScript las instala Bun, que también ejecuta los scripts del build; el renderizado en el servidor sigue ejecutándose con Node. El build de producción añade un bundle SSR dedicado, producido por vite build --ssr.

El code splitting se define explícitamente en la configuración del build:

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

Declaré tres grupos: React y su scheduler, las animaciones de Motion y el cliente de Inertia. El navegador puede reutilizar sus archivos en caché de un despliegue a otro, siempre que el build no modifique su contenido.

Durante el build de producción, Sharp prepara variantes WebP y AVIF de las portadas y del avatar, sin ampliar las fuentes. Vite las escribe en /build/assets, con URL que contienen el hash de cada archivo. Las imágenes que se muestran usan srcset, sizes y dimensiones explícitas; los JPEG originales siguen disponibles para compartir en redes sociales. La portada de un artículo tiene prioridad, mientras que las tarjetas situadas por debajo del viewport se cargan bajo demanda.

Tailwind CSS 4 configurado en la hoja de estilo

El estilo se resuelve con Tailwind CSS 4, compilado por su plugin de Vite. Su configuración se escribe en CSS, sin archivo tailwind.config.js: resources/css/app.css importa Tailwind y después las hojas de estilo del design system, entre las que hay un bloque @theme que declara, entre otras cosas, los colores y los radios de borde. Esos valores se convierten en clases utilizables en los componentes.

Los botones, los menús y los tooltips proceden del design system de Aubia, distribuido como un shadcn registry. Su código se copia en el repositorio, en resources/js/components, y se construye sobre Base UI, que gestiona el foco y la navegación con el teclado.

El design system define también los dos temas, el efecto de cristal, las fuentes y las animaciones. Trato sus ajustes y sus límites en el artículo que le dedico.

Laravel Cloud para el alojamiento, Pest para los tests

Laravel Cloud aloja el sitio, con los datos almacenados en la Unión Europea, y ejecuta sus dos procesos, FrankenPHP para Laravel y Node para el SSR.

Laravel Nightwatch señala los errores y las solicitudes lentas de producción. La política de privacidad precisa los datos que tratan Laravel Cloud, Cloudflare y Nightwatch.

La suite de tests está escrita con Pest 5. Los tests unitarios y de feature cubren, entre otras cosas, las Actions, los controllers y las respuestas de Inertia; los tests de arquitectura comprueban reglas de código, como declare(strict_types=1) en cada archivo PHP escrito para el proyecto o la coherencia de las claves entre los archivos de idioma.

Un test de feature inicia el bundle SSR real en Node y comprueba que las frases de la página de inicio aparecen en el HTML antes de que se ejecute cualquier JavaScript en el navegador. Los tests de navegador pasan por el plugin Browser de Pest, que controla Chromium con Playwright.

En cada pull request, GitHub Actions comprueba el PHP con Pint, el TypeScript con Oxlint y Oxfmt, y después los tipos. En paralelo, la suite de Pest se ejecuta en cuatro lotes, y los tests de navegador, en un workflow aparte.

En los tests, una solicitud HTTP a un servicio externo falla si no se ha simulado, y las respuestas de aubia.cloud se construyen con Http::fake. Si cambia el contrato de la API de aubia.cloud, ningún test del sitio público lo detecta.

La beta de Aubia es gratuita y sin límite de tiempo. Inscríbase para recibir su invitación.

Únase a la lista de espera

Volver al blog