We Killed the PHP Monolith. It Took 18 Months and One Client's Data.
← Back
March 9, 2026Architecture13 min read

We Killed the PHP Monolith. It Took 18 Months and One Client's Data.

Published March 9, 202613 min read

Seven years of PHP. Forty-three database tables. Six thousand lines of Blade templates. Three thousand paying clients. A team that voted unanimously to rewrite it all in Next.js. I was the one who said it would take three months. It took eighteen.

This isn't a tutorial. It's the kind of post you write after the war is over.


The monolith that ran the company

The product was a B2B SaaS platform. Client management, invoicing, job scheduling, reporting. Built on Laravel 5 back in 2017, when Laravel was the right answer. By 2023 it was still running. Reliably. Every new feature request, though, sent a cold shiver through the team.

The codebase had accrued seven years of decisions. Each one sensible in isolation, none of them sensible together. Business logic lived in controllers. Controllers called other controllers. There were 14 different "helper" files, three of which no one dared touch. A field rename on the clients table meant updating 23 Blade templates by hand. Our senior backend guy kept a paper checklist for it.

┌─────────────────────────────────────────────────────┐
│                 PHP MONOLITH (2023)                 │
├─────────────────────────────────────────────────────┤
│  Browser → Apache → Laravel Router                  │
│                          │                          │
│              ┌───────────┼───────────┐              │
│              ↓           ↓           ↓              │
│         Controller  Controller  Controller          │
│              │           │           │              │
│    ┌─────────┼─────┐     │     ┌─────┴──────┐      │
│    ↓         ↓     ↓     ↓     ↓            ↓      │
│  Model  Helper  Model  Model  Helper14    Model    │
│    │         │           │                  │      │
│    └────┬────┘           └────────┬─────────┘      │
│         ↓                         ↓                 │
│      MySQL DB               Blade Templates         │
│  (43 tables, no migrations                          │
│   applied since 2021)                               │
└─────────────────────────────────────────────────────┘

Technical debt wasn't the real problem. Velocity was. New engineers spent six weeks just orienting. Features that should take two days took two weeks. Newer competitors on modern stacks were eating into our deal cycles.

Migration felt inevitable. The pitch was clean. Next.js for the frontend, a proper REST API, TypeScript everywhere, component architecture, tests that ran in under a minute. A codebase a new engineer could contribute to on day three.

What we underestimated was the number of implicit contracts baked into seven years of code. They weren't documented. They weren't tested. They just worked. Until we started pulling threads.


The plan: strangler fig. the reality: strangling ourselves.

We went with the strangler fig pattern, the industry-standard approach to big rewrites. Run the new system in parallel, route traffic to it piece by piece, gradually strangle the old system until it's dead. Clean. Surgical. Beloved by conference talks.

Month one went beautifully. Next.js app stood up, connected to the existing MySQL database, login screen working, dashboard skeleton in place. An Nginx reverse proxy sat in front of both systems: new routes went to Next.js, everything else fell through to PHP.

