Every digital banking platform eventually hits the same wall: the monolith that shipped a decade ago still works, still processes real money for real account holders every day, and still cannot be touched without fear. Ours was a PHP application built with Laravel, serving multiple financial institutions from a single, tightly coupled codebase. It had grown feature by feature, bank by bank, for years. The rewrite wasn’t optional. Every new institution we onboarded meant another branch of conditional logic, another set of hacks specific to one bank bolted onto shared controllers, and another opportunity for one institution’s change to break another’s production environment.
This article covers the architecture decisions we made while rewriting that platform as a multitenant system driven by configuration, and what the migration process taught us about verifying legacy behavior instead of assuming it.
The core decision: separate “what the UI looks like” from “what the business logic does”
The biggest architectural decision was splitting the platform into three layers instead of one: a thin frontend driven by configuration; a backend for frontend (BFF) that owns all business logic, session state, and vendor orchestration; and a config service that serves server-driven UI (SDUI) definitions per institution.
In the legacy system, “multitenant” meant if ($bank === 'X') scattered across views and controllers. Every institution’s quirks lived inside the same PHP files as everyone else’s, which meant every deployment carried the blast radius of every tenant at once. The new architecture inverts that. The mobile and web clients render forms, layouts, and copy from JSON served by the config service, merged through three layers: platform defaults, core banking provider defaults, then tenant-specific overrides. A new institution’s branding, field visibility, or feature flags become a config change, not a code change. The frontend contains no logic specific to an institution. It renders what the configuration defines.

That decision to push all business logic into a backend service and make the frontend a renderer let us migrate in stages. We could stand up new BFF endpoints one feature at a time behind the same configuration-driven client without a single cutover event. Legacy and new systems ran side by side for months, split by institution and feature, and nobody outside the engineering team could tell which backend was serving a given screen.
The security decision: stop letting the client hold the keys
The second major decision came from an audit of the tokens flowing between the client and the backend. Like many systems that grow over time, the legacy platform’s API responses included internal identifiers and session tokens issued by the core banking system for a given member or account. These appeared in payloads that the client held and replayed on subsequent calls. The approach worked, but it meant an artifact sitting on the client carried a token minted by the core banking system itself, and every downstream service had to trust whatever the client sent back.
We restructured this so the BFF never lets a core banking session token leave the server. On initial resolution, the BFF holds the real token on the server, keyed by its own session identifier, and the client sees only that opaque session UUID. Every subsequent request resolves the real token from server-side session state rather than trusting anything replayed from the client.
The mechanical part of this change was straightforward. The challenge was scope: identifying every place where a value shaped like a real token was flowing, not just fields with an obvious name such as memberToken. We found aliased fields carrying the same sensitive value under a different property name. A search based on field names missed them because the value mattered, not the label attached to it.
We also found flows that broke a simpler mental model. A couple of endpoints cached resolved session context across two separate HTTP requests: an MFA challenge and its resumption. A migration that always resolved the token from server-side session state would have broken authentication resumption the next time that challenge fired. Unit tests would not have exposed the failure because they typically mocked the layer where the gap lived.
The fix in those cases was to carry an explicit internal object, never exposed to the client, through the cached challenge state and reseed server-side session context from it before resuming. This was a deliberate design decision rather than a mechanical rename, and one worth flagging for review rather than treating as a routine migration change.
The performance decision: let Redis carry the weight of session state
Moving the real token off the client solved the trust problem, but it introduced a new one. The BFF now had to resolve that token, the member’s profile, and account-level context on every request that needed them. Calling the core banking system each time would have made the new platform slower than the one it was replacing. The third decision was to cache resolved session state rather than fetch it on every request.
Once a session is established, the BFF writes the real token and the resolved member and account context into Redis under the opaque session identifier, with a time to live tied to the permitted session duration. Every later request in that session reads from Redis instead of paying the round-trip cost of a call to a core banking system that, in most deployments, is the slowest and most rate-limited dependency in the request path. What used to require a network call to an external system on every screen became an in-memory lookup, reducing fetch times for requests that depended on member or account context.
The cache keys are never a flat string. Each one is built from three parts: what is being cached, which tenant it belongs to, and an optional subkey for the specific record. This structure prevents a cached profile for one institution from colliding with an identically shaped record for another. It also means an entire tenant’s cache can be cleared or inspected without touching anyone else’s data, which matters when a single Redis instance serves every institution on the platform.
The same pattern caches the merged three-layer configuration for a tenant because recomputing that merge on every request would reduce the latency gains Redis provides. The result for account holders was fewer round trips per screen and lower dependence on fresh core banking lookups for each action.
The methodology lesson: documentation is something you build, not something you inherit
One constraint shaped the migration process: there was no migration specification handed down from the old system. Nobody had written a document describing what the legacy application did feature by feature. The old codebase itself was the only complete record of the business rules. Institutional memory had faded, the original authors were gone, and the Blade templates and controllers were the closest thing to a specification that existed.
The practice became clear: for every feature we migrated, we read the legacy implementation rather than relying on a summary and documented what we learned as we went. That growing set of documents became the migration’s specification, built one feature at a time instead of inherited on day one. The documentation became valuable, but we learned that it was not equivalent to the source itself.
During one debugging session, three separate “bug fixes” based on our migration notes turned out to be wrong once checked against the legacy source. Each one moved behavior away from what the old system did in production rather than back toward it. In each case, the notes had conflated two separate legacy checks or described a distinction that sounded reasonable but that the original code didn’t make.
The documentation was written in good faith by someone summarizing a page of PHP in a sentence, but the summary lost a detail that mattered.
The corrective habit was clear but easy to skip under time pressure: treat every internal document, including the ones we wrote ourselves, as a working hypothesis about the legacy system rather than a fact about it. Before relying on a written description of “what the old system does” to justify a fix, reread the legacy implementation. Plausibility is not evidence. A behavior that “seems right” for a banking flow is worth only as much as the source code confirms.
Every time the source and documentation disagreed, we corrected the documentation, so the record improved as the migration progressed. It never replaced the source as the final authority. The principle also works in the other direction. Not every divergence from legacy behavior is a bug. Some are intentional improvements that have received explicit approval. The discipline lies in knowing which is which before acting rather than assuming either case.
What carried over
None of these decisions were exotic. UI driven by configuration, a BFF that owns business logic, and server-side token indirection are well-established patterns. What made them work in a live, regulated, multitenant banking system was discipline in the implementation details: treating the legacy source, not a document about it, as the final authority; scoping security fixes by the value flowing through a field rather than its name; and building the migration documentation alongside the code instead of waiting for a finished specification that was never coming.
The architecture gave us the seams to migrate safely. The process discipline kept the migration honest.
Building a Safer Path for Banking Modernization
Legacy banking systems can be modernized without forcing a single high-risk replacement. Phased migration, clear service boundaries, secure session management, and careful verification of existing behavior can help teams modernize while protecting critical banking operations. For teams working through similar architecture challenges, explore our core banking modernization services.







