architecture

Replacing Laravel with Spring Boot behind the read/write split

Why we moved the write API from Laravel to Spring Boot, what the read/write split made replaceable, the generator that turned the Prisma schema into Java, and the compatibility problems that only surface during a cutover.

A while ago I wrote about the read/write split. GraphQL Yoga and Pothos handle reads. Laravel handles writes. The Prisma schema generates types for both sides.

That post ended with the costs of the split: two deployments, two CI pipelines, two sets of logs, and a mental model where the frontend developer has to know whether an operation is a query or a mutation.

What I did not know when I wrote it was that we were about to make one of those two services disappear. Not the split itself, but the Laravel half of it. The write API is now Spring Boot.

Why we made the call at all

Before the how, the why, because “we moved a service to a different language” is a strange decision to read on its own. Nobody moves a working application because they prefer the syntax.

Most of the core products at the company are already Java. That is the language the wider engineering organisation builds in, and this application was an exception to it. One application in the portfolio speaking a different runtime is a small decision when the team is small and an increasingly expensive one as the product grows.

That alone would not have justified the work. What changed is the second half of the argument, and it is about how code gets written now.

In 2026 a large share of the code we write is written by AI agents. That does not make language choice irrelevant, it changes what makes a language choice expensive. The cost of writing code in a language has gone down a lot. The cost of everything around the code has not. Reviewing it, running it, owning it at three in the morning, and staffing the team that has to understand it.

Two things follow from that, and both point the same way.

The first is hiring. A product written in the same stack as everything else can be worked on by anyone already in the organisation. We can move an engineer between products without a language transition, and we can hire one person who is useful across all of them instead of one person who is useful on this one. That is a real constraint, and it does not get smaller with time.

The second is contribution. A single application in a separate stack is a place where fewer people can help. Reviews need a reviewer who knows the language. An incident at an awkward hour needs someone who can read the code. The smaller the pool, the more the application depends on the few people who can work in it.

So the decision was to bring the product into the stack the rest of the company builds in. If most of the code is going to be produced with heavy agent involvement anyway, then the differentiator is not which language an agent can write. Agents write anything. The differentiator is how many people on the team can review, own, and extend what comes out.

That is the honest reason. It was not performance, and it was not a preference for Spring over Laravel. It was consolidating on one stack so that the team and the hiring pipeline stop being split across two.

Why the split made this possible at all

If the application had been a single Laravel service serving both reads and writes, replacing the runtime would have meant replacing the whole product at once. Every page, every endpoint, every query. That is a rewrite, and rewrites at this scale usually fail.

The split meant the write side was already isolated. It had one job: accept POST, PUT, PATCH and DELETE, validate, authorize, persist, and push work onto queues or Temporal. Nothing reads through it. So it could be replaced by a different application as long as that application did the same job with the same contract.

The reads never moved. The Next.js frontend never moved. The GraphQL schema never moved. The Prisma schema never moved. One of the three applications swapped its runtime, and the interface it exposed stayed the same.

That is the argument for splitting reads and writes that I did not make clearly enough the first time. It is not just that reads and writes want different tools. It is that a clean boundary is what makes either side replaceable.

The port

The work took two weeks, in numbered stages. That structure was not cosmetic. Porting a mature application is hundreds of small decisions, and without an order you end up debugging the whole thing at once.

The order that worked:

  1. Scaffolding and generated code. Spring Boot build, Dockerfile, and the generated JPA entities and Spring Data repositories produced from the Prisma schema.
  2. Application bootstrap and security. JWT, HMAC and service token filters.
  3. Domain ports. Mail, attachments, websockets, then the queue harness on RabbitMQ.
  4. Schedulers. Unallocated ticket retries, task retries, roster nudges, SLA reconcile.
  5. Temporal workflows. The reminder workflow, the ticket lifecycle workflow, and the task graph workflow with its parallel split, join, external wait and agent nodes.
  6. Observability and packaging. Metrics, tracing, error reporting, profiling, and the Helm chart.
  7. The allocation engine.
  8. The cutover.

Step one deserves more than a line, because it is where the schema stopped being a TypeScript concern and started being a Java one.

One schema, one more output

The Prisma schema was already driving five outputs for the TypeScript side. A new generator made it six:

