Skip to content
Back to Blog

Laravel 12 and Next.js 16 in One Bun Monorepo: What yabasha.dev Runs

Bashar AyyashJanuary 5, 2026Updated September 19, 20268 min read1,523 words
Laravel 12 and Next.js 16 in One Bun Monorepo: What yabasha.dev Runs
TL;DR

yabasha.dev runs Laravel 12 and Next.js 16 as one Bun workspace monorepo, built by Turbo. The frontend reads public API endpoints with no credentials and caches them for 180 to 300 seconds. Writes need a SHA-256 hashed bearer token carrying one of 15 abilities. Cache purges are fire-and-report, with time-based ISR as the real safety net.

8 min read · 1,523 words

Every claim below is checkable against the repo. I removed the ones that were not.

I published a version of this post in January 2026. Rereading it this week, I found it described PostgreSQL, Sentry, Vercel Pro and Laravel Cloud. This site runs none of them. It also walked through an app/Events/ContentInvalidated.php that has never existed here, and a queue job with five retries and exponential backoff that, in the real repo, has no retries at all.

So this is the honest version. Every file path, version number and behaviour below comes from the running code.

A monorepo is not an architecture. The architecture is the boundary between the two apps, and on this site that boundary is one hashed token wide.

What does yabasha.dev actually run?

One repository, three workspaces, built by Turborepo over Bun workspaces.

PieceWhat it isWhere it lives
API and adminLaravel 12 on PHP 8.4, Filament 4 adminapps/backend
FrontendNext.js 16, App Router, React 19apps/web
Shared components@yabasha/ui, shadcn/ui in the new-york stylepackages/ui
DatabaseMySQL in productionapps/backend/.env
QueueHorizon on Redisapps/backend/config/horizon.php
Process managerPM2, one VPS/var/www/yabasha.dev

The root package.json declares workspaces: ["apps/*", "packages/*"] and pins packageManager: "bun@1.3.0". turbo.json defines four tasks. dev is marked persistent and uncached. build, lint and typecheck each declare dependsOn: ["^build"] style ordering so packages/ui compiles before apps/web consumes it.

There is no Kubernetes, no edge network and no second cloud account. One box runs everything.

Why one repo instead of two?

Because the two apps share a contract that changes often, and nothing else enforces it.

When I add a field to a Laravel API resource, the TypeScript type in apps/web/src/lib/api.ts that reads it has to change in the same breath. In two repositories that is two pull requests, and the window between them is where a production 500 lives. In one repository it is one commit, and CI either passes on both apps or fails.

That is the whole argument. It is not about tooling elegance.

Split the repo when two teams need different release cadences. Until then, the second repo only buys you a coordination problem you did not have.

The cost is real and worth naming. A monorepo makes it easy to reach across the boundary when you should not. packages/ui may not import from apps/web, and nothing but review enforces that.

How do the two apps talk to each other?

Not the way most Laravel and Next.js guides describe. There is no Sanctum cookie handshake between the frontend and the API, because the frontend never acts as a logged-in user.

