Migrating from Laravel to NestJS: Lessons from a Fintech Rewrite
When I joined TamPay, the Celler platform was a working Laravel app serving real users. The brief was simple: rewrite it in NestJS without breaking anything. This is what that looked like in practice.
Why rewrite at all?
The honest answer is: the codebase had grown beyond what Laravel's conventions could contain cleanly. A fintech platform with crypto-fiat flows, multi-role permissions, and a growing merchant API surface needs typed contracts between layers. PHP is expressive but the lack of compile-time safety in the V1 codebase meant bugs were caught in staging at best, production at worst.
NestJS + TypeScript gave us three things we couldn't easily retrofit onto the PHP codebase: a module system that enforced boundaries, DTOs that validated at the edge, and dependency injection that made the whole thing testable.
The migration strategy
We did not do a big-bang rewrite. Big-bang rewrites fail. Instead we used the strangler fig pattern: stand up the new NestJS service alongside the old Laravel app, route traffic to NestJS endpoint by endpoint, and let the old service die gracefully.
The proxy layer (nginx) handled routing — requests to migrated routes hit NestJS, everything else still went to Laravel. This meant both services ran in parallel for about six weeks, sharing the same PostgreSQL database.
The database parity problem
Sharing a database between two services sounds scary. It worked because we drew a strict rule: during migration, NestJS only writes to tables it fully owns. Laravel handles the rest. We used table-level ownership tracked in a migration manifest — a simple JSON file that listed which service owned which table.
The real headache was Eloquent's implicit conventions. Laravel timestamps your columns as created_at / updated_at using its own format. TypeORM has a different default. You discover this the moment a Laravel-written row gets touched by TypeORM and the timestamp precision changes. Fix: explicitly configure TypeORM to match Laravel's timestamp format and column naming conventions in the shared tables.
What NestJS unlocked
- ·Guards and interceptors gave us a clean place to put auth, logging, and rate limiting — things that lived in middleware spaghetti in Laravel.
- ·class-validator DTOs meant bad input never reached business logic. In Laravel, validation lived in the controller and was frequently skipped during internal calls.
- ·The module system forced us to think about bounded contexts. Each domain (wallets, transactions, merchants) became its own module with explicit imports.
- ·TypeScript caught entire classes of bugs at compile time. No more 'undefined is not a function' in production at 2am.
What we got wrong
We underestimated the crypto/fiat flow complexity. The old Laravel code had a lot of implicit state managed through database flags that weren't documented. We only discovered some of it when edge-case transactions started behaving oddly in staging. Lesson: before migrating financial transaction logic, write integration tests against the old system first to capture its behavior — even the undocumented parts.
We also spent too long debating ORM choice (TypeORM vs Prisma). We went with TypeORM for the migration because of its alignment with NestJS decorators and because switching ORMs and frameworks at the same time felt like too much risk. In hindsight, the extra schema control TypeORM gave us was worth it for a fintech context.
Would I do it again?
Yes — but I'd invest more time in the parallel-run testing phase. Running both services simultaneously is the safety net; the mistake is taking it down too early. Keep Laravel alive for at least two billing cycles after you think the migration is done. You'll catch edge cases you didn't know existed.
This is based on the Celler V2 rewrite at TamPay. Some details are generalized for confidentiality. If you're planning a similar migration and want to talk through the approach, drop me an email.