generator java_spring {
  provider = "prisma-java-spring-generator"
  output   = "../../../apps/api-java/src/main/java/com/example/api/generated"
}

It writes into three packages: model for the JPA entities, repository for the Spring Data interfaces, and id for composite key classes. Roughly 225 of the Java files in the write service are generated, out of about 530 total.

The handwritten code sits outside that package by convention, and the generated header says so in every file:

/*
 * Auto-generated by prisma-java-spring-generator.
 * ...
 * Handwritten business logic must live outside this generated package.
 */

That header is doing real work. A generated package that people are allowed to edit stops being generated, and the next db:generate silently overwrites the edit.

The migrations did not change at all

The consequence I did not expect: Prisma migrations are SQL, and SQL is not framework specific.

When the write API was Laravel, the migration files and the Eloquent generator had to agree about the database, which meant an entire generator dedicated to keeping PHP in sync with a schema it did not own.

With Spring, the same migrations run untouched. The Java side reads the schema the same way the TypeScript side does. The database never noticed the runtime change, because the only thing that talks to it is still Prisma and raw SQL.

The header on the first post said the schema was the single source of truth. A full runtime replacement of the write API is the strongest test of that claim I can think of, and it held.

What actually took work in the generator

Generating a class per model is easy. The mapping is where the time went, and three problems account for most of it.

Identity generation for serial columns

Prisma’s @default(autoincrement()) maps to a Postgres SERIAL style column. JPA has a few ways to model that, and the obvious ones are wrong for this schema.

The generated entity uses a SEQUENCE generator, and the entity implements Persistable with an isNew() so Spring knows the difference between an insert and an update. This is the part that usually breaks quietly. If Spring cannot tell a new entity from a detached one, it issues an update where you wanted an insert, and you get zero rows written instead of an error.

There is a shared generator class for this case, with a comment explaining that it exists for columns that are serial style. It looks like overkill for a mapping problem. It is not, because the alternative is every entity guessing and some of them guessing wrong.

Composite primary keys

Prisma supports @@id with multiple fields. JPA requires an @EmbeddedId class with equals and hashCode. There is no sensible way to write that inline in the entity.

The generator emits a separate id class per composite key model. Boring generated code, and boring is correct here. Handwriting composite key classes is exactly the kind of task where a typo in hashCode produces a bug that only appears under specific query patterns.

Models with a single column primary key keep the id inline and do not get a class. The rule is simple: generate the extra file only when the schema requires it.

Enums

Postgres enums map to Java enums, and the names do not always line up. Prisma enum labels are the database values. Java constant names cannot contain some of the characters a Postgres enum label can, so the mapping has to be explicit.

The generator emits two things per enum: a Java enum and a small type class that maps Java constant names to the database labels. It is the least interesting code in the package and the one I am most glad not to maintain by hand, because an enum label change is exactly the sort of thing a reviewer skips over.

Where the schema stopped helping

This is the part I would want to read if I were doing this again. Porting an application is not rewriting it. The old one is running in production, and anything that consumed its behaviour is still out there.

Everything the schema covered came across for free. Everything outside the schema had to be handled by hand, and that is where the real work was.

Encryption has to keep working on old rows

Some columns hold values encrypted with the Laravel application key. Tokens, mostly, for the external services we integrate with.

A Spring Boot application with a new encryption implementation would work perfectly for anything it wrote and fail on every row written before the cutover. The data would look corrupt, not encrypted, and no amount of testing on fresh rows would catch it.

So the first version of the port implemented a Laravel compatible encrypter: same key derivation, same payload format, so the new service could read what the old service wrote. Only after that was verified did we move to a Spring Security TextEncryptor for new writes.

The lesson generalises. If your port touches encrypted data or signed tokens, the compatibility requirement is not about the API contract, it is about the bytes already in your database.

Error responses are part of the contract

One of the stage ten fixes was aligning 404 and 400 response shapes with what Laravel produced.

Nothing in our own code depended on those shapes. But error responses leak into places you forget: frontend error handling that checks a field name, end to end tests asserting a status and a body, and anything else that treated a 400 as more than a number.

The fix is cheap once you know, and impossible to guess. We found it by running the existing end to end tests against the new service and treating every failure as a contract difference rather than a bug in the test.