PathWho calls itWhat it needs
Public reads, GET /api/v1/*The Next.js server, during ISRNothing. Rate limited at throttle:300,1 per IP
Unpublished contentNext.js, in preview modeAn X-Preview-Secret header
Writes, POST /api/v1/*My own tooling, from anywhereA bearer ManageToken
Filament adminMe, in a browserA session, gated by User::canAccessPanel()

The shape of those endpoints follows the conventions I wrote up in building RESTful APIs with Laravel. What changed since then is the authentication, which is where most of the thinking went.

A ManageToken is generated once, shown once, and stored as a SHA-256 hash. The plaintext is never written to the database. Each token carries a subset of 15 abilities from the TokenAbility enum, from posts.write through newsletter.subscribe, and every use writes an audit row.

The AuthenticateManageOrSanctum middleware has one rule worth stating plainly. A bearer token must resolve to an active ManageToken or the request gets a 401. There is no anonymous fallback, so a revoked token fails closed rather than quietly downgrading to public access.

How does a published post reach the frontend?

PostObserver::saved() dispatches RevalidateFrontendJob, which POSTs a cache tag and a URL list to /api/revalidate on the Next.js side with an X-Revalidate-Token header. The route calls revalidateTag() and pings IndexNow.

I wrote that protocol up in detail, including the version I would build where staleness costs money, in the Cache Handshake. Two details from it matter here.

The route accepts 12 tag names and rejects everything else. A typo does not purge a different tag, it purges nothing, and the 400 is only reported. Separately, the urls array in the request body does not purge anything. It feeds the IndexNow ping only. The tag is what clears the cache.

Behind all of it sits time-based revalidation, set per fetch in apps/web/src/lib/api.ts:

ContentSeconds
Blog post detail, project detail, media detail180
Blog list, categories in context, /now300
Category and tag lists, project list600
Sitemap, speaking pages3600

What happens when a cache purge fails?

Nothing retries. This is the part the old version of this post got most wrong.

RevalidateFrontendJob sets no $tries and no $backoff. It wraps an Http::timeout(8) call in a try/catch, and on failure it calls report() and returns. The job is marked complete. Horizon shows no failure, because there was none.

The safety net is not the retry. There is no retry. The safety net is that every cached fetch also expires on a timer, so the worst case is a stale page for 180 seconds, not forever.

That is a deliberate trade for a portfolio, and I would not ship it to a client. The honest framing is that time-based ISR does the work and the purge is an optimisation that usually fires. If you need the acknowledged, retried version, it is in the Cache Handshake post linked above.

One consequence catches me every time. GeneratePostSeoJob writes with updateQuietly(), which skips the observer, so regenerating SEO fields fires no purge at all. The page catches up on its own timer. A curl immediately after still shows the old value.

Why does the test suite run on SQLite when production runs MySQL?

Because it was fast to set up, and I have not fixed it.

apps/backend/phpunit.xml pins DB_CONNECTION=sqlite and DB_DATABASE=:memory:. The CI job installs pdo_sqlite only. The suite is healthy by its own measure. As of today it runs 478 passing tests and 1 skipped, through Pest 4.

But every MySQL-specific behaviour in this codebase is untested everywhere it runs. A correlated subquery that SQLite tolerates and MySQL rejects would pass CI and fail in production. I know of no such bug today. I also have no test that would find one.

I have no plan to fix it this quarter, which is the useful admission. Naming an untested surface is worth more than pretending it is covered.

What does a deploy look like?

Two commands and one trap.

The Laravel side runs composer install --no-dev, which has bitten me. A class that only reaches the tree through a require-dev package passes CI, where dev dependencies are installed, and fatals on the server, where they are not. That is a whole category of bug with no test that can catch it, because the code runs in the one place the suite never does.

The Next.js side runs a production build and then:

pm2 restart portfolio-yabasha --update-env

--update-env is not optional. A plain pm2 restart inherits PM2's cached environment and silently ignores every value you just changed in .env.local. I have debugged the same "my new env var does nothing" problem more than once before remembering.

The box also needed 4 GB of swap added before next build would finish. Without it the build is OOM-killed with no useful message.

What would I change?

GapWhy it is still open
No retry on cache purgesTime-based ISR covers it for a portfolio. It would not cover a shop.
Tests run on SQLite, production on MySQLNeeds a MySQL service in CI. An afternoon, never the urgent afternoon.
One box, no redundancyA second box doubles cost to remove an outage I have not had.
No structured trace across the two appsI removed Sentry and log JSON instead. Correlating a publish to a purge is still grep.

The pattern in that table is the honest one. Every gap is a deliberate trade against a failure I have not been hit by yet, and I would make a different call for a client system.

I use this stack to run an AI pipeline over my own content, which I describe in how I built an AI agent for my portfolio. The Laravel queue concepts above come straight from the Horizon documentation, and the caching model on the Next.js side is the one described in the revalidateTag reference.

Tested with

Versions read from composer.json and package.json on 2026-09-19.

  • Laravel 12.69, PHP 8.4
  • Laravel Horizon 5.48, Sanctum 4.3
  • Filament 4
  • Next.js 16.3, React 19.2
  • Bun 1.3.0, Turborepo 2.10
  • MySQL in production, SQLite in the test suite
Bashar Ayyash
AUTHOR

Bashar Ayyash (Yabasha)

AI Systems Architect for regulated industries — evals, harness design, AI security.

Bashar Ayyash is an AI engineer and dev lead in Amman, Jordan. 20 years shipping software, 4 years inside a regulated bank building production RAG and agent systems with evals, guardrails and monitoring — in Arabic and English. He writes at yabasha.dev and builds open-source tooling for AI-assisted development.

Newsletter

Practical AI + full-stack insights for MENA builders. No spam.

Read more on the blog

Browse the latest articles or explore the full archive.