Voltar para Artigos

Parte 1 da série “Meu próprio site (rukado.com)”

Por dentro deste site

Um monorepo Laravel + Next.js, e as decisões que explicam cada camada

9 min de leitura167
LaravelNext.jsTypeScriptFilamentPostgreSQLDocker

Todo portfólio de dev tem uma página "sobre". Poucos explicam como foram feitos. Este post abre o capô.

Não é um tour de features — é um tour de decisões. Para cada camada, a pergunta que interessa não é "o que eu usei", é "o que eu escolhi não fazer, e por quê". Se você está montando algo parecido, é essa parte que economiza seu tempo.

O mapa

Camada Escolha Por quê em uma linha
Repositório Monorepo, apps/api + apps/web Duas aplicações de verdade, um histórico só
Backend Laravel 13 + PHP 8.5 API v1 read-only + painel admin
Admin Filament 5, 26 resources O CMS é o próprio painel, não um serviço externo
Banco PostgreSQL 18 tsvector, jsonb, índices parciais — o banco trabalha
Frontend Next.js 16 (App Router) + React 19 Server Components consumindo a API
UI Mantine 9 + PostCSS Componentes acessíveis, sem CSS-in-JS em runtime
i18n next-intl, rotas nativas por idioma /artigos/… e /articles/… são endereços canônicos
Testes Pest 5 (API) + Jest/RTL (web) 30 arquivos de teste no front, feature tests na API
Deploy Docker + Coolify, VPS 2 vCPU / 4 GB Build no próprio servidor, com guardas

Agora as partes que valem explicação.

1. Tradução é schema, não if

O erro clássico de site bilíngue é guardar title_pt e title_en na mesma linha. Funciona com dois idiomas e quebra no terceiro.

Aqui cada conteúdo tem duas tabelas:

  • articles — o que não muda com o idioma: autor, categoria, série, capa, published_at, contadores.
  • article_translations — o que muda: title, slug, subtitle, excerpt, body_markdown, reading_minutes, word_count, SEO.

O que isso compra:

  • Um slug por idioma. unique(['locale', 'slug']) — este artigo pode ser /artigos/por-dentro-deste-site e /articles/inside-this-site, sem colisão.
  • Status por tradução. O texto em inglês pode ficar em rascunho enquanto o português já está publicado.
  • Idioma novo = linha nova. Nenhuma migration, nenhuma coluna.
  • Rastro de tradução automática. is_machine_translated e translated_at existem para que "traduzido por máquina" seja um fato no banco, não uma suposição.

A tabela locales é a fonte da verdade — article_translations.locale é chave estrangeira para ela. Idioma inválido não entra no banco, ponto.

2. O banco faz o trabalho pesado

Tem uma tentação forte de resolver busca com LIKE %termo% e seguir a vida. Três coisas aqui rodam no Postgres de propósito:

Busca full-text que já nasce pronta. A coluna search_vector é GENERATED ALWAYS AS ... STORED. Ninguém precisa lembrar de atualizar índice depois de salvar — é impossível ela ficar dessincronizada da linha. E ela é consciente do idioma:

CASE WHEN locale LIKE 'pt%' THEN 'portuguese'::regconfig
     ELSE 'english'::regconfig END

Um texto em português é indexado com o stemmer português. "Configurações" acha "configuração".

Os pesos também importam: título é A, subtítulo e excerpt são B, corpo é C. Um termo no título vale mais que o mesmo termo no meio do texto.

Índice parcial para a query que realmente roda. A listagem de artigos é sempre a mesma pergunta: publicados, públicos, mais novos primeiro.

CREATE INDEX articles_feed_idx ON articles (published_at DESC)
WHERE status = 'published' AND visibility = 'public' AND deleted_at IS NULL

Índice menor, mais quente em cache, e só cobre as linhas que alguém realmente pede.

Regra de negócio como constraint. Um artigo dentro de uma série ocupa uma posição, e duas posições iguais na mesma série não deveriam existir. Isso não é validação de formulário — é um índice único parcial. Validação em PHP você contorna com um seeder distraído; constraint no banco, não.

A regra: se a integridade importa, ela mora no banco. Validação de formulário é conveniência para o usuário, não garantia.

3. URL que já existiu continua existindo

Você publica um artigo, alguém compartilha, e três dias depois você percebe uma typo no título. Corrige o slug — e quebra todo link que já circulou.

Aqui, sempre que o slug de uma tradução muda, o valor antigo vai para article_slug_history. Uma tabela de escrita única, sem updated_at, porque a linha nasce e nunca mais é tocada.

O caminho de recuperação é bonito porque resolve dois problemas com um endpoint só. Quando uma URL não bate, o front chama /articles/{slug}/alternates, que é o único read da API que não é limitado ao idioma da requisição. Ele responde para:

  • um visitante trocando de idioma no meio do artigo;
  • um link compartilhado com o público do outro idioma;
  • e um slug aposentado, que é exatamente o mesmo tipo de URL apontando para exatamente o mesmo artigo.

Slugs vivos são procurados primeiro. Se um endereço antigo foi reaproveitado por outro artigo, ele resolve para quem o ocupa agora, não para quem o ocupava antes.

