alifaraun/wallet

Multi-currency, limit-aware wallet/ledger package for Laravel.

Maintainers

Package info

github.com/alifaraun/wallet

pkg:composer/alifaraun/wallet

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0-beta 2026-06-24 17:40 UTC

This package is auto-updated.

Last update: 2026-06-24 17:52:10 UTC


README

Latest Version on Packagist Tests Total Downloads License

A multi-currency, limit-aware wallet & ledger package for Laravel — built for correctness under concurrency and for high-traffic scenarios like voucher/reseller balances.

It stores money as integer minor units, guards every balance change with a pessimistic row lock inside a transaction, supports per-(holder, currency) wallets, direction-aware transaction limits plus daily maximums, per-wallet overdraft, and emits events instead of forcing its own notifications.

Table of contents

Why this package

Concern How it's handled
Float rounding on money Stored as signed BIGINT minor units; exposed to you as ordinary numbers via a cast. Input conversion still rounds at the money boundary, but stored balances and ledger math stay integer-only.
Concurrent balance writes SELECT … FOR UPDATE inside DB::transaction; both credits and debits are serialized per wallet.
Multiple currencies One wallet row per (holder, currency); the holder is polymorphic (any model).
Spend/receive limits DB-backed override hierarchy for debits; flat per-currency config for credits; per-transaction limits plus daily maximums.
Overdraft / credit lines Per-wallet min_balance floor (default 0).
Daily-limit cost at scale O(1) running counter table (pluggable; a SUM() fallback ships too).
App coupling Holder is any model; the causer is an explicit parameter (never reads auth()); notifications are your event listeners.

Requirements

  • PHP 8.1+ (Laravel 10 supports 8.1–8.3)
  • Laravel 10
  • A production database with row-level locking: MySQL 8+, MariaDB 10.5+, or PostgreSQL 12+
    • SQLite is fine for tests (it serializes writers globally).
  • Holder / causer / operation key type: numeric (auto-incrementing) by default. If those models use UUID or ULID keys, set WALLET_MORPHS=uuid (or ulid) before running the migrations — see Configuration.

Installation

composer require alifaraun/wallet

Core wallet tables auto-load from the package, so the fastest install path is:

php artisan migrate

If you prefer app-owned migration stubs, publish and run the core migrations:

php artisan vendor:publish --tag=wallet-migrations
php artisan migrate

Only if you switch to EloquentCurrencyRepository should you publish the optional currencies migration:

php artisan vendor:publish --tag=wallet-currency-migrations
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=wallet-config

The service provider and the Wallet facade are auto-discovered.

Required: set a default currency before moving money. The package ships no fallback, so add WALLET_DEFAULT_CURRENCY=USD to your .env (or set default_currency in the published config/wallet.php) to a code present in your currencies list. Any call that omits a currency while this is unset throws MissingDefaultCurrencyException.

Quick start

1. Make a model a wallet holder

use Alifaraun\Wallet\Concerns\HasWallets;
use Alifaraun\Wallet\Contracts\WalletHolder;
use Illuminate\Database\Eloquent\Model;

class Reseller extends Model implements WalletHolder
{
    use HasWallets;
}

2. Create a wallet and move money

use Alifaraun\Wallet\Facades\Wallet;

$wallet = $reseller->walletOrCreate(currency: 'USD', name: 'Main balance');

Wallet::credit($wallet, 100.00, type: 'topup', causer: auth()->user());
Wallet::debit($wallet, 19.99, type: 'voucher_sale', causer: auth()->user());

$wallet->refresh();
$wallet->balance; // 80.01

Amounts are ordinary major-unit numbers (19.99). type is any string (or a BackedEnum). causer is explicit and may be null — the package never reads auth() for you.

3. Fetch a holder's wallet later

$reseller->wallet('USD');         // ?Wallet — null if it doesn't exist
$reseller->walletOrFail('USD');   // Wallet — throws WalletNotFoundException if missing
$reseller->walletOrCreate('USD'); // Wallet — fetches or creates (idempotent)

