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

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.
| Piece | What it is | Where it lives |
|---|---|---|
| API and admin | Laravel 12 on PHP 8.4, Filament 4 admin | apps/backend |
| Frontend | Next.js 16, App Router, React 19 | apps/web |
| Shared components | @yabasha/ui, shadcn/ui in the new-york style | packages/ui |
| Database | MySQL in production | apps/backend/.env |
| Queue | Horizon on Redis | apps/backend/config/horizon.php |
| Process manager | PM2, 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.
Continue Reading
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.
| Path | Who calls it | What it needs |
|---|---|---|
Public reads, GET /api/v1/* | The Next.js server, during ISR | Nothing. Rate limited at throttle:300,1 per IP |
| Unpublished content | Next.js, in preview mode | An X-Preview-Secret header |
Writes, POST /api/v1/* | My own tooling, from anywhere | A bearer ManageToken |
| Filament admin | Me, in a browser | A 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:
| Content | Seconds |
|---|---|
| Blog post detail, project detail, media detail | 180 |
Blog list, categories in context, /now | 300 |
| Category and tag lists, project list | 600 |
| Sitemap, speaking pages | 3600 |
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?
| Gap | Why it is still open |
|---|---|
| No retry on cache purges | Time-based ISR covers it for a portfolio. It would not cover a shop. |
| Tests run on SQLite, production on MySQL | Needs a MySQL service in CI. An afternoon, never the urgent afternoon. |
| One box, no redundancy | A second box doubles cost to remove an outage I have not had. |
| No structured trace across the two apps | I 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.
Related
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 (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.
Related Articles

Invalidate Next.js 16 ISR from Laravel: The Cache Handshake

How I Built an AI Agent for my Portfolio (Yabasha.dev) using Laravel & Next.js

Graph Engineering Is Mostly Airflow With A New Coat Of Paint

The $18K Ceiling Breaker: Skills That Actually Move Your Number
Read more on the blog
Browse the latest articles or explore the full archive.