Existe também um model Redirect para redirecionamentos manuais. Todos resolvem o mesmo problema por caminhos diferentes: link que funcionou uma vez deve continuar funcionando.

4. O front degrada, não cai

Frontend que consome API tem um modo de falha desagradável: a API cai e a página inteira vira erro 500 — inclusive o cabeçalho, o rodapé e o menu, que não dependiam de nada.

O client da API aqui tem três níveis:

Helper Comportamento Quando usar
apiResource / apiPaginated Lança ApiError Dado essencial da página
apiOptional 404 vira null, resto lança Detalhe que pode não existir → notFound()
tolerate Qualquer falha vira null, e loga Dado periférico do layout

O tolerate é o detalhe que importa. apiOptional cobre 404 — mas API fora do ar não devolve status nenhum: o fetch rejeita com TypeError muito antes disso. São casos diferentes e o teste é explícito sobre isso.

E ele sempre registra no log. Fallback silencioso é como bug de produção vira permanente: a página parece boa, os dados sumiram, e ninguém sabe há quanto tempo.

5. CI que testa a coisa real

Duas decisões pequenas que evitam uma classe inteira de "passou no CI, quebrou em produção".

Postgres de verdade, não SQLite. O schema usa tsvector, jsonb e índices parciais. Rodar teste em SQLite testaria outro banco de dados. O workflow sobe postgres:18 como service — a mesma major que roda em produção. Migration que só funciona em um dos dois falha no CI, não no deploy.

O build do front não fala com a API. Nenhuma página é pré-renderizada, e o container de build nem enxerga a rede da aplicação. Isso é um invariante, e o CI o protege: se alguém adicionar um fetch em tempo de build, o build quebra no PR — em vez de quebrar no servidor, onde o hostname interno não resolve.

O gate do front roda em ordem, do mais barato ao mais caro: typegen → oxfmt → oxlint → stylelint → tsc → jest. Erro de formatação não gasta o tempo de uma suíte inteira.

Os dois workflows são filtrados por path — mudança na API não rebuilda o Next, e vice-versa. E cada arquivo de workflow se inclui no próprio filtro: mudar como a checagem roda também precisa ser checado rodando.

6. Infra: o incidente que virou três guardas

Este site roda numa VPS de 2 vCPU e 4 GB. Em 07/08/2026, o servidor inteiro saiu do ar por sete horas.

O que aconteceu: dois builds Docker rodaram ao mesmo tempo, a RAM acabou, e sem swap o kernel entrou em page-reclaim thrashing — ~1 GB/s de leitura em disco, CPU travada em 180%, e nenhum OOM kill para encerrar, porque sem swap sempre há page cache reclamável. O painel do próprio Coolify caiu junto.

A correção não foi "comprar mais RAM". Foram três guardas independentes:

Guarda O que faz
Concurrent Builds = 1 Nunca mais dois builds ao mesmo tempo (o padrão era 2)
Watch path por app Push que só toca a API não builda o Next
swap + earlyoom Build que estoura a RAM morre e falha o deploy, em vez de travar o host

E mem_limit em cada container, que é a outra metade: o limite vive no cgroup, então um estouro é morto dentro do container e nunca vira travamento do host. Os números somam ~2,2 GB entre API, worker, scheduler, Postgres, Redis, SeaweedFS e Next — deixando ~750 MB livres para um build rodar.

Detalhe que só se aprende doendo: PHP_FPM_PM_MAX_CHILDREN: 6. A imagem serversideup/php dimensiona o pool do FPM pela RAM do host, e não enxerga o limite do cgroup. No padrão, o pool cresce direto através do teto de 448 MB e mata o container sob carga.

7. Conteúdo é dado, não arquivo

Nada aqui é markdown commitado no repositório. Artigos, projetos, experiências, certificações, conquistas, métodos de apoio — tudo vive no banco e é editado no Filament.

O seeder é reference data, e roda em todo deploy. Isso só é seguro porque idempotência é contrato: os seeders usam updateOrCreate/firstOrCreate, e existe um SeederIdempotencyTest que falha o CI se algum seeder deixar de ser idempotente. A regra não depende de ninguém lembrar dela.

Checklist para levar embora

Se você for montar algo parecido:

  • Tradução em tabela separada, com unique(locale, slug) — não em colunas _pt / _en
  • Índice para a query que roda de verdade, não para a tabela inteira
  • Coluna gerada quando o valor deriva da linha — sync manual sempre esquece
  • Regra de integridade como constraint; validação de form é UX, não garantia
  • Histórico de slug antes de permitir editar slug
  • Um caminho explícito para "a API caiu" que não seja erro 500
  • Fallback que loga — degradação invisível é bug permanente
  • CI no mesmo banco e na mesma major que a produção
  • Invariantes de build protegidos pelo CI, não por memória
  • mem_limit em todo container, se o build roda no mesmo host
  • Se o seeder roda em produção, teste automatizado que garante idempotência

Nada disso é sofisticado. É só a diferença entre um projeto que você abandona em seis meses e um que você ainda consegue mexer depois de um ano longe.

Os próximos posts entram em cada um desses pontos com mais profundidade. Se quiser puxar um assunto específico primeiro, a página de contato está aberta.