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
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-sitee/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_translatedetranslated_atexistem 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_limitem 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.