Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Wallet Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. config/wallet.php — cache/lock drivers, TTLs, storage/rounding behavior.
  2. The published migrations — wallets, transactions, transfers. Read the transactions schema before writing any reporting queries; it is the ledger, and everything else is derived.
  3. https://bavix.github.io/laravel-wallet/guide/introduction/ — the README stops right where the real questions start (transfers, multi-wallet, exceptions).
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

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

  • 10.0.0 — 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.
  • 12.1.0 — no code changes required, but four redundant indexes are dropped. On a high-traffic table, run 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.
  • 12.0.5 — 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

  1. Balance looks wrong → query transactions for that wallet ordered by id; the ledger is truth.
  2. Balance differs between servers → cache/lock driver is node-local; fix env config.
  3. Duplicate credits → missing idempotency key in your service layer.
  4. Slow list page → missing with('wallet').
  5. Random 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
thomas-0816/doctrine-dbal-duckdb
moyisoft/laravel-admin
oxygen/mod-pages
oxygen/mod-import-export
oxygen/mod-media
ksoft/laravel-contentbuilderjs
ourgoldpackages/exchange
mikkimike/laravel-exchange1c
wandesnet/mercadopago-laravel
tsfcorp/email
larafony/core
laravel-enso/currencies
tartan/laravel-online-payment
gam/laravel-sat-catalogs
cloudcreativity/laravel-stripe
abdulelahragih/querybuilder
wedevbr/celcoin-laravel
surazdott/nepali-date
devsquad-cockpit/laravel
coding-socks/laravel-upload-handler