$reseller->walletOrCreate();      // currency omitted -> config('wallet.default_currency')

walletOrCreate() / createWallet() are idempotent fetch-or-create: if a wallet for that (holder, currency) already exists it's returned as-is. The name, minBalance, extra, ref, lowBalanceThreshold and lowBalanceMode arguments apply only when a new wallet is created — they never update an existing one. name, min_balance, low_balance_threshold, low_balance_mode, is_active and extra can be changed afterwards directly on the wallet ($wallet->update([...])); ref, currency and balance are the wallet's immutable identity/ledger fields and cannot be mass-assigned.

Currency codes are case-insensitive here too ('usd' resolves the same wallet), and the currency is optional everywhere it's accepted — leave it out to use config('wallet.default_currency'). That config key has no default and must be set (via WALLET_DEFAULT_CURRENCY or directly in config/wallet.php); omitting a currency while it is unset throws MissingDefaultCurrencyException.

Core concepts

  • Holder — any Eloquent model that use HasWallets and implements WalletHolder. Stored polymorphically, so Client, User, Reseller, … all work.
  • Wallet — a balance for one (holder, currency) pair. A holder may have many wallets, one per currency. Carries an optional extra JSON bag for app-specific data (e.g. a provider account ID).
  • Transaction — an immutable ledger row for every movement: amount (positive magnitude), fee, signed net_amount, balance_before/balance_after, type, debit flag, optional polymorphic operation and causer, and an extra JSON bag.
  • Causer — who/what initiated the movement (a user, a job, nothing). Explicit and nullable.
  • Operation — the domain object a movement relates to (an order, an invoice, the transaction being reversed). Polymorphic and nullable.

Operations

All operations are available on the Wallet facade or by injecting Alifaraun\Wallet\WalletManager.

credit — add funds

Wallet::credit(
    wallet: $wallet,
    amount: 100.00,
    type: 'topup',                 // string|BackedEnum
    operation: $invoice,           // optional ?Model
    causer: auth()->user(),        // optional ?Model
    fee: 2.00,                     // deducted from the credited amount
    description: 'Bank deposit',
    reference: 'dep_8842',         // optional unique key (per wallet)
    extra: ['gateway' => 'meeg'],
);

debit — remove funds

Wallet::debit(
    wallet: $wallet,
    amount: 19.99,
    type: 'voucher_sale',
    causer: auth()->user(),
    fee: 0.50,                     // added to the amount leaving the wallet
);

transfer — move funds between two wallets (same currency)

[$debit, $credit] = Wallet::transfer(
    from: $resellerA->walletOrFail('USD'),
    to: $resellerB->walletOrFail('USD'),
    amount: 50.00,
    causer: auth()->user(),
    fee: 1.00,                     // optional; charged to the sender (see below)
    reference: 'xfer_8842',        // optional idempotency key (see below)
);

Both legs run in one transaction with both rows locked in a deadlock-safe order; if either leg fails, the whole transfer rolls back. Each leg is limit-checked — the sender's debit limits and the receiver's credit limits both apply. Different currencies throw CurrencyMismatchException, and transferring to the same wallet throws InvalidOperationException.

An optional fee is charged to the sender on top of the amount: the sender's wallet loses amount + fee while the receiver gets the full amount. The fee leaves the system (it is not credited anywhere) — use it to model a processor/service cut, or pass fee: 0 (the default) for a 1:1 move.

Pass an optional reference to make the transfer idempotent. It keys the sender's debit leg (and the receiver's credit leg), so a replayed transfer with the same reference hits the per-wallet uniqueness guard on the sender and rolls the whole transfer back with DuplicateReferenceException instead of moving money twice. Both legs share the reference, so it also serves as a lookup key for the pair.

refund — give money back for something that happened

Wallet::refund(
    wallet: $wallet,
    amount: 19.99,
    operation: $order,             // required: what is being refunded
    causer: auth()->user(),
    description: 'Order cancelled',
);

