Ir para o conteúdo principal

Devlog · Fase 0 · 2/7

A stack técnica de aubia.dev

Construí aubia.dev sobre uma única aplicação Laravel, sem banco de dados. O Octane e o FrankenPHP a mantêm em memória entre requisições, e o React renderiza suas páginas no servidor com o Inertia.

Publicado em Atualizado em 11 min de leitura

  • laravel
  • octane
  • frankenphp
  • inertia
  • vite

Quando o seu navegador solicita uma página de aubia.dev, o Cloudflare encaminha a requisição ao servidor hospedado pelo Laravel Cloud, onde o FrankenPHP a entrega ao Laravel. O Laravel prepara os dados da página e obtém de um processo Node o HTML de seus componentes React. No seu navegador, o React hidrata esse HTML para tornar a página interativa.

O framework Laravel no centro do projeto

O site é uma aplicação Laravel 13 sobre PHP 8.5. O Laravel lê o idioma no prefixo da URL, /fr ou /en por exemplo, antes de preparar a página solicitada. Uso o mesmo framework para aubia.cloud e para o aplicativo desktop, construído com NativePHP.

Reuni todo o site em um único repositório: o código PHP, as páginas React, os textos dos seis idiomas em JSON e os artigos do blog em Markdown são implantados juntos. Durante o build, o comando Artisan blog:compile transforma os artigos em um catálogo que já contém o HTML deles.

Nenhuma conexão SQL é declarada em config/database.php. As inscrições na lista de espera são armazenadas por aubia.cloud: o site público as encaminha com o cliente HTTP do Laravel, em uma requisição assinada. O artigo sobre a coleta de endereços de e-mail sem tabela local descreve em detalhes essa troca e sua dependência da rede. O Redis mantém o restante do estado, ou seja, as sessões e o cache, incluindo os contadores do rate limiter.

O código PHP é organizado em cinco módulos: Blog, Cloud, Locale, Seo e Waitlist. Suas Actions, suas Queries e o gateway para aubia.cloud são descritos no artigo sobre os cinco módulos.

Otimização de desempenho com o Laravel Octane

Com PHP-FPM, o Laravel é inicializado a cada requisição, mesmo quando um processo PHP atende várias antes de ser reciclado, conforme ajustes como pm.max_requests.

Uma inicialização por requisição. O PHP-FPM reinicializa o Laravel antes de cada resposta, mesmo quando o worker mantém seu PID. A inicialização leva 87 ms na minha máquina de desenvolvimento.

Na minha máquina de desenvolvimento, um MacBook Pro M5 Pro com PHP 8.5 e opcache ativo, a inicialização do Laravel leva 87 ms. Cada duração que registrei ali é a mediana de pelo menos dez medições feitas em 8 de setembro de 2026: uma ordem de grandeza, específica dessa máquina.

O Laravel Octane inicializa o Laravel uma vez por worker, e depois cada worker atende as requisições seguintes com a aplicação já carregada. Os workers às vezes são reciclados ou reiniciados, durante um deploy, por exemplo.

Em produção, o Octane serve a página inicial de aubia.dev com uma mediana de 51 ms, segundo os logs de acesso do Laravel Cloud de 5 a 8 de setembro de 2026, com os robôs excluídos. Um quarto das requisições leva menos de 48 ms e três quartos, menos de 64 ms.

Em aubia.dev, o Octane usa o FrankenPHP, um servidor de aplicações PHP escrito em Go que integra o servidor web Caddy.

Com o FrankenPHP instalado e configurado, OCTANE_SERVER o seleciona como driver:

dotenv
# .env: o driver ativo em aubia.dev
OCTANE_SERVER=frankenphp
Uma inicialização por worker. O worker carrega o Laravel uma vez e, depois, cada requisição paga apenas o seu próprio processamento, até a reciclagem. Em produção, a página inicial é servida com uma mediana de 51 ms, segundo os logs de acesso do Laravel Cloud de 5 a 8 de setembro de 2026, com os robôs excluídos.

O Octane redefine o estado do framework entre requisições, sem limpar automaticamente todas as variáveis globais e propriedades estáticas do código da aplicação. Os dados específicos de um visitante podem ser reutilizados para o próximo se permanecerem em um singleton ou em uma propriedade estática. Por isso declaro como scoped o gateway da renderização no servidor, que mantém um estado específico da requisição em andamento: o container o recria a cada requisição.

A escolha do FrankenPHP

