yemeni-open-source/laravel-wallet
A Laravel package that adds wallet functionality to your application, offering a simple API for managing balances, deposits, withdrawals, and transactions so you can integrate digital wallets into PHP projects with minimal setup.
This release (v1.2.0) is an early-lifecycle change to a wallet/transaction package, so treat the public API as still settling. The single notable feature is a switch that lets insufficient-wallet operations return false instead of throwing UnacceptedTransactionException.
Minimal setup:
composer require the package, then publish assets:
php artisan vendor:publish --provider="...\WalletServiceProvider"
class User extends Authenticatable
{
use HasWallet;
}
$user->wallet; // lazy-created
$user->deposit(100_00); // amounts in minor units
$user->balanceInt; // int
$user->balance; // formatted string/decimal
Where to look first: config/wallet.php (published) is where the new v1.2.0 behaviour is toggled and where currency/decimal settings live. After that, read the HasWallet trait and the Exceptions/ directory — the exception classes tell you more about the package's failure modes than the docs usually do.
First use case: credit a user's wallet on payment webhook, then debit it when they spend. Wire the debit through withdraw() and decide now whether insufficient funds is an exception or a boolean — because v1.2.0 lets you pick, and mixing the two across a codebase is the main source of bugs in this release.
Pick one failure contract per layer and stick to it. The new feature means withdraw()/forceWithdraw() can either throw UnacceptedTransactionException or return false. Choose per-context:
// Pattern A — exceptional (default, pre-1.2.0 behaviour)
try {
$user->withdraw($amount);
} catch (UnacceptedTransactionException $e) {
return back()->withErrors('Insufficient balance.');
}
// Pattern B — boolean (v1.2.0, opt-in via config)
if (! $user->withdraw($amount)) {
return back()->withErrors('Insufficient balance.');
}
Use the boolean style in controllers and form requests (it reads like validation), and keep the exception style in domain services and jobs, where a rejected transaction should abort the whole unit of work and be retried/dead-lettered. Boolean returns inside a queued job make partial failures invisible.
Wrap money movement in a DB transaction with a locked wallet. Optimistic balance checks are not safe under concurrency:
DB::transaction(function () use ($user, $amount) {
$wallet = $user->wallet()->lockForUpdate()->first();
if (! $wallet->withdraw($amount)) {
throw new \RuntimeException('Insufficient funds'); // roll back
}
});
Note the deliberate re-throw: when you switch the package to return false, you lose the automatic rollback that a thrown exception gave you. Re-introduce it yourself, or the surrounding transaction will commit with no debit applied.
Use intent-check before mutation for UX, not for correctness. canWithdraw()/balance reads are fine for disabling a button, but always re-check inside the locked transaction — a user with two tabs will race you.
Idempotency for webhooks. Payment webhooks retry. Pass a deterministic meta key per transaction (a provider event ID or client-generated UUID) and guard at your own layer with firstOrCreate on a payments table, keyed on that ID, before calling deposit(). Do not rely on the wallet package to deduplicate a webhook replay for you.
Transfers need both sides locked, and deadlock ordering matters. Lock wallets by ascending ID when moving money between two users:
$ids = collect([$from->wallet->id, $to->wallet->id])->sort()->values();
Testing pattern. Use RefreshDatabase, seed a wallet with deposit() rather than factory-forcing the balance column, and write one test per failure contract. If you flip the config in phpunit.xml via WALLET_THROW_ON_INSUFFICIENT=false, add an explicit test that the exception is not thrown, otherwise the suite silently drifts from production.
catch (UnacceptedTransactionException) blocks become dead code and existing tests that expectException will fail. Grep for the exception class before flipping it.false erases the reason. UnacceptedTransactionException can carry context about why a transaction was unaccepted; a bare boolean cannot. If you need to distinguish "insufficient funds" from "wallet frozen" or "amount invalid", keep the exception path for those operations and use the boolean only on the paths you control.: Transaction around a withdraw() call will now be wrong under static analysis (PHPStan/Psalm will flag Transaction|false). Update signatures deliberately instead of adding @phpstan-ignore.DB::transaction; a false return does not. Any side effect you perform after a rejected withdraw() will commit unless you check the return value and bail.balance (the formatted accessor). Use balanceInt / minor units and format only at the presentation boundary. Watch for casts in your own code turning an int balance into a string.decimal_places or currency after wallets have balances, existing rows are not migrated. Decide this before the first production deposit.composer.json and read the changelog before 1.3.x — a package this young is likely to move the config key or the return contract again.How can I help you explore Laravel packages today?