A refund is a credit tagged refund; it is limit-checked like any credit. It can be partial and does not erase the original transaction.

reverse — undo a transaction exactly

Wallet::reverse(
    transaction: $failedTransaction,
    causer: auth()->user(),
    description: 'Provider rejected',
);

A reversal applies the exact opposite of the original's net amount and links back to it (so the original drops out of the notReversed() scope). Because it's a correction, it bypasses limits, the overdraft floor, and the active-wallet check — undoing something must always succeed, so you can still reverse a movement after a wallet has been deactivated (is_active = false). A transaction can be reversed at most once: it uses a deterministic per-wallet reference (rev_<id>), so reversing it again — regardless of the causer — throws DuplicateReferenceException instead of crediting back twice, and a retry job and a manual admin action can't double-undo it.

reverse() refuses transfer legs. A transfer is two linked rows — a transfer_out on the sender and a transfer_in on the receiver — committed atomically by transfer() (both or neither). reverse() only ever touches one wallet, so reversing a single leg would unbalance the pair; it throws InvalidOperationException instead. To undo a whole transfer, move the funds back with a compensating transfer() in the opposite direction — you control the reference, description, and causer on that correction.

Reversing a credit whose funds were already spent can drive the balance below its floor — possibly negative. Because reverse() bypasses the min_balance floor by design, undoing a credit after that money already left the wallet (e.g. it was debited elsewhere) leaves the balance below min_balance. That is intentional — the funds are genuinely owed back — but it means a reversal can park a wallet under its floor without an InsufficientBalanceException. Reconcile such wallets deliberately rather than assuming a balance can never sit below its floor.

A reversal is a correction, not volume, so it does not count toward daily usage in either direction — it neither adds to the opposite direction's total nor decrements the original's. (Both trackers agree: CounterTableUsage records nothing for a reversal, and LedgerSumUsage excludes reversal rows.) This mirrors the fact that reverse() bypasses the limits themselves.

Money & currencies

Money is stored as integer minor units (cents, etc.) but you always work in major units:

Wallet::credit($wallet, 12.50);     // you pass 12.50
$txn->amount;                       // you read 12.50
$txn->getRawOriginal('amount');     // stored as 1250

You can pass amounts as int, float, or a numeric string — handy when the value arrives as text (a request field, a CSV cell). A string is validated like any amount (a non-numeric string throws InvalidAmountException, not a raw TypeError) and then converted to minor units the same way:

Wallet::credit($wallet, '12.50');

All three input types are rounded to the nearest minor unit. Stored balances, counters, and ledger math stay on integers; only the conversion boundary touches float space. To keep that boundary exact, every minor-unit value — each amount, fee, and the resulting balance — must stay within ±2⁵³ (Money::MAX_SAFE_MINOR, ~9×10¹⁵ minor units); anything beyond is rejected with InvalidAmountException (getReason() names whether it was the amount or the resulting balance) rather than stored imprecisely. For the same reason a currency may declare at most 15 decimals (Money::MAX_DECIMALS): an 18-decimal "wei"-style precision can't be represented exactly by this design and is refused the moment that currency is read. An amount that is positive but smaller than one minor unit (so it would round to 0, e.g. 0.004 USD) is also rejected rather than silently recorded as a no-op movement.

Currency precision comes from the configured currency source. The shipped default config('wallet.currencies') includes a small starter set:

Code Decimals Symbol
USD 2 $
EUR 2
GBP 2 £
LYD 3 ل.د
USDT 6

Codes are normalized to uppercase, so 'usd' and 'USD' resolve to the same wallet. Add/remove entries in the config array (any ISO 4217 code, with its decimal count), or swap the whole source (see Extending). There is intentionally no currency enum — the supported set is data you control, not code.

Limits

Limits are direction-aware and expressed as positive magnitudes. They are checked against the movement's net balance impact, which includes the fee: a debit of amount + fee is what the debit limits see, and a credit of amount - fee is what the credit limits see. So a fee can push a debit over its max bound, or pull a credit under its min bound.

Debit limits — DB-backed, with an override hierarchy