O arquivo config/octane.php usa o FrankenPHP como padrão quando nenhuma variável de ambiente é definida:

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

O RoadRunner é outro servidor compatível com os workers do Octane. O Swoole também está disponível, mas exige uma extensão PHP compilada.

Escolhi o FrankenPHP em vez do RoadRunner pelo Caddy, o servidor web que ele integra, e porque seu desenvolvimento continua ativo no ecossistema do Laravel. Ele também é mais simples de operar. Meu registro de decisão de arquitetura destacava o suporte do Caddy a HTTP/3 e a certificados automáticos. Em aubia.dev, o FrankenPHP não cuida deles: o Cloudflare estabelece as conexões TLS e HTTP/3 com o navegador, antes de chegar ao servidor.

Esse binário cobre apenas o PHP: a renderização no servidor é executada em um processo Node separado.

Mudar para o RoadRunner exigiria seus pacotes Composer e seu binário, além de alterar OCTANE_SERVER.

Inertia e React, da rota ao bundle

As páginas são componentes do React 19 escritos em TypeScript 7, memoizados no build pelo React Compiler. O Inertia 3 exibe o componente que uma rota ou um controller designa pelo nome, com suas props. Não escrevi nenhuma API própria para o site: exceto nas páginas de confirmação e de cancelamento da inscrição, que consultam o aubia.cloud diretamente a partir do navegador, os dados da interface passam por essas props.

A página inicial e as páginas legais não têm controller: em routes/web.php, Route::inertia('/', 'welcome') associa a URL ao componente da página. O blog passa por um controller, que busca o artigo solicitado antes de chamar Inertia::render('blog/show', ...).

Um middleware adiciona props compartilhadas a cada página, entre elas o idioma atual e os textos desse idioma. Os componentes leem esses textos com um hook useT.

O Wayfinder gera funções TypeScript a partir das rotas do Laravel. O cartão de um artigo obtém seu link chamando uma delas com o idioma e o slug, em vez de montar a URL manualmente. Se a rota for renomeada, a verificação de tipos falha.

A renderização no servidor do Inertia

Uma página React renderizada apenas no navegador depende de o JavaScript ser carregado e executado para exibir seu conteúdo. Nem todos os robôs de indexação o executam, e os que executam podem adiar esse trabalho. Quero que os mecanismos de busca possam ler o conteúdo sem precisar executar o JavaScript do site.

Com o SSR do Inertia, o Laravel envia a página e seus dados a um processo Node. O Node renderiza os componentes React em HTML e o devolve ao Laravel, que o inclui na resposta por meio da view Blade.

A resposta HTML inicial contém, portanto, o conteúdo e os metadados da página. O Laravel precisa esperar o resultado do Node antes de enviá-la. A renderização pelo Node leva 6 ms na minha máquina de desenvolvimento.

No navegador, o React hidrata o HTML recebido para tornar interativos os componentes da página, inclusive o formulário.

O HTML antes do JavaScript. O Laravel fornece os dados, o Node renderiza a página em HTML e o Laravel o inclui na view Blade: o navegador pode ler o conteúdo antes de hidratá-lo. A renderização pelo Node leva 6 ms na minha máquina de desenvolvimento.

Em desenvolvimento, o plugin Vite do Inertia cuida do SSR sem um servidor de renderização separado para iniciar. Em produção, o processo Node é executado ao lado do servidor PHP: é um segundo processo a operar.

A configuração do SSR é declarada em config/inertia.php:

