bavix/laravel-wallet
bavix/laravel-wallet is a Laravel package for working with virtual wallets, supporting transactions, deposits, withdrawals, and multi-wallet setups with a documented API.
Install and scaffold
composer require bavix/laravel-wallet
php artisan vendor:publish --provider="Bavix\Wallet\WalletServiceProvider"
php artisan migrate
Check the Laravel/PHP support matrix before you start: 11.x covers Laravel 11–13 on PHP 8.3–8.5, while 12.x targets Laravel 13 only. Pin the major version in composer.json so a routine composer update doesn't jump a breaking migration.
Where to look first
config/wallet.php — cache/lock drivers, TTLs, storage/rounding behavior.wallets, transactions, transfers. Read the transactions schema before writing any reporting queries; it is the ledger, and everything else is derived.https://bavix.github.io/laravel-wallet/guide/introduction/ — the README stops right where the real questions start (transfers, multi-wallet, exceptions).Bavix\Wallet\Interfaces\* — the interfaces tell you exactly which methods each trait gives you, faster than the docs.First use case: pick the trait, then smoke-test
Start with a single integer wallet, not floats. It's the least surprising default and matches how most prices/credits are stored.
use Bavix\Wallet\Interfaces\Wallet;
use Bavix\Wallet\Traits\HasWallet;
class User extends Model implements Wallet
{
use HasWallet;
}
$user = User::first();
$user->balanceInt; // 0 (first read may create the wallet row)
$user->deposit(10); // +10
$user->withdraw(1); // -1
$user->balanceInt; // 9
$user->forceWithdraw(200, ['description' => 'payment of taxes']); // -191
If that works, you understand 80% of the package. Everything else is composition: products (CanPay/Customer), floats (HasWalletFloat), multi-wallet slugs, transfers.
Choose the trait set deliberately — it's hard to change later
| Need | Trait + Interface |
|---|---|
| Simple balance on a model | HasWallet + Wallet |
| Money with decimals | HasWalletFloat + Wallet, WalletFloat |
| Model that spends money | CanPay + Customer |
| Purchasable thing | HasWallet + ProductInterface / ProductLimitedInterface |
HasWalletFloat adds depositFloat/withdrawFloat and balanceFloat on top of the same integer ledger — the float is a view over the stored amount. Don't mix float and int calls in the same domain flow without deciding which one owns rounding.
Wrap wallet mutations in your own domain service
Never let controllers call deposit()/withdraw() directly. You want one place for idempotency keys, audit metadata, and transaction boundaries:
final class CreditAccount
{
public function __construct(private Wallet $wallet) {}
public function handle(int $amount, string $reason, array $meta = []): Transaction
{
return DB::transaction(function () use ($amount, $reason, $meta) {
return $this->wallet->deposit($amount, $meta + [
'description' => $reason,
'idempotency_key' => $meta['idempotency_key'] ?? Str::uuid(),
]);
});
}
}
The meta array is persisted on the transaction — use it for order IDs, external payment references, and admin reasons. It's your only cheap join between the ledger and your business tables.
Idempotency for webhooks and retries
Payment webhooks retry. Either store your own idempotency_key in meta and check for an existing transaction before depositing, or use the package's external API UUIDs (added in 10.x, present in transactions/transfers). Do not rely on "the user clicked once" — queue workers and gateway retries will double-credit.
Purchases: pay vs safePay vs basket
$user->pay($item); // throws on insufficient funds
if ($user->safePay($item)) { // boolean, no exception on failure
// ...
}
$user->refund($item); // reverses; returns bool
For carts, don't loop safePay over N products with ProductLimitedInterface — that's N queries and a half-charged basket. Follow the README's advice and override PurchaseServiceInterface so availability is checked in one request and debited atomically. Use PurchaseQueryHandlerInterface::one(PurchaseQuery::create($user, $item)) when you need to ask whether a purchase already happened (entitlement checks, "buy once" gates) without doing a write.
Transfers between wallets
For user-to-user or wallet-to-wallet movement, use the transfer API rather than withdraw + deposit in sequence — the latter is two ledger entries with no atomic guarantee and no transfers row linking them. Transfers are the right primitive for escrow, payouts, and marketplace splits.
Eager loading is not optional
User::with('wallet')->paginate(); // single wallet
User::with('wallets')->paginate(); // multi-wallet
Any listing page that renders a balance will otherwise fire one query per row. This is the single most common performance complaint with this package.
Reporting
Read transactions directly for statements — join on payable_type/payable_id (morph) plus confirmed. Don't reconstruct history by replaying deposit/withdraw calls in PHP. The ledger table is append-oriented and indexed for exactly this; the 12.1.0 release trimmed duplicate indexes so writes are cheaper while reads keep the same plans.
Testing
RefreshDatabase + a factory state that seeds a wallet via deposit() keeps tests honest. Assert on balanceInt, not balance, and add at least one test that asserts an insufficient-funds path throws — that's where regressions hide when you refactor your service layer.
balance vs balanceInt
balance is a Brick\Math\BigNumber-style object; balanceInt is the plain integer. Comparing balance with == against a number works often enough to be a trap, but arithmetic and JSON serialization don't. Be explicit everywhere: balanceInt for storage/logic, balanceFloat for display.
Reading a balance used to create wallets — verify your version
Before 10.0.0, merely reading $user->balance could materialize a wallet row, which broke Nova/admin screens (phantom wallets for every user you viewed). That was fixed; if you're on an older major, don't build admin listings that touch balance on unloaded models.
Negative balances are a feature, not a bug
forceWithdraw deliberately bypasses the funds check and can drive a wallet negative. That's correct for taxes/fees/chargebacks — but it means a bug in your fee logic silently goes negative instead of throwing. Gate forceWithdraw behind an admin-only service and log it loudly.
Locking and cache are configuration, and they matter in production
WALLET_CACHE_DRIVER, WALLET_CACHE_TTL, WALLET_LOCK_DRIVER, WALLET_LOCK_TTL (env vars, added in 10.x) control the balance cache and the mutation lock. Defaults are fine for a single box. On multi-server or with queue workers, set the cache and lock drivers to something shared (Redis) — an array/file cache per node means locks that don't lock and balances that disagree. Never use array in production.
Concurrency: wrap composite operations
Each deposit/withdraw is internal-consistent, but check balance → decide → withdraw across two calls is not. Put the read and the write in one DB::transaction and let the package's lock do the rest. This is the #1 source of "user spent the same money twice" bugs.
Exception names matter
Failures are namespaced under Bavix\Wallet\Exceptions\* (InsufficientFunds, BalanceIsEmpty, AmountInvalid, WalletNotFound, WalletAlreadyExists, ProductEnded). Catch the specific class in your domain layer and convert it to your own result type — don't let InsufficientFunds escape to a controller and render a 500.
Version-specific upgrades are not all no-ops
to_type/from_type on the transfer table deprecated, transfer relations switched MorphMany → HasMany, EagerLoaderServiceInterface contract changed, BookkeeperServiceInterface::missing() became forget(), LockProviderNotFoundException removed. Code touching those will break.php artisan migrate in a maintenance window or drop them manually (pt-online-schema-change / DROP INDEX CONCURRENTLY) and insert the migration names into the migrations table so they don't re-run.createWallet now accepts zero decimal places; if you were working around that, remove the workaround.Always run the upgrade guide (https://bavix.github.io/laravel-wallet/#/upgrade-guide) rather than trusting semver alone; 9.x → 10.x → 11.x/12.x each carry behavioral changes.
Extension points before forks
Three first-party extensions cover most "I need to modify the package" impulses: Swap (exchange rates between wallets), uuid (UUID primary keys on holder models), Warm Up (recompute/refresh cached balances after bulk imports). Bulk-importing transactions directly into the DB then needing consistent balances is exactly the Warm Up use case — don't hand-write the reconciliation.
Bulk operations
If you insert transactions manually (data migration, backfill), you bypass the package's balance cache. Either go through deposit() in chunks, or run the Warm Up extension afterwards. A silently stale cached balance is very hard to debug from the UI side.
Debugging checklist
transactions for that wallet ordered by id; the ledger is truth.with('wallet').WalletNotFound in jobs → wallet was created lazily by a web request; call createWallet explicitly in the seeding step.Community
The Telegram channel @laravel_wallet is linked from the README and is where the maintainer actually answers design questions — worth joining before you commit to a multi-wallet schema you can't migrate out of.
How can I help you explore Laravel packages today?