Stored in the wallet_limits table. Resolution is most-specific-first:

  1. a rule for the specific wallet, then
  2. a holder-type default (holder_type set, wallet_id null), then
  3. a global default (both null), then
  4. the config('wallet.limits.debit') fallback.

The first tier with any matching rule wins outright — tiers never blend.

use Alifaraun\Wallet\Models\WalletLimit;

WalletLimit::create([
    'holder_type' => Reseller::class, // omit wallet_id => applies to every Reseller wallet
    'currency' => 'USD',
    'min_amount' => 1.00,             // smallest single debit
    'max_amount' => 3000.00,          // largest single debit
    'daily_max_amount' => 50000.00,   // largest total debits per calendar day
]);

List currency before the *_amount fields (as above). The amount columns are money-cast and read the row's currency to know the decimal precision, so a limit row whose amounts are assigned before its currency throws MissingCurrencyContextException.

Any bound left null (or with no matching rule at all) means unlimited — never a hard 0.

Each tier allows at most one rule per currency — active or not. Creating a second row for the same (wallet_id, holder_type, currency) scope throws DuplicateWalletLimitException, regardless of either row's is_active state. To change a rule, update the existing row (and toggle is_active on it) rather than creating another one alongside it.

Credit limits — config only

Credits are limited purely by config('wallet.limits.credit'), keyed by currency with a default fallback. There is no per-holder/per-wallet credit override.

'limits' => [
    'credit' => [
        'USD' => ['min' => null, 'max' => 5000.00, 'daily_max' => 20000.00],
        'default' => ['min' => null, 'max' => null, 'daily_max' => null],
    ],
],

A per-currency block is selected whole — it does not merge field-by-field with default. List every bound a currency entry cares about; any field it omits is treated as null (unlimited), not inherited from default.

Daily window

"Daily" means the calendar day in config('wallet.daily_window_timezone') (defaults to your app timezone), so limits reset at your midnight rather than the database server's.

Overdraft

Each wallet has a min_balance floor (default 0). A debit may take the balance down to — but not below — min_balance. Set it negative to allow an overdraft / credit line:

$line = $reseller->walletOrCreate(currency: 'USD', name: 'Credit line', minBalance: -500.00);

Wallet::debit($line, 400.00); // ok, balance -400.00
Wallet::debit($line, 200.00); // throws InsufficientBalanceException (would be -600.00)

Low balance warning

Each wallet may set a low_balance_threshold (major-unit amount; null — the default — disables the feature entirely) and a low_balance_mode:

  • once (the default) — fires only when balance crosses from above the threshold to at-or-below it. Re-arms automatically once balance recovers back above the threshold.
  • always — fires on every balance-decreasing movement while balance stays at or below the threshold, crossing or not.
$wallet = $reseller->walletOrCreate(currency: 'USD', lowBalanceThreshold: 50.00, lowBalanceMode: 'once');

// or post-creation:
$wallet->update(['low_balance_threshold' => 50.00, 'low_balance_mode' => 'always']);

A wallet with no low_balance_mode of its own falls back to config('wallet.low_balance.default_mode') (default 'once').

Only balance-decreasing movements can fire it — debit, the transfer_out leg, and a reverse() that undoes a credit. A credit, refund, the transfer_in leg, and a reverse() that undoes a debit never fire it, even if the resulting balance is still at or below the threshold: those movements only ever move balance up. There is no extra "already warned" state to track — both modes are derived purely from the movement's own balance_before/balance_after (already recorded on every transaction) compared against the threshold, so concurrent movements on the same wallet are serialized by the same row lock that protects every other rule in the pipeline, and LowBalanceWarning is dispatched after commit like TransactionRecorded — see Events.

use Alifaraun\Wallet\Events\LowBalanceWarning;

Event::listen(LowBalanceWarning::class, function (LowBalanceWarning $event) {
    $event->wallet->holder->notify(new WalletRunningLow($event->wallet, $event->balanceAfter, $event->threshold));
});

