Devlog · Fase 0 · 5/7
A arquitetura Modules do repo web
Cinco módulos, um único ponto de saída para o cloud e revisões que se leem pelo caminho de arquivo: a arquitetura Laravel do repo web, gerado em mais de noventa por cento por agentes de IA.
- laravel
- architecture
- octane

Um site vitrine sem banco de dados é algumas páginas e um formulário. A tentação, num escopo tão pequeno, é empilhar tudo em dois ou três controllers e seguir em frente. Peguei o caminho inverso já na primeira semana do projeto. Quando coloquei a landing no ar em abril, arrumei cada responsabilidade em seu próprio módulo. Hoje o repo tem cinco domínios: Cloud, Locale, Seo, Waitlist, e agora Blog. Eis por que essa disciplina se sustenta, mesmo nessa escala.
Uma pasta por domínio, não por tipo técnico
A estrutura inicial do Laravel arruma o código por natureza: todos os controllers juntos, todos os services juntos, todas as classes utilitárias juntas. Em aubia.dev, mudei para uma arrumação por domínio de negócio. Cada pasta em app/Modules/ carrega uma intenção de produto completa, com suas subpastas Actions/, DataTransferObjects/, Http/ e Enums/ quando são úteis.
Cloud: o único ponto de saída HTTP em direção aapi.aubia.cloud.Locale: a resolução de idioma antes do roteamento.Seo: a geração do sitemap e dos sinais de indexação.Waitlist: o proxy de inscrição em double opt-in.Blog: o motor flat-file que serve este artigo.
Quando uma intenção muda, sei exatamente onde olhar. Nenhum arquivo joga dos dois lados.
As Actions: uma intenção, uma classe
Cada gesto de negócio vive em sua própria classe de Action. Nenhum service faz-tudo que acumula quinze métodos ao longo dos meses: uma classe faz uma coisa, a expõe por um método de entrada, e se testa fora da stack HTTP. A convenção Aubia quer essas classes final readonly por padrão, com um ponto de entrada nomeado (execute) ou invocável.
A resolução de locale é um bom exemplo. Ela reproduz a mesma cascata que o middleware, sem tocar no segmento de URL, isolada para permanecer testável:
public function __invoke(Request $request): string
{
$candidates = [
$request->cookie(SetLocale::COOKIE_NAME),
$this->extractAcceptLanguage($request),
config('app.locale'),
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && in_array($candidate, SetLocale::SUPPORTED_LOCALES, true)) {
return $candidate;
}
}
return SetLocale::DEFAULT_LOCALE;
}
O controller que chama essa Action permanece trivial: ele delega e redireciona. Toda a lógica testável está em outro lugar.
Os DTOs readonly: um contrato tipado entre as camadas
Entre uma camada e a seguinte, nunca faço transitar um array associativo vagamente tipado. Cada payload estruturado passa por um Data Transfer Object em PHP puro: uma readonly class com propriedades promovidas, com um fromArray() na entrada e um método de saída explícito. Nenhuma dependência externa para isso, nenhum mapeamento mágico: a tipagem estrita do PHP 8.5 basta, e o contrato se lê num piscar de olhos.
A inscrição na lista de espera ilustra por que esse contrato importa. O formulário captura um campo honeypot temporal, startedAt, usado apenas para armadilhar os bots do lado da validação. Esse campo nunca deve sair para o cloud. O DTO o carrega internamente mas o exclui do payload de saída:
public function toCloudPayload(): array
{
return [
'email' => $this->email,
'utm_source' => $this->utmSource,
'utm_medium' => $this->utmMedium,
'utm_campaign' => $this->utmCampaign,
'locale' => $this->locale,
];
}
A fronteira entre o que o web conhece e o que o cloud recebe está escrita em preto no branco, em um método cujo nome diz exatamente o seu papel.
Um único ponto de saída em direção ao cloud
Como a Fase 0 não armazena nada localmente, todo dado persistente sai para o repo cloud via um proxy HTTP. Centralizei essas chamadas em uma única classe, CloudApiClient, para que as Actions nunca tenham de conhecer os detalhes do cliente Http nativo do Laravel: timeouts, backoff, logging sem dado pessoal. O cliente aplica duas políticas de retry distintas conforme a idempotência do verbo. Um GET reproduz em erro de rede e em 5xx, sem efeito colateral. Um POST assinado só reproduz em uma falha de rede clara, nunca em uma resposta recebida, para não arriscar uma dupla inscrição.
Esse cliente é final mas, deliberadamente, não readonly no nível da classe: sob o Octane, ele deve permanecer sem estado de uma requisição a outra, e permanecer mockável nos testes. Um detalhe de disciplina stateless que evita vazamentos entre workers.
Por que essa disciplina em um repo tão pequeno
A pergunta honesta é: isso não é demais para cinco páginas?
Este repo é gerado noventa por cento e mais por agentes de IA, e a revisão humana continua sendo o verdadeiro gargalo. Uma fronteira de módulo nítida é uma salvaguarda nos dois sentidos: um agente solto sobre Waitlist não transborda para Seo por acidente, e minhas revisões continuam curtas de ler porque o escopo de uma mudança se adivinha pelo seu caminho de arquivo.
A Fase 1 trará um verdadeiro site de produto, e cada módulo de hoje é uma fundação, não um andaime descartável. E essa estrutura é coerente de um repo Aubia a outro: reencontro os mesmos reflexos no cockpit desktop e no cloud. Essa regularidade, num projeto tocado sozinho, bem vale algumas pastas a mais.
Descer para dentro da pele do site
Resta o mais visível: a paleta, o vidro e o movimento. Para receber o convite para a beta 0.1 assim que ela abrir, a lista de espera está lá.
Esta construção é contada aqui, artigo após artigo. Embarque: o seu feedback desenhará o que vem a seguir.
Entrar na lista de espera