alifaraun/wallet
Composer 安装命令:
composer require alifaraun/wallet
包简介
Multi-currency, limit-aware wallet/ledger package for Laravel.
README 文档
README
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
- Requirements
- Installation
- Quick start
- Core concepts
- Operations
- Money & currencies
- Limits
- Overdraft
- Reference uniqueness
- Exceptions
- Events
- Configuration
- Extending
- Concurrency & high traffic
- Database schema
- Testing
- Troubleshooting
- Changelog
- Contributing
- Security
- Credits
- License
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(orulid) 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=USDto your.env(or setdefault_currencyin the publishedconfig/wallet.php) to a code present in yourcurrencieslist. Any call that omits a currency while this is unset throwsMissingDefaultCurrencyException.
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).typeis any string (or aBackedEnum).causeris explicit and may benull— the package never readsauth()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. Thename,minBalance,extra,ref,lowBalanceThresholdandlowBalanceModearguments apply only when a new wallet is created — they never update an existing one.name,min_balance,low_balance_threshold,low_balance_mode,is_activeandextracan be changed afterwards directly on the wallet ($wallet->update([...]));ref,currencyandbalanceare 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 HasWalletsand implementsWalletHolder. Stored polymorphically, soClient,User,Reseller, … all work. - Wallet — a balance for one
(holder, currency)pair. A holder may have many wallets, one per currency. Carries an optionalextraJSON bag for app-specific data (e.g. a provider account ID). - Transaction — an immutable ledger row for every movement:
amount(positive magnitude),fee, signednet_amount,balance_before/balance_after,type,debitflag, optional polymorphicoperationandcauser, and anextraJSON 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 — atransfer_outon the sender and atransfer_inon the receiver — committed atomically bytransfer()(both or neither).reverse()only ever touches one wallet, so reversing a single leg would unbalance the pair; it throwsInvalidOperationExceptioninstead. To undo a whole transfer, move the funds back with a compensatingtransfer()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 themin_balancefloor by design, undoing a credit after that money already left the wallet (e.g. it was debited elsewhere) leaves the balance belowmin_balance. That is intentional — the funds are genuinely owed back — but it means a reversal can park a wallet under its floor without anInsufficientBalanceException. 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:
CounterTableUsagerecords nothing for a reversal, andLedgerSumUsageexcludes reversal rows.) This mirrors the fact thatreverse()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
floatspace. 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 withInvalidAmountException(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 to0, e.g.0.004USD) 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:
- a rule for the specific wallet, then
- a holder-type default (
holder_typeset,wallet_idnull), then - a global default (both null), then
- 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
currencybefore the*_amountfields (as above). The amount columns are money-cast and read the row'scurrencyto know the decimal precision, so a limit row whose amounts are assigned before its currency throwsMissingCurrencyContextException.
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()isnull(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 orcontext()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()insideDB::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_usagecounter (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 set1to 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_usageaccrues 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_transactionsandwallet_daily_usageforeign keys useON DELETE RESTRICT(notCASCADE), 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, setis_active = false(new credit/debit/transfer/refund movements then throwWalletNotActiveException;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, andextraare mass-assignable so you can edit them in trusted code — butmin_balanceis a financial control (a large negative floor is an unlimited overdraft) andis_activegates 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.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-24