Reference uniqueness

Pass a reference to give credit/debit/refund/transfer a stable, deduplicated key. A reference is unique per wallet: reusing one on the same wallet is rejected with a DuplicateReferenceException instead of moving money twice (for transfer, the reference keys both legs, so a replay is rejected at the sender and the whole transfer rolls back):

Wallet::credit($wallet, 50.00, reference: 'pay_123');
Wallet::credit($wallet, 50.00, reference: 'pay_123'); // throws DuplicateReferenceException

The check is independent of the causer, so anyone reusing the reference (a retry job, then an admin) is rejected the same way. The same reference used on a different wallet moves money there independently. It's enforced both by an in-transaction pre-check (an existence-only query inside the wallet lock) and a unique database index (wallet_id, reference) as the race backstop. Catch DuplicateReferenceException to make a retry safe — its getReference() / getWallet() tell you what collided.

Every transaction stores a non-null reference: when you don't pass one, the package mints a globally unique ULID (e.g. txn_01j…), so an auto-generated reference is safe to use as a public identifier on its own. A caller-supplied reference is only unique per wallet — the same value (pay_123) can legitimately exist on other wallets — so look those up by (wallet_id, reference), not by reference alone (a bare lookup can match rows on several wallets and isn't separately indexed). Movements without a caller reference each get a distinct auto-reference, so they never collide.

Exceptions

All extend Alifaraun\Wallet\Exceptions\WalletException (which has a context(): array) and carry typed getters returning major-unit values.

Exception Thrown when Key getters
InsufficientBalanceException debit would breach the balance floor getWallet(), getAttemptedAmount(), getAvailableBalance(), getMinBalance()
TransactionLimitExceededException per-transaction limit breached getBreachedBound(), getLimit(), getAttemptedAmount()
DailyLimitExceededException daily window limit breached getBreachedBound(), getLimit(), getDailyTotal()
CurrencyMismatchException cross-currency transfer getFromCurrency(), getToCurrency()
WalletNotActiveException operating on an inactive wallet getWallet()
InvalidAmountException non-positive / non-finite / non-numeric amount, an amount (or resulting balance) beyond the safe integer range, or one that rounds to zero at the currency's precision getAttemptedAmount(), getReason()
InvalidAttributeException a string input (reference, type, description, name, ref, currency) exceeds its column length, or an extra bag exceeds its size cap getAttribute(), getMaxLength(), getValue()
InvalidOperationException an operation is semantically invalid (a same-wallet transfer, or reverse() on a transfer_out/transfer_in leg) context()
UnknownCurrencyException currency not known to the source getCurrencyCode()
DuplicateReferenceException a movement reuses a reference already present on that wallet getReference(), getWallet()
MissingDefaultCurrencyException a currency was omitted but config('wallet.default_currency') is not set
MissingCurrencyContextException a money-cast attribute was read or written without the model's currency loaded context()
WalletNotFoundException walletOrFail() finds no wallet getHolder(), getCurrency()
use Alifaraun\Wallet\Exceptions\InsufficientBalanceException;
use Alifaraun\Wallet\Exceptions\TransactionLimitExceededException;
use Alifaraun\Wallet\Exceptions\DailyLimitExceededException;

try {
    Wallet::debit($wallet, 5000.00);
} catch (InsufficientBalanceException $e) {
    report("Need {$e->getAttemptedAmount()}, have {$e->getAvailableBalance()}");
} catch (TransactionLimitExceededException | DailyLimitExceededException $e) {
    report("Limit hit on the {$e->getBreachedBound()} bound");
}

For a credit limit breach, getLimit() is null (credit limits come from config, not a DB row).

These exceptions carry sensitive financial detail — their messages and context() include wallet refs/ids, attempted amounts, available balances, and floors. Log them server-side, but don't render the raw message or context() to end users (especially across tenants). Catch the typed exception and surface your own sanitized message.

Events

The package sends no notifications; it fires events you listen to. WalletCreated, TransactionRecorded and LowBalanceWarning are dispatched after commit, so listeners never act on a rolled-back movement.

