A banking backend in Laravel 13: architecture walkthrough
Laravel 13 arrived on March 17, 2026, requires PHP 8.3 at minimum, and gets bug fixes through Q3 2027 and security fixes until March 17, 2028 (release notes). None of that is banking. Together it is a foundation you could defend in an audit.
So the exercise: design the core of a bank, accounts, transfers, KYC, on Laravel 13, and mark where the framework carries the load and where my code has to. The walkthrough draws on a real banking backend I worked on, anonymized and simplified, and nothing below is compliance advice. The anchor is real: six months of Laravel 13 running my multitenant SaaS, whose queues already carry payment webhooks.
Why would a bank care about release dates?#
A bank plans in years, and Laravel publishes the calendar: majors yearly around Q1, bug fixes for 18 months, security fixes for two years, every release. Laravel 11's security window closed on March 12, 2026, and Laravel 12 stopped receiving bug fixes on August 13, 2026. Laravel 13 runs to Q3 2027 and March 17, 2028. In a regulated codebase that is an audit finding waiting on a calendar. Treat the 2028 date as a deadline now, with eighteen months of runway and a one-day upgrade path that my first half-year on the framework already tested.
Drawing the module walls#
The architecture is a modular monolith: one deployment, one database, hard internal borders. Laravel neither helps nor stops you. The structure documentation describes a single application tree with no module system, so the walls are convention: a CI check on cross-module imports, one service provider per module.
| Module | Owns | Never touches |
|---|---|---|
| Ledger | Accounts, balances, journal entries | Users, providers, documents |
| Payments | Transfer intents, idempotency keys, provider clients | Balance arithmetic |
| KYC | Documents, review states, screening results | Money movement |
| Identity | Users, credentials, passkeys, sessions | Journal rows |
| Notifications | Templates, delivery, retries | Business decisions |
Modules talk through actions and interfaces, never through each other's tables. A boundary nobody can read off one page dies at the first deadline.
Thin controllers, loud attributes#
The HTTP layer validates and hands off. Laravel 13's controller attributes make the security posture readable at the class level (controllers documentation):
#[Middleware(['auth', 'verified'])]
class TransferController
{
#[Authorize('create', [Transfer::class])]
public function store(StoreTransferRequest $request, InitiateTransfer $initiate)
{
return TransferResource::make(
$initiate->handle($request->user(), TransferData::from($request->validated()))
);
}
}The form request validates, a DTO freezes the result into typed properties, and the action receives an object whose types tell the truth instead of an array whose keys you must remember. It then runs from a job, an artisan command, or a test. Fat controllers are a smell in a blog app. In a bank they are a finding.
How does money move exactly once?#
Moving money exactly once rests on double-entry bookkeeping and idempotency, and provenance matters. Every transfer writes two journal entries, one debit and one credit, and balances are derived from the ledger rather than trusted as stored opinions. Idempotency: the client sends a key with each transfer, honored exactly once. Neither comes from the framework. The database documentation gives me transactions, the bookkeeping is mine:
DB::transaction(function () use ($data) {
$seen = IdempotencyKey::where('key', $data->idempotencyKey)
->lockForUpdate()
->first();
if ($seen) {
return $seen->transfer;
}
Entry::pairFor($data); // one debit, one credit
return Transfer::settle($data, IdempotencyKey::record($data->idempotencyKey));
});Laravel's own idempotency machinery lives one level down, at dispatch: ShouldBeUnique, the #[UniqueFor] attribute, and WithoutOverlapping middleware lean on atomic cache locks (queues documentation). None of it stores the client's key. That table and its row lock are application code. My SaaS runs the same shape one scale down: byte-identical webhook redeliveries die on a hash journal, never settling twice.
Queue discipline for money jobs#
Money jobs get the full attribute set, because failure modes are the point:
#[Tries(5)]
#[Backoff([30, 300, 900])]
#[Timeout(60)]
#[FailOnTimeout]
#[MaxExceptions(3)]
#[CountCrashesAsExceptions]
class SettleTransferJob implements ShouldQueue
{
}#[Tries] on the class beats the worker's --tries flag, and #[Backoff] spaces the retries. The timeout pair turns a hung settlement into a failure, and the exception cap bounds attempts that end in exceptions. The newest one, September's crash counter, makes an attempt that dies with an OOM-killed worker count toward that cap (changelog, PR laravel/framework#61737). That is the difference between a bounded failure and an infinite retry.
Queue::route() is new in 13, matching by class, interface, trait, or parent class:
Queue::route(SettleTransferJob::class, connection: 'redis', queue: 'payments');
Queue::route(RunsKycScreening::class, queue: 'kyc');
Queue::route(SendsCustomerMessage::class, queue: 'notifications');- payments: one worker, serialized behind the ledger. Nothing slow shares the lane.
- kyc: third-party screening calls, minutes long, flaky. Isolation protects payments from vendor outages.
- notifications: cheap, loud, first to shed load. My queued Telegram alerts live there and never needed to know what a ledger is.
The queue docs recommend this split, workers taking queues by priority:
php artisan queue:work --queue=payments,kyc,defaultPasskeys, with an asterisk#
Login is where the exercise meets vendor decisions. The default starter kits ship Fortify with TOTP two-factor, solid and unglamorous. Passkeys enter through the WorkOS AuthKit variant of each starter kit, React, Svelte, Vue, or Livewire, picked with a flag at laravel new, free up to one million monthly active users (starter kits documentation). Some release coverage called this "passkey authentication" in Laravel. The documentation says WorkOS variant and stops there, a vendor dependency a bank should choose with open eyes.
Passkeys here mean the WorkOS AuthKit starter kit variant. The framework has no native passkey support. I have not verified WorkOS certifications against any banking regulator, so the compliance question stays open.
Login and KYC stay separate: documents and review states live in the KYC module, authentication decides who is asking.
What to steal for your project#
The list outlives the bank:
- Draw the module table before the first migration, and let CI enforce it. Walls drawn later are decoration.
- Push every money write into an action behind a DTO, callable from jobs and tests.
- Wrap the paired entries and the idempotency key in one transaction, and test the double submit first.
- Copy the full attribute set onto any job that touches money, the crash counter included.
- Put
Queue::route()in one provider per module and give payments its own queue. - Read the support table quarterly and put March 17, 2028 in the calendar while it is routine.
The whole list works without a banking license and costs a day or two on a codebase you run. The rent gets paid the first time a failed job tells you why it died.