Building a Reliable Crypto Balance Manager using Laravel
Reliable balance management is a deceptively complex problem. At first glance, operations like “add funds” or “withdraw funds” appear trivial. In reality, they must operate correctly under concurrency, retries, partial failures, and strict financial invariants.
This article presents a production-style wallet system implemented in PHP (Laravel 11, PHP 8.3) and expands it into a broader set of engineering principles for building financially correct systems. The focus is not only on implementation, but on why certain patterns — such as immutable ledgers, idempotency, and state machines — are necessary.
The key idea underpinning the entire system is simple:
Balance is a projection. The ledger is truth.
The Real Problem: Concurrency and Consistency
Most wallet systems fail not because of cryptography, but because of race conditions.
Consider a wallet with 100 units and two concurrent withdrawals (80 and 50). Without proper synchronization, both may succeed, resulting in a negative balance. The system does not crash — it silently corrupts financial state.
A classic lost update scenario:
T1: Read 100 => Check ≥80 => Plan to write 20
T2: Read 100 => Check ≥50 => Plan to write 50Both succeed → final balance = -30.
Nothing crashed. The system behaved exactly as written.
Wallet systems are not CRUD applications.
They are financial consistency systems operating in unreliable distributed environments.
Key Principle: Balance is a Projection, Ledger is Truth
System Architecture
1. High-Level Architecture
WalletService is a readonly class orchestrating logic:
readonly class WalletService
{
public function __construct(
private WalletRepositoryInterface $walletRepository,
private PrecisionRepositoryInterface $precisionRepository,
private OperationRepositoryInterface $operationRepository,
) {}
}This design allows swapping implementations (e.g., for testing) and keeps business logic clean of database details.
2. ER Diagram
Core Models:
The Operation model uses soft deletes to never hard-delete financial records, preserving auditability.
3. Operation State Machine
States:
OS_INPROCESS— operation created, balance unchangedOS_COMPLETE— operation completed, balance updatedOS_FAILED— processing error
This supports asynchronous deposits (e.g., waiting for blockchain confirmations).
Deep Dive into Components
1. Service Layer: WalletService
public function addBalance(
string $amount,
string $walletUuid,
OpStateEnum $opState = OpStateEnum::OS_COMPLETE,
?Operation $operation = null
): ?Operation {
$amount = $this->validate(
amount: $amount,
walletUuid: $walletUuid,
);
return $this->walletRepository->credit(
amount: $amount,
walletUuid: $walletUuid,
opState: $opState,
operation : $operation
);
}Validation includes:
- UUID validity
- Wallet existence
- Precision support for currency
- Minimum amount threshold
After validation, it delegates to walletRepository->credit() or debit(). The actual database transaction (begin, update wallet, insert operation, commit) is hidden inside the repository.
2. Precision Handling: Why String + BCMath?
Example:
$balance = bcsub($balance, $amount, 8);
bccomp($operation->amount, $amount) === 0 // precise comparisonThis is essential for comparing decimal strings without floating-point errors.
3. Concurrency Control: Pessimistic Locking
Code with locking (\App\Repositories\WalletRepository::credit):
return DB::transaction(function () use ($amount, $walletUuid, $opState, $operation) {
[$wallet, $precisionDTO] = $this->getWalletAndPrecision(
walletUuid: $walletUuid,
lockForUpdate: true
);
if ($operation === null) {
Wallet::query()->whereKey($wallet->id)->increment('amount', $amount);
return $this->operationRepository->add(
new OperationDTO(
uuid: Str::uuid()->toString(),
amount: $amount,
precision: $precisionDTO,
walletId: $wallet->id,
opType: OpTypeEnum::OP_TYPE_CREDIT,
opState: $opState,
createdAt: now(),
)
);
}
return $this->changeOperationState($operation, $opState, $wallet, $precisionDTO);
}, 3);This serializes access. The second request waits and sees the updated state.
Correctness > Throughput in financial systems.
4. Idempotency
Without idempotency, retrying a request creates duplicate deposits.
$existing = Operation::where('idempotency_key', $key)->first();
if ($existing) {
return $existing; // return the same result
}
// create new operationRecommendation: require an Idempotency-Key header for API deposits and check against the operations.data JSON field.
Get bugfix666’s stories in your inbox
Join Medium for free to get updates from this writer.
Exactly-once execution is usually an illusion. Reliable systems implement at-least-once delivery + idempotent handling.
5. CLI Command: Problematic Pattern
public function handle(WalletService $walletService)
{
$amount = $this->argument('amount');
$walletUuid = $this->argument('wallet');
for ($i = 0; $i < 100; $i++) { // ⚠️ test artifact
$operation = $walletService->addBalance(
amount: $amount,
walletUuid: $walletUuid,
opState: OpStateEnum::OS_INPROCESS
);
BalanceProcessCallbackJob::dispatch(
new BalanceProcessCallbackDTO(
opState: OpStateEnum::OS_COMPLETE,
operationUuid: $operation->uuid
)
);
}
}Problems:
Recommendation: remove the loop; accept only one deposit per command invocation.
Testing Strategy
1. Functional Tests
$amount = '12.345678';
$operation = $walletService->addBalance($amount, $walletUuid);
$this->assertTrue(bccomp($operation->amount, $amount) === 0);
$wallet = $walletService->findByUuid($walletUuid);
$this->assertTrue(bccomp($wallet->amount, $amount) === 0);Tests include:
testWalletAddBalance— deposit, verify, withdraw, balance returns to zerotestWalletFails— withdraw from empty wallet → exception- API tests for
/api/v1/wallet/add-balance
2. What’s Missing
- Unit tests with mocked repositories
- Concurrency tests (2 parallel withdrawals)
- Edge precision value tests
Reconciliation: The Safety Net
Even the best systems drift. Reconciliation is not a bug — it’s operational maturity.
What to reconcile:
Any mismatch triggers alerts.
Monitoring Financial Metrics
Financial alerts are more important than server alerts.
Comparison of Approaches
The choice depends on needs: for BTC/ETH — string + BCMath; for USD fiat — integer cents is acceptable.
Production Readiness Checklist
Verdict: The system is prototype-ready. It passes functional tests and demonstrates core patterns. For production deployment with real funds, critical items must be addressed.
Architectural Strengths
Results
Functional Outcomes
All required features are implemented and verified by tests:
- The system correctly rejects withdrawals from empty wallets (
testWalletFailspasses) - Handles high-precision amounts (e.g.,
12.345678BTC) without floating-point rounding - API responses validate JSON structures for
/api/v1/users,/api/v1/operations,/api/v1/wallet/add-balance
Error Handling
When validation fails, the service throws specific exceptions:
InvalidUuidStringException— malformed UUIDWalletNotFoundException— UUID valid but wallet missingWalletCurrencyPrecisionNotSetException— blockchain/currency not configuredProcessingAmountIsInvalidException— amount below minimum allowed
Key Takeaways
- Ledger is truth, balance is projection. Always store an immutable operation log.
- Never use float for money. Only integer or string + BCMath.
- Lock rows on mutations.
lockForUpdate()prevents race conditions. - Always add idempotency.
Idempotency-Keyheaders are mandatory. - Model operations as a state machine. Critical for async settlement.
- Soft deletes on financial records. Auditability must never be lost.
- Implement reconciliation from day one. Systems drift — it’s a matter of time.
- Financial alerts > server alerts. Track drift, negative attempts, pending volume.
Comparison with Alternative Approaches
Many wallet systems store balance as an integer (cents or satoshis) to avoid decimal issues. This project uses strings and BCMath — more flexible but requires careful precision management.
The repository pattern is more sophisticated than typical Laravel “fat model” designs. The async job pattern is similar to Stripe’s “payment intent” model: create an object in in_process state, then confirm later.
Conclusion
This case study dissected a real-world Laravel balance management system using only its own source code as evidence. The architecture cleanly separates user identity (User), balance storage (Wallet), and transaction log (Operation). The service layer delegates to repositories, enabling precise decimal handling and asynchronous state transitions.
The CLI command, despite its loop quirk, correctly demonstrates creating in-process operations and dispatching completion jobs. Functional tests confirm deposits, withdrawals, error conditions, and API responses.
Reliable financial systems are not defined by elegant APIs. They are defined by the integrity of state transitions.
Money does not tolerate ambiguity. Reliable architecture systematically removes it through:
- Ledgers (immutable log)
- Locks (pessimistic locking)
- Transactions (atomicity)
- State machines (operation lifecycle)
- Idempotency (safe retries)
- Reconciliation (verification)
Individually, these mechanisms may feel heavy. Together, they produce something valuable:
Predictable financial behavior under imperfect conditions.
That is ultimately what users trust — not frameworks, not blockchains, but the guarantee that the system will preserve financial truth even when parts of it fail.
And that trust is not accidental.
It is engineered.
Built with lessons from production wallet systems that have survived real traffic, failures, and the harsh realities of financial correctness.