Event When Payload
WalletCreated a wallet is created $wallet
TransactionRecorded a movement commits $transaction, $wallet
LowBalanceWarning a balance-decreasing movement commits leaving balance at/below low_balance_threshold $wallet, $transaction, $threshold, $balanceBefore, $balanceAfter, $mode
LimitBreached just before a limit exception throws $wallet, $exception
use Alifaraun\Wallet\Events\TransactionRecorded;

Event::listen(TransactionRecorded::class, function (TransactionRecorded $event) {
    $event->wallet->holder->notify(
        $event->transaction->debit
            ? new WalletDebited($event->transaction)
            : new WalletCredited($event->transaction)
    );
});

For heavy traffic, make such listeners ShouldQueue so the locked transaction stays short.

Configuration

config/wallet.php (publish with the wallet-config tag):

return [
    // Retry the whole transaction on deadlock / lock-wait. Defaults to 3 (safe:
    // the movement fully rolls back before a retry); raise under heavy contention.
    'transaction_attempts' => (int) env('WALLET_TRANSACTION_ATTEMPTS', 3),

    // Swap any model for your own subclass.
    'models' => [
        'wallet' => \Alifaraun\Wallet\Models\Wallet::class,
        'transaction' => \Alifaraun\Wallet\Models\WalletTransaction::class,
        'limit' => \Alifaraun\Wallet\Models\WalletLimit::class,
        'daily_usage' => \Alifaraun\Wallet\Models\WalletDailyUsage::class,
    ],

    // Polymorphic key type for holder/operation/causer columns. 'numeric'
    // (default), 'uuid', or 'ulid'. Read by the migrations, so set it before
    // migrating; all three relations share the type.
    'morphs' => env('WALLET_MORPHS', 'numeric'),

    // Where currency metadata comes from.
    'currency_repository' => \Alifaraun\Wallet\Repositories\ConfigCurrencyRepository::class,
    // REQUIRED — used when you call createWallet()/walletOrCreate()/wallet() without a
    // currency. No hardcoded fallback: set it (here or via WALLET_DEFAULT_CURRENCY) or
    // a defaulted call throws MissingDefaultCurrencyException.
    'default_currency' => env('WALLET_DEFAULT_CURRENCY'),
    'currencies' => [ /* code => ['decimals' => int, 'symbol' => string] */ ],

    // How "today's total" is computed for daily limits.
    'daily_usage' => \Alifaraun\Wallet\Support\CounterTableUsage::class,

    // Limit rules. All positive major-unit numbers or null (= unlimited).
    // Per-transaction (min/max) and daily maximums are enforced.
    'limit_resolver' => \Alifaraun\Wallet\Support\DefaultLimitResolver::class,
    'limits' => [
        'debit'  => ['default_min' => null, 'default_max' => null, 'daily_default_max' => null],
        'credit' => ['default' => ['min' => null, 'max' => null, 'daily_max' => null]],
    ],

    'ref_prefix' => env('WALLET_REF_PREFIX', 'wlt_'),                     // wallet refs
    'transaction_ref_prefix' => env('WALLET_TRANSACTION_REF_PREFIX', 'txn_'), // auto transaction refs
    'daily_window_timezone' => env('WALLET_DAILY_TIMEZONE', config('app.timezone', 'UTC')),

    // Fallback for wallets with no low_balance_mode column of their own.
    'low_balance' => [
        'default_mode' => env('WALLET_LOW_BALANCE_DEFAULT_MODE', 'once'),
    ],
];

Extending

Every moving part is a contract resolved from the container — bind your own or point the config at a different class.

Currency source

Ship-default reads the config array. Switch to the DB-backed model, or your own:

// config/wallet.php
'currency_repository' => \Alifaraun\Wallet\Repositories\EloquentCurrencyRepository::class,

If you switch to EloquentCurrencyRepository, publish the optional currencies migration first with php artisan vendor:publish --tag=wallet-currency-migrations.

