021/laravel-wallet
A Laravel package for adding wallet functionality to your app: manage multiple balances, credit and debit funds, record transactions, handle deposits, withdrawals and transfers, and track wallet history with an Eloquent-friendly API.
composer require 021/laravel-wallet. Laravel auto-discovers the service provider (O21\LaravelWallet\LaravelWalletServiceProvider).php artisan vendor:publish will list them), then run php artisan migrate. You get wallets and transactions tables by default; table names are configurable.HasWallet trait to User (add CanPay too if you want the payment façade). Both traits are in O21\LaravelWallet\Traits.slug:$user->createWallet(['name' => 'Main', 'slug' => 'main']);
$wallet = $user->getWallet('main');
deposit, withdraw, transfer, pay all return a Transaction model:$wallet->deposit('150.00', ['reference' => 'stripe_123']);
$wallet->withdraw('20.00');
$wallet->balance; // string decimal
$wallet->transactions; // relation, newest last by default
Where to look first: the published wallet.php config (defaults for balance, confirmation, decimals, table names), then Wallet / Transaction models, then the HasWallet trait to see exactly which methods are on your user model.
First real use case: a user tops up (deposit) onto a main wallet, spends via withdraw/pay, and you render a paginated ledger from the transactions relation. That loop exercises 90% of the package.
Multiple named wallets per model. Prefer slugs as a domain vocabulary (main, bonus, refund, cashback) over a single balance. Always null-check getWallet() — it returns null for unknown slugs:
$wallet = $user->getWallet('bonus') ?? $user->createWallet(['slug' => 'bonus']);
Ledger rendering. The transactions relation is query-builder-compatible, so paginate and filter by type directly instead of loading everything:
$wallet->transactions()->where('type', 'credit')->latest()->paginate(25);
Transfers between holders. Use the wallet-to-wallet transfer so both sides get a transaction row; don't compose withdraw + deposit yourself unless you need different amounts on each leg:
DB::transaction(function () use ($sender, $receiver, $amount) {
$sender->getWallet('main')->transfer($receiver->getWallet('main'), $amount);
});
Payments via CanPay. $user->pay($wallet, $amount, $meta) is the idiomatic checkout call; keep payment-specific meta (order id, gateway ref) on the transaction rather than a parallel table.
Attach meta for reconciliation. deposit/withdraw/transfer accept a meta array as the trailing argument. Store external references there and use them as your idempotency key: look up an existing transaction by reference before creating a new one, so retried webhooks don't double-credit.
Wrap multi-step flows in DB::transaction. Individual operations are atomic on the wallet row, but "debit A, credit B, write an order" is not — compose it yourself.
Audit via events/observers. Rather than polling the ledger, register listeners for the package's transaction events (or an observer on Transaction) to push to your audit log, notify users, or trigger downstream jobs.
Extend by config, not by editing vendor code. The config lets you swap in your own Wallet / Transaction models — do that when you need extra columns, relations, or casts. Custom transaction handlers are the extension point for new domain operation types (refund, cashback, reversal): define the handler, register it in config, and call it like any other operation.
Reversals over deletes. For refunds and corrections, create a compensating transaction (crediting the opposite wallet) instead of deleting history; the ledger stays reconstructable.
bccomp/bcadd or the package's decimal abstraction; $wallet->balance === '0.00' string comparisons will bite you.UPDATE wallets SET balance = .... Balance is a maintained, denormalized column; mutate only through the wallet API or you'll desync it from transactions.DB::transaction and lockForUpdate(). Load-test your hot path (e.g. a shared system wallet) — it's the classic failure mode.wallet.php during staging rather than discovering it in production.getMorphClass() stable or existing wallets become unreachable — a subtle data-migration trap.balance reads both respect status, otherwise dashboards drift.meta is JSON; filtering by a meta key works on MySQL/Postgres but is limited on SQLite. If you need heavy meta queries, promote those fields to real columns on your extended Transaction model.transactions for type, amount, status, meta, plus the wallet's balance column. A mismatch almost always means a raw update or a failed multi-step flow that wasn't in a transaction.How can I help you explore Laravel packages today?