┌──────────────── STRANGLER FIG SETUP ────────────────────┐
│                                                          │
│   Browser                                                │
│      │                                                   │
│      ↓                                                   │
│   Nginx Reverse Proxy                                    │
│      │                                                   │
│      ├─── /app/clients*  ──────→  Next.js  (new)        │
│      ├─── /app/invoices* ──────→  Next.js  (new)        │
│      │                                                   │
│      └─── /* (everything else) → PHP Laravel (old)      │
│                                                          │
│   ┌──────────────────────────────────────────────────┐  │
│   │             Shared MySQL Database                 │  │
│   │        (both apps read/write directly)            │  │
│   └──────────────────────────────────────────────────┘  │
│                                                          │
│   THE HIDDEN PROBLEM: Sessions, cache, and auth live     │
│   in completely different worlds on each side.           │
└──────────────────────────────────────────────────────────┘

The first real problem hit in week three: session management. PHP stored sessions server-side in Redis. Next.js was issuing JWTs. When a user logged in on the PHP side and clicked a link that routed them to a Next.js page, Next.js had no idea who they were. They got silently redirected to the login screen.

At first this only affected internal testers. Then we accidentally routed a client-facing page to Next.js without closing the auth gap. Forty-seven clients got logged out mid-session on a Thursday afternoon. Eleven emailed support. One called the CEO directly. I still remember the CEO walking over to my desk.

"This is what no one puts in the strangler fig blog posts: the seam between two systems is where the worst bugs live. And users find those bugs every single time."

The session bridge: a hack that became infrastructure

Our fix was inelegant but necessary. We built a PHP endpoint that accepted a valid PHP session cookie and returned a short-lived signed token. Next.js middleware would request this token on the first authenticated page load, then use it against a new Node API layer.

api/auth/bridge.php
<?php
// PHP → Next.js session bridge
// Called by Next.js middleware on first authenticated load

session_start();

if (!isset($_SESSION['user_id'])) {
    http_response_code(401);
    echo json_encode(['error' => 'No active session']);
    exit;
}

$userId  = $_SESSION['user_id'];
$role    = $_SESSION['user_role'];
$expires = time() + 300; // 5-minute bridge window

$payload = base64_encode(json_encode([
    'uid'  => $userId,
    'role' => $role,
    'exp'  => $expires,
]));

// Shared secret lives in .env on both the PHP and Node sides
$signature = hash_hmac('sha256', $payload, getenv('BRIDGE_SECRET'));

header('Content-Type: application/json');
echo json_encode([
    'token'     => $payload . '.' . $signature,
    'expiresAt' => $expires,
]);

It worked. It also became critical infrastructure we hadn't planned for, weren't testing properly, and which silently failed if the PHP session had expired between the time Next.js loaded and the time the middleware made the bridge request. We added retry logic, then monitoring, then alerts. A "temporary" bridge endpoint lived in production for eleven months.


The database problem nobody mentioned

Running two applications against one database sounds fine until you think about it for five minutes. Both apps were writing. Both had their own connection pools. The PHP app used Eloquent with soft deletes on almost every model, where a deleted_at timestamp marked a row as gone without physically removing it. The Next.js API layer, using Prisma, had no idea this convention existed.

For three months, when users deleted a client in the new Next.js interface, the record was hard-deleted. The PHP app, expecting soft deletes, started throwing null reference errors when related data tried to load a parent record that was now gone. The errors were silent, logged to a file no one was actively watching.

We found out when a client called to ask why six months of job history had disappeared. They'd deleted and re-added a client thinking it would "reset" the account. Instead, it permanently destroyed everything associated with that client's ID. Jobs, invoices, notes, history. One hard DELETE CASCADE and it was gone. That phone call was the worst phone call of my year.

18 months (not 3)
11 months the session bridge lived in prod
1 client's 6-month history lost to a soft-delete mismatch
43 DB tables with undocumented conventions

What we got right (eventually)

By month six, we stopped pretending the rewrite was on schedule and started treating it as the long migration it actually was. A few things finally turned the tide:

  • API contracts first. We stopped letting both apps touch the database directly. Every module got a versioned API contract written before implementation. Both the PHP adapter and the new Next.js frontend talked through that API. No more shared-DB surprises.
  • Feature flags instead of URL routing. We moved off Nginx URL-pattern routing to a per-client flag system. Each account could be opted into specific Next.js features independently. Rollbacks became a config change instead of a deployment.
  • A database conventions document. We spent a week writing down every implicit convention in the legacy schema: soft deletes, JSON column formats, enum string values, composite key patterns, timezone assumptions. Every new API endpoint had a checklist item for each.
  • An owner for the seam. One engineer owned the boundary between old and new full-time. That role shouldn't need to exist in a well-planned migration. In a real one, it's the most important seat on the team.

The day we flipped the switch

Month seventeen. Every module migrated. The PHP app served exactly one page, a legacy summary report one enterprise client relied on. Feature flags were at 100% for all other clients on all other routes.

We shut down the PHP server on a Tuesday at 2 PM. I watched Nginx access logs for twenty minutes. No 404s, no 500s, no session bridge requests. The new system handled everything without a flicker.

There was no celebration. More like exhaling after holding your breath for a year and a half. The next feature request was two engineers, three days, shipped clean. No Blade templates, no mystery helper files, TypeScript components, typed API contracts, tests that ran in twenty-eight seconds.

The monolith was dead. The strangler fig had finished the job. It just took eighteen months instead of three.


What I'd tell someone starting this tomorrow

Don't rewrite. Migrate. A rewrite is "start over and do it right." A migration is "move everything of value into a new home without breaking what already works." Migration is slower, messier, and far more likely to actually ship.

Triple your timeline estimate, then add six months. Not because you're slow. Because the old system will reveal new implicit contracts every week.

Document the seam obsessively. Every place the old and new systems touch is a bug waiting to happen. Auth handoffs, session formats, database conventions, file storage paths, cache key namespaces. Write it all down. The day you assume the other system handles something the same way yours does is the day a client loses data.

Give someone ownership of the migration itself. Not as a side project. Not "when they have time." It's the most important engineering work happening during that window, and it needs an engineer who wakes up every morning thinking about the seam.

Eighteen months. One phone call I'll never forget. A codebase that now ships features in days instead of weeks. Worth it, I think.

Share this
← All Posts13 min read