Back to Articles

Part 1 of the series “My own website (rukado.com)”

Inside This Site

A Laravel + Next.js monorepo, and the decisions behind every layer

8 min read166
LaravelNext.jsTypeScriptFilamentPostgreSQLDocker

Every developer portfolio has an "about" page. Very few explain how they were built. This post opens the hood.

It isn't a feature tour — it's a tour of decisions. For each layer, the interesting question isn't "what did I use", it's "what did I choose not to do, and why". If you're building something similar, that's the part that saves you time.

The map

Layer Choice Why, in one line
Repository Monorepo, apps/api + apps/web Two real applications, one history
Backend Laravel 13 + PHP 8.5 Read-only v1 API plus the admin panel
Admin Filament 5, 26 resources The CMS is the panel — no external service
Database PostgreSQL 18 tsvector, jsonb, partial indexes — the database works
Frontend Next.js 16 (App Router) + React 19 Server Components consuming the API
UI Mantine 9 + PostCSS Accessible components, no runtime CSS-in-JS
i18n next-intl, native routes per language /artigos/… and /articles/… are both canonical
Tests Pest 5 (API) + Jest/RTL (web) 30 test files on the front end, feature tests on the API
Deploy Docker + Coolify, 2 vCPU / 4 GB VPS Builds on the server itself, with guardrails

Now the parts that deserve an explanation.

1. Translation is schema, not if

The classic bilingual-site mistake is storing title_pt and title_en on the same row. It works with two languages and breaks on the third.

Here every piece of content has two tables:

  • articles — what doesn't change with language: author, category, series, cover, published_at, counters.
  • article_translations — what does: title, slug, subtitle, excerpt, body_markdown, reading_minutes, word_count, SEO fields.

What that buys:

  • One slug per language. unique(['locale', 'slug']) — this article can be /articles/inside-this-site and /artigos/por-dentro-deste-site, no collision.
  • Status per translation. The English text can sit in draft while the Portuguese one is already live.
  • A new language is a new row. No migration, no column.
  • A machine-translation trail. is_machine_translated and translated_at exist so "this was machine translated" is a fact in the database, not a guess.

The locales table is the source of truth — article_translations.locale is a foreign key into it. An invalid language code simply cannot enter the database.

2. Let the database do the heavy lifting

There's a strong pull toward solving search with LIKE %term% and moving on. Three things here run in Postgres on purpose:

Full-text search that's correct by construction. The search_vector column is GENERATED ALWAYS AS ... STORED. Nobody has to remember to refresh an index after saving — it cannot drift out of sync with its row. And it's language-aware:

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

Portuguese text is indexed with the Portuguese stemmer. Searching "configurações" finds "configuração".

The weights matter too: title is A, subtitle and excerpt are B, body is C. A term in the title outranks the same term buried in the body.

A partial index for the query that actually runs. The article listing always asks the same question: published, public, newest first.

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

Smaller index, warmer in cache, and it only covers rows anyone actually asks for.

Business rules as constraints. An article inside a series occupies a position, and two articles shouldn't share a position in the same series. That's not form validation — it's a partial unique index. PHP-side validation gets bypassed by a careless seeder; a database constraint doesn't.

The rule: if integrity matters, it lives in the database. Form validation is a convenience for the user, not a guarantee.

3. A URL that once existed still exists

You publish an article, someone shares it, and three days later you spot a typo in the title. You fix the slug — and break every link already in circulation.

Here, whenever a translation's slug changes, the old value is written to article_slug_history. A write-once table with no updated_at, because the row is created and never touched again.

The recovery path is neat because it solves two problems with one endpoint. When a URL misses, the front end calls /articles/{slug}/alternates — the one read in the API that is deliberately not scoped to the requested locale. It answers for:

  • a visitor switching language mid-article;
  • a link shared with the other language's audience;
  • and a retired slug, which is exactly the same kind of URL pointing at exactly the same article.

Live slugs are searched first. If an old address has since been taken up by another article, it resolves to whichever article holds it now, not the one that used to.

There's also a Redirect model for manual redirects. They all solve the same problem from different directions: a link that worked once should keep working.

4. The front end degrades — it doesn't fall over