php
// config/inertia.php, trecho
'ssr' => [
    'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
    'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
    'url' => env('INERTIA_SSR_URL'), // endereço interno padrão omitido
    'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
    'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],

Quando o bundle SSR está ausente, ensure_bundle_exists evita a chamada ao Node, e o Laravel recorre à renderização no cliente. Definir throw_on_error como false permite o mesmo fallback quando a renderização falha. A view app.blade.php mantém um título e uma descrição específicos da página, as diretivas robots e, nas páginas indexáveis, a URL canônica e os links hreflang disponíveis.

Durante uma indisponibilidade transitória do SSR, prefiro esse fallback a uma página de erro, mesmo que a exibição possa ser atrasada. Um robô que não executa JavaScript deixa então de receber o conteúdo do artigo.

Substituí o gateway HTTP nativo do Inertia por ObservedSsrGateway, uma classe que o estende. Quando o SSR está ativado para a requisição, esse gateway sinaliza as falhas e as respostas ausentes, vazias ou inválidas. O log mantém apenas o componente e uma categoria de erro. Um HTML não vazio, porém incompleto, passa sem ser sinalizado.

O contador após a renderização inicial

O contador da lista de espera busca seu total em aubia.cloud e o mantém em um cache no servidor. Se o cache estiver vazio, essa chamada de rede pode atrasar a resposta.

O middleware HandleInertiaRequests compartilha o contador como prop diferida apenas na página inicial, que o exibe. O navegador solicita seu valor após a renderização inicial:

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

Se o próprio cache ficar indisponível, rescue: true omite a prop do payload sem fazer a requisição diferida falhar. O contador permanece então ausente da página.

A requisição diferida leva 3 ms na minha máquina de desenvolvimento, com o cache do servidor quente.

O contador depois da página. Uma requisição JSON diferida lê o total no cache do servidor do Laravel, alimentado por aubia.cloud, sem passar pelo Node nem pela renderização no servidor.

O build com Vite 8 e Rolldown

O Vite 8 integra nativamente seu bundler, o Rolldown, escrito em Rust. As dependências JavaScript são instaladas pelo Bun, que também executa os scripts do build; a renderização no servidor continua sendo executada pelo Node. O build de produção adiciona um bundle SSR dedicado, produzido por vite build --ssr.

O code splitting é definido explicitamente na configuração do 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,
                    },
                ],
            },
        },
    },
},

Declarei três grupos: o React e seu scheduler, as animações do Motion e o cliente do Inertia. O navegador pode reutilizar os arquivos deles em cache de um deploy para outro, desde que o build não altere o conteúdo.

Durante o build de produção, o Sharp prepara variantes WebP e AVIF das capas e do avatar, sem ampliar as fontes. O Vite as grava em /build/assets, com URLs que contêm o hash de cada arquivo. As imagens exibidas usam srcset, sizes e dimensões explícitas; os JPEGs originais continuam disponíveis para compartilhamento social. A capa de um artigo tem prioridade, enquanto os cartões abaixo do viewport são carregados sob demanda.

Tailwind CSS 4 configurado na folha de estilo

O estilo é feito com o Tailwind CSS 4, compilado por seu plugin Vite. Sua configuração é escrita em CSS, sem arquivo tailwind.config.js: resources/css/app.css importa o Tailwind e depois as folhas de estilo do design system, entre as quais há um bloco @theme que declara, entre outras coisas, as cores e os raios de borda. Esses valores viram classes utilizáveis nos componentes.

Os botões, os menus e os tooltips vêm do design system da Aubia, distribuído como um shadcn registry. O código deles é copiado para o repositório, em resources/js/components, e construído sobre o Base UI, que cuida do foco e da navegação por teclado.

O design system também define os dois temas, o efeito de vidro, as fontes e as animações. Abordo seus ajustes e limites no artigo dedicado a ele.

Laravel Cloud para a hospedagem, Pest para os testes

O Laravel Cloud hospeda o site, com dados armazenados na União Europeia, e executa seus dois processos, FrankenPHP para o Laravel e Node para o SSR.

O Laravel Nightwatch sinaliza os erros e as requisições lentas da produção. A política de privacidade especifica os dados tratados pelo Laravel Cloud, pelo Cloudflare e pelo Nightwatch.

A suíte de testes é escrita com o Pest 5. Os testes unitários e de feature cobrem, entre outras coisas, as Actions, os controllers e as respostas do Inertia; os testes de arquitetura verificam regras de código, como declare(strict_types=1) em cada arquivo PHP escrito para o projeto ou a coerência das chaves entre os arquivos de idioma.

Um teste de feature inicia o bundle SSR real no Node e verifica se as frases da página inicial aparecem no HTML antes de qualquer execução de JavaScript no navegador. Os testes de navegador passam pelo plugin Browser do Pest, que controla o Chromium com o Playwright.

Em cada pull request, o GitHub Actions verifica o PHP com Pint, o TypeScript com Oxlint e Oxfmt e, depois, os tipos. Em paralelo, a suíte do Pest é executada em quatro lotes, e os testes de navegador, em um workflow à parte.

Nos testes, uma requisição HTTP a um serviço externo falha se não tiver sido simulada, e as respostas de aubia.cloud são montadas com Http::fake. Se o contrato da API de aubia.cloud mudar, nenhum teste do site público o detecta.

A beta da Aubia é gratuita e sem limite de tempo. Inscreva-se para receber o seu convite.

Entrar na lista de espera

Voltar ao blog