Skip to content
All case studies
Payments / Fintech·Senior Full-Stack Developer·2024–present

Re-platforming a payments system: four legacy codebases into one

A payments platform had grown into four separate legacy codebases nobody could safely change. I designed the modern replacement - a three-tier, type-safe, modular architecture - and, as its primary developer, am rebuilding it feature by feature, with no big-bang cutover.

~75%
of the new platform authored personally (1,815 commits)
4 → 1
legacy codebases consolidated into one modular platform
5
repositories designed and built end to end

Stack

PHP 8.4Mezzio / LaminasDoctrine ORMSlim 4RedisNuxt 4Vue 3Vuetify 3TypeScriptOpenAPIMariaDBDDEV

In one line

I designed the target architecture for the ground-up rebuild of a payments company's internal platform - and as the primary developer I've written roughly 75% of it (1,815 commits across five repositories), turning four tangled legacy systems into one modular, type-safe platform that a team can actually extend.

The context

This is the internal platform behind a payments business - the system staff use to manage thousands of merchants and terminals: onboarding, product and PIN/serial stock, reporting, settlement, content management. It works. It also can't keep up with what the business needs next, because of how it's built.

When correctness is tied to real money, "rewrite it" is the easy sentence and the hard job. The hard job is replacing the engine while the plane is flying - and doing it without betting the business on a single switch-over.

The mess I inherited

The platform wasn't one legacy system. It was four, layered up over a decade:

  • A Zend Framework 1 backend exposing SOAP and JSON-RPC
  • A Zend Framework 2 backend exposing REST on Doctrine 2.5
  • A ZF2 + AngularJS 1.2 hybrid - server-rendered pages and a SPA
  • A separate Vue 3 SPA monorepo of ~11 apps stitched into that frontend

Four runtimes, three eras of frontend, two RPC styles, and business logic smeared across all of them. A single feature could touch several codebases; no one could say where a rule actually lived; and onboarding a developer meant teaching four architectures at once. Worse, the data layer was full of traps the new system would have to keep working with - the old database doesn't get to be rewritten on the same schedule as the code.

The architecture I designed

I designed a clean three-tier system to replace all four, along with the patterns the rebuild is built on.

Backend - a modular monolith (Mezzio / PHP 8.4, Doctrine ORM)

One deployable, but internally split into domain modules - auth, customers, merchants, terminals, payment, reporting, PIN management, content. Every request flows through a strict Handler → Service → Repository layering, and that boundary is enforced in CI (not just documented), so the modular structure can't quietly erode into a ball of mud. Services return DTOs, never entities, which keeps the public API contract decoupled from the database schema - the schema can stay ugly and legacy while the contract stays clean.

// A handler stays thin: parse input, call a service, return a DTO.
// Business logic never leaks up into HTTP, and persistence never leaks up
// into business logic.
final class FetchCustomerStoresHandler implements RequestHandlerInterface
{
    public function __construct(private StoreService $stores) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $customerId = (int) $request->getAttribute('id');
        $criteria   = SearchCriteria::fromRequest($request); // page, size, search, sort

        // Service returns DTOs; the handler never sees a Doctrine entity.
        return new JsonResponse($this->stores->listForCustomer($customerId, $criteria));
    }
}

The request pipeline itself is an explicit, ordered middleware stack - error handling, env validation, body parsing, routing, banned-request filtering, authentication, route verification, forced-action checks (e.g. mandatory password change), authorization (ACL), then dispatch. Auth is pluggable through adapters (username/password against DB or LDAP, SSO via Google / Azure AD, and JWT bearer-token validation for every subsequent request), with MFA (email OTP or TOTP) enforceable globally or per role.

Middleware - a thin proxy tier (Slim 4)

It would have been simpler to let the SPA call the API directly. The proxy earns its place: it owns session and token validation, injects auth headers, handles CORS, and caches static lookups in Redis - so the API stays focused on business logic and the frontend talks to exactly one front door. Backend ↔ middleware traffic runs over an internal network, never the public edge.

Frontend - a layered Nuxt 4 / Vue 3 SPA (Vuetify 3)

Organised into layers (base, auth, administration, customers, …) with server-side pagination, full i18n, and a database-driven ACL surfaced through a single useAcl() composable. On login the API returns the user's allowed resource names; the frontend renders tabs, buttons and actions against that list instead of scattering permission logic through dozens of v-ifs:

const { isAllowed } = useAcl()
// One source of truth for "can this user see/do this", driven by backend ACL.
const canEditCustomer = computed(() => isAllowed('ui/editCustomer'))

Contract - a generated, type-safe SDK

The frontend never hand-writes API types; it consumes a TypeScript client generated from the backend's OpenAPI spec, so the two repositories cannot drift apart. That pipeline is its own story → Killing API schema drift.

Decisions that mattered (and the tradeoffs)

  • Modular monolith, not microservices. A small team replacing four legacy systems does not need a distributed-systems problem layered on top of a domain problem. One deployable with hard internal boundaries (enforced by static analysis) gives most of the modularity benefit and almost none of the operational tax. The boundaries are real; the network hops aren't. If a module ever needs to split out later, the seams already exist.
  • A dedicated middleware tier. The cost is an extra hop and another service to run. The payoff is one home for session handling, caching, and cross-cutting concerns - and a seam where responses can be shaped or cached without touching business logic in the API.
  • Patterns over heroics for the legacy data. The old database is full of traps: polymorphic tables keyed by a type-discriminator column, 0000-00-00 used as a "not deleted" sentinel, raw HTML stored in free-text fields. Rather than let each developer rediscover these the hard way, I encoded them as reusable patterns - a shared polymorphic entity with per-domain services, a SoftDeleteable interface, a centralized HTML purifier injected wherever user text is stored, and a findOrFail() on the base repository so existence checks are one consistent line instead of ten hand-rolled null checks. New domains reuse them instead of re-solving them, which is what keeps five developers writing code that looks like it came from one.

Migrating without a big bang

There is no flip-the-switch cutover, by design. The work is organised into phases, and each feature is independently deployable - it goes live when it passes QA and a legacy-parity check, not at the end of a phase. Each migration follows the same disciplined flow: analyze the legacy implementation, rebuild the backend (entity, repository, service, DTO, handlers, tests), regenerate the typed SDK, wire the middleware route, build the frontend, then verify against the legacy system for parity. That keeps risk small and continuous instead of hoarding it all for one terrifying weekend.

It also means migration is a chance to question the old design, not just copy it. (One such moment - a query the legacy system had quietly been running for years - turned into a 660× optimization.)

Where it stands

I designed this platform from an empty repository and, as its primary developer, authored roughly 75% of the codebase (1,815 commits across five repositories), working within a five-developer team. The new stack runs and is validated in a test environment, with features rebuilt and signed off module by module against the legacy system; production rollout is phased.

The honest summary: this isn't a finished migration I'm taking a victory lap on - it's a large, in-flight modernization where I shaped the architecture, set the patterns, and wrote most of the code. The win I care about is structural: the replacement is something a team can safely extend, instead of four systems everyone was afraid to touch.

Let’s build something that lasts

Hiring a senior engineer or architect for a remote team? I work across EU and US time zones - tell me what you're building.

Newsletter

I write about software architecture, PHP, Vue, TypeScript, and developer experience. No spam, unsubscribe anytime.

Copyright © 2026. All rights reserved.