Commit timing changes behaviour

A bug that only exists in the new runtime: a job was being dispatched before the transaction that created its input had committed. In the old code path this worked by accident. In the new one the job ran against a database that did not yet contain the row.

Spring has publishAfterCommit for exactly this. The point is not the annotation. The point is that when you move an application to a different framework, the transaction and event semantics change underneath you, and code that was correct by luck stops being correct.

I would expect this class of bug in any port. It is invisible in review because there is nothing wrong on the line being changed.

Consistency with the old behaviour needs its own pass

Before the cutover we went back to the old repository and ported the fixes made to the old code after the new port had started. Both services were live in development at the same time, and bugs were still being fixed on the old side.

This is easy to forget and annoying to discover later. If the old service keeps receiving changes while you port, you need a deliberate step at the end that reconciles the two, or you will ship a regression that was already fixed.

Deleting the generator that lost its runtime

At the end, prisma-eloquent-generator was removed along with the eloquent package it wrote into.

I mention the deletion because it is the more interesting half of the generator story. A generator is a commitment. It is a dependency that tracks the Prisma schema, that breaks when Prisma changes something internal, and that has to be understood by anyone who touches the generated output.

We are running one fewer generator and one fewer runtime now, and the codebase is smaller. That is the right trade when the thing the generator existed to serve no longer exists.

What I would not do is keep a generator around for a runtime nothing uses, in case it is needed again. Dead generators produce dead code that still shows up in reviews and still confuses whoever reads the package list next.

What the cutover actually looked like

The cutover was not a code change. It was scaling the old workloads down, updating the ingress so write traffic reached the new service, and deleting the old one from the local stack.

That the cutover was a deployment change rather than a development task is the whole point of the split. Every stage before it was independently testable, because reads never depended on which application served writes. If the port had failed halfway, the product would still have worked. We would have abandoned the branch and been exactly where we started.

That is not true of a rewrite where both sides move together. There is no halfway that ships.

The numbers

Around two weeks of staged work. About 530 Java files in the write service, of which 225 are generated and 160 are tests. One application replaced. Three applications still in the monorepo, the same three as before, minus one runtime. The migration chain untouched.

The parts that were boring: generated entities and repositories, queue consumers, scheduler wiring, the Helm chart. All mechanical, all verifiable against the old behaviour.

The parts that were genuinely hard: encryption compatibility, error shape compatibility, transaction timing, and the reconciliation pass at the end.

Notice that none of the hard parts are about Java. They are all about the fact that a running system has a contract broader than its API documentation, and a port has to satisfy all of it.

What I would do differently

I would do the compatibility inventory first. Before writing any of the new application, list every place where data crosses the boundary in a format that outlives the process: encrypted columns, signed tokens, cached payloads, queue message shapes, error response bodies, and date or number formatting. An inventory would have turned four debugging sessions into one afternoon of reading.

I would also start the end to end tests against the new service much earlier. Not after the domain code exists, but as soon as the application boots. The tests are the specification for “does this behave like the old one”, and the earlier they are running, the less code you have to re-examine when one fails.

The thing I would keep exactly as it was is the staging. Two weeks of ordered steps looks slow on a plan. It was the fastest part of the whole project, because at no point were we debugging an application that did not run.

And the schema decision I would make the same way. Generating the structural layer from one source is what turned a runtime replacement into a mapping exercise plus a compatibility checklist, instead of a rewrite of every layer that touches the database.

The part that mattered most

The technical work is the part I can describe precisely, so it takes up most of this post. But if the question is whether it was worth doing, the answer is not in the file count.

Consolidating on one stack changes who can work on the product. That is a slow, compounding thing rather than a measurable one. It shows up when someone from another team picks up a bug in this service without needing an introduction to a language, and when a hiring conversation stops being about finding the one person who knows both the product and the runtime.

The reason the timing worked is the same reason a lot of things changed this year. Most of the code is produced with agents in the loop now, which made the writing cost of a migration much lower than it used to be. What did not get cheaper is the reading, reviewing and owning. Those are done by people, and every stack you add divides the pool of people who can do them.

So the call was to stop being the exception. Everything the team builds in, the product joins. Fewer runtimes, one hiring profile, more people who can contribute on the day it matters.