use Alifaraun\Wallet\Contracts\CurrencyRepository;

class MyCurrencies implements CurrencyRepository
{
    public function exists(string $code): bool { /* … */ }
    public function decimals(string $code): int { /* … */ }
    public function symbol(string $code): string { /* … */ }
}

// config/wallet.php => 'currency_repository' => MyCurrencies::class

Daily-usage tracker

// O(1) counter table (default) — best for high traffic:
'daily_usage' => \Alifaraun\Wallet\Support\CounterTableUsage::class,

// Zero-extra-table SUM() over the ledger — fine for low volume:
'daily_usage' => \Alifaraun\Wallet\Support\LedgerSumUsage::class,

Limit resolver & models

Point config('wallet.limit_resolver') at your own LimitResolver, or config('wallet.models.*') at a subclass to add columns/behaviour without forking.

Custom transaction types

Any string works as a type; you are not limited to the package's TransactionType enum:

Wallet::debit($wallet, 5.00, type: 'card_issue');
Wallet::credit($wallet, 5.00, type: MyTypes::Bonus); // a BackedEnum also works

Concurrency & high traffic

  • Correctness comes from re-reading the wallet row with lockForUpdate() inside DB::transaction, then checking limits/balance, writing the ledger row, updating the balance, and recording usage — all atomically. The cache is never used for locking.
  • Daily maximums stay O(1) via the wallet_daily_usage counter (maintained inside the same transaction, so it rolls back with it).
  • Tuning: deadlock/lock-wait auto-retry is on by default (WALLET_TRANSACTION_ATTEMPTS=3); raise it (5+) under heavy contention, or set 1 to disable. A retry is safe — the whole movement rolls back before re-running.
  • Read replicas: the package uses your default connection; because the critical reads happen inside a transaction, Laravel routes them to the primary. Just don't serve wallets from an async replica with a non-sticky read/write split.
  • Throughput: writes to different wallets scale freely; a single hot wallet is serialized (hundreds of ops/sec range). Scale by spreading load across many wallets.
  • Housekeeping: wallet_daily_usage accrues one row per wallet per active day — prune old rows periodically.

The bundled test suite runs on SQLite and verifies sequential logic; it does not (and cannot, on a single connection) prove the lock under real contention. Concurrency safety rests on the lockForUpdate-in-transaction design.

Octane/Swoole note: correctness (the balance lock) is enforced by the database transaction and is unaffected by the runtime. The separate immutability guard on transaction/usage models — which blocks accidental direct writes — uses a per-class static flag and is a developer guardrail, not a hard boundary; under Swoole coroutines (concurrent coroutines in one worker) treat it as such. The real integrity guarantees are the DB constraints and the locked transaction.

Database schema

Table Purpose
wallets one balance per (holder, currency); min_balance, low_balance_threshold/low_balance_mode (nullable), is_active, extra (JSON), unique (holder_type, holder_id, currency)
wallet_transactions immutable ledger; non-null reference (unique per-wallet key / public id); polymorphic operation + causer; unique (wallet_id, reference); wallet_id FK is ON DELETE RESTRICT
wallet_limits debit limit rules (scope + currency); per-transaction and daily maximums are enforced
wallet_daily_usage running per-(wallet, day, direction) totals for O(1) daily checks; wallet_id FK is ON DELETE RESTRICT
currencies optional; publish it with wallet-currency-migrations only when using EloquentCurrencyRepository

All money columns are signed BIGINT minor units.

Wallets with history can't be silently hard-deleted. The wallet_transactions and wallet_daily_usage foreign keys use ON DELETE RESTRICT (not CASCADE), because a DB-level cascade would bypass the model's immutability guard and wipe the ledger with no event and no trace. To retire a wallet, set is_active = false (new credit/debit/transfer/refund movements then throw WalletNotActiveException; reverse() still works so you can correct history on a retired wallet). A genuine hard-delete is a deliberate act: clear the wallet's history first.

Testing

composer install
composer test          # or: vendor/bin/phpunit

Static analysis and style:

vendor/bin/phpstan analyse
vendor/bin/pint --test

Troubleshooting

Symptom Cause Fix
UnknownCurrencyException on walletOrCreate() The currency code isn't in your source Add it to config('wallet.currencies') (or your currencies table / custom repository) with its decimals and symbol.
InvalidAmountException Amount is 0, negative, non-finite, non-numeric, beyond the safe integer range (±2⁵³), smaller than one minor unit so it rounds to zero, or the movement's resulting balance would exceed that range (also: a non-finite minBalance) Pass a positive, finite amount within range that is at least one minor unit at the currency's precision; keep amounts and balances within ±2⁵³ minor units; a fee may be 0 but not negative; minBalance may be negative but must be finite.
InvalidAttributeException A reference/type/description/name/ref/currency string is longer than its column Shorten the value (reference ≤ 64, type ≤ 50, currency ≤ 10, the rest ≤ 255), or widen the column and the matching WalletManager constant. The package validates up front so you get this instead of a DB error.
DuplicateReferenceException A reference already exists on that wallet (e.g. a replayed request) References are unique per wallet — use a fresh one per distinct movement, or treat the exception as "already processed" and look up the existing transaction by (wallet_id, reference).
InvalidOperationException Same-wallet transfer, or reverse() called on a transfer_out/transfer_in leg Use distinct wallets for transfers; undo a transfer with a compensating transfer() instead of reverse().
WalletNotActiveException is_active is false on the wallet (credit/debit/transfer/refund only — reverse still works) Re-activate it ($wallet->update(['is_active' => true])) or branch on is_active before moving money.
InsufficientBalanceException Debit would breach min_balance (default 0) Fund the wallet, lower the amount, or set a negative min_balance for an overdraft line.
TransactionLimitExceededException / DailyLimitExceededException A per-transaction or daily bound was hit Inspect getBreachedBound() / getLimit(); adjust the wallet_limits row (debit) or config('wallet.limits.credit') (credit).
DuplicateWalletLimitException A wallet_limits row already exists for the same (wallet_id, holder_type, currency) scope (active or not) Update the existing rule in place (toggle is_active on it) instead of creating a second row for the same scope.
MissingCurrencyContextException when reading money fields The query did not select the model's currency column Include currency when partially selecting money-cast models such as wallets, transactions, or limit rows.
Balance looks wrong by a factor of 10/100 A wrong decimals for the currency Minor units depend on decimals; correct the currency definition (changing it after data exists needs a migration of stored values).
RuntimeException: "decimals" must be … between 0 and 15 A currency declares more than 15 decimals (e.g. 18 for wei) This float-boundary design can't represent that precision exactly; cap the currency at ≤ 15 decimals, or model the asset in a coarser unit.
Listeners didn't fire on a rolled-back movement By design WalletCreated / TransactionRecorded are afterCommit; only LimitBreached fires before the rollback.

Changelog

See CHANGELOG.md.

Contributing

Pull requests are welcome. Please run vendor/bin/phpunit, vendor/bin/phpstan analyse, and vendor/bin/pint before submitting.

Security

Hardening notes

  • Never pipe raw request input into $wallet->update(...). min_balance, is_active, name, and extra are mass-assignable so you can edit them in trusted code — but min_balance is a financial control (a large negative floor is an unlimited overdraft) and is_active gates whether money can move. Whitelist these explicitly ($wallet->update($request->validate([...])) with your own rules, or set them in code) rather than passing $request->all(). The wallet's identity/ledger fields (ref, currency, balance, holder_*) are not mass-assignable and can't be tampered with this way.
  • Exceptions carry sensitive detail. See Exceptions — don't surface raw exception messages or context() to end users.
  • The immutability guard on transaction/usage models is a developer guardrail, not a security boundary. Integrity rests on the DB constraints and the locked transaction (see the Octane/Swoole note under Concurrency).

If you discover a security issue, please email ali1996426@hotmail.com instead of using the issue tracker.

Credits

License

The MIT License (MIT). See LICENSE.