API-backed front ends have an ugly failure mode: the API goes down and the whole page becomes a 500 — including the header, the footer and the nav, none of which needed the API at all.

The API client here has three levels:

Helper Behavior Use it for
apiResource / apiPaginated Throws ApiError The page's essential data
apiOptional 404 becomes null, everything else throws A detail that may not exist → notFound()
tolerate Any failure becomes null, and logs Peripheral layout data

tolerate is the detail that matters. apiOptional covers 404s — but an API that is genuinely down returns no status at all: fetch rejects with a TypeError long before there's a status to inspect. Different cases, and the test suite is explicit about it.

And it always logs. A silent fallback is how a production bug becomes permanent: the page looks fine, the data is gone, and nobody knows for how long.

5. CI that tests the real thing

Two small decisions that eliminate an entire class of "green in CI, broken in production".

Real Postgres, not SQLite. The schema uses tsvector, jsonb and partial indexes. Testing on SQLite would be testing a different database. The workflow spins up postgres:18 as a service — the same major that runs in production. A migration that only works on one of them fails in CI, not at deploy time.

The front-end build never talks to the API. No page is prerendered, and the build container can't even see the app's Docker network. That's an invariant, and CI protects it: if someone adds a build-time fetch, the build fails on the PR instead of failing on the server, where the internal hostname doesn't resolve.

The front-end gate runs cheapest-first: typegen → oxfmt → oxlint → stylelint → tsc → jest. A formatting error doesn't cost you a full test suite.

Both workflows are path-filtered — an API change doesn't rebuild Next, and vice versa. And each workflow file includes itself in its filter: changing how the check runs also has to be checked by running it.

6. Infrastructure: the incident that became three guardrails

This site runs on a 2 vCPU / 4 GB VPS. On 2026-08-07, the whole server was down for seven hours.

What happened: two Docker builds ran at the same time, RAM ran out, and with no swap the kernel entered page-reclaim thrashing — ~1 GB/s of disk reads, CPU pinned at 180%, and no OOM kill to end it, because without swap there is always reclaimable page cache. Coolify's own dashboard went down with it.

The fix wasn't "buy more RAM". It was three independent guardrails:

Guardrail What it does
Concurrent Builds = 1 Never two builds at once again (the default was 2)
Per-app watch paths A push touching only the API doesn't build Next
swap + earlyoom A build that outgrows RAM dies and fails the deploy, instead of stalling the host

Plus mem_limit on every container, which is the other half: the limit lives in the container's cgroup, so an overrun is killed inside that container and never becomes a host-wide stall. The numbers add up to ~2.2 GB across API, worker, scheduler, Postgres, Redis, SeaweedFS and Next — leaving ~750 MB free for a build to run in.

One detail you only learn the painful way: PHP_FPM_PM_MAX_CHILDREN: 6. The serversideup/php image sizes the FPM pool from the host's RAM and can't see the cgroup limit. On defaults, the pool grows straight through the 448 MB ceiling and OOMs the container under load.

7. Content is data, not files

Nothing here is markdown committed to the repo. Articles, projects, experience, certifications, achievements, support methods — it all lives in the database and is edited in Filament.

Seeders carry reference data, and they run on every deploy. That's only safe because idempotency is a contract: the seeders use updateOrCreate/firstOrCreate, and a SeederIdempotencyTest fails CI if any seeder ever stops being idempotent. The rule doesn't depend on anyone remembering it.

A checklist to take with you

If you're building something similar:

  • Translations in a separate table with unique(locale, slug) — not _pt / _en columns
  • Index the query that actually runs, not the whole table
  • Generated columns when a value derives from its row — manual sync always gets forgotten
  • Integrity rules as constraints; form validation is UX, not a guarantee
  • Slug history before you allow slug edits
  • An explicit path for "the API is down" that isn't a 500
  • A fallback that logs — invisible degradation is a permanent bug
  • CI on the same database and the same major as production
  • Build invariants enforced by CI, not by memory
  • mem_limit on every container if builds run on the same host
  • If seeders run in production, an automated test that guarantees idempotency

None of this is sophisticated. It's just the difference between a project you abandon in six months and one you can still work on after a year away.

Upcoming posts go deeper into each of these. If you want a specific one first, the contact page is open.