goldoni / laravel-virtual-wallet
Virtual multi-wallet for Laravel with entries, transfers, enums and configurable table prefix
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/goldoni/laravel-virtual-wallet
Requires
- php: ^8.2
README
Virtual multi-wallets for Laravel — credit, debit, transfer.
Atomic, idempotent, configurable, and PSR-12 compliant.
✨ Features
- ✅ Multi-wallet by
label + currency - ✅ Immutable entries log with running balance
- ✅ Atomic operations with per-wallet locking
- ✅ Idempotency via
idempotency_key - ✅ Configurable enums, models, and table prefix
- ✅ Fluent API:
Wallet::for($user)->label('main')->currency('EUR')->credit('100.00');
⚙️ Requirements
- PHP 8.2+
- Laravel (provided by the host application)
- Any DB supported by Laravel that handles
decimal(20,8)
🚀 Quick Start
- Install (path repo)
composer require goldoni/laravel-virtual-wallet:dev-main
- Publish config
php artisan vendor:publish --tag=wallet-config
- Migrate
php artisan migrate
Default tables (with prefix wallet_):
wallet_wallets,wallet_entries,wallet_transfers
🧩 Configuration
config/wallet.php
return [ 'allow_negative' => false, 'default_currency' => 'EUR', 'precision' => 8, 'table_prefix' => 'wallet_', 'models' => [ 'wallet' => Goldoni\LaravelVirtualWallet\Models\Wallet::class, 'entry' => Goldoni\LaravelVirtualWallet\Models\Entry::class, 'transfer' => Goldoni\LaravelVirtualWallet\Models\Transfer::class, ], 'enums' => [ 'entry_type' => Goldoni\LaravelVirtualWallet\Enums\EntryType::class, 'entry_status' => Goldoni\LaravelVirtualWallet\Enums\EntryStatus::class, 'transfer_status' => Goldoni\LaravelVirtualWallet\Enums\TransferStatus::class, ], ];
Notes
table_prefix: change to avoid table name conflicts.allow_negative: allow overdraft on debit.precision: monetary scale (columns aredecimal(20,8)).
🧱 Add the Trait to Your Owner
use Goldoni\LaravelVirtualWallet\Traits\HasWallets; class User extends Authenticatable { use HasWallets; }
The trait adds:
wallets()morphMany relationwallet(string $label = 'main', ?string $currency = null)creator/getter
🛠️ Usage
use Goldoni\LaravelVirtualWallet\Facades\Wallet;
Credit
Wallet::for($user) ->label('main') ->currency('EUR') ->credit('100.00', ['idempotency_key' => 'dep-100']);
Debit
Wallet::for($user) ->label('main') ->currency('EUR') ->debit('25.00', ['idempotency_key' => 'wd-25']);
Transfer
Wallet::for($buyer) ->label('main') ->currency('EUR') ->transfer( toOwner: $seller, amount: '35.00', options: ['idempotency_key' => 'order-500'], fromLabel: 'main', toLabel: 'revenue' );
History & Balances
$entries = Wallet::for($user)->label('main')->currency('EUR')->history(50); $balance = Wallet::for($user)->label('main')->currency('EUR')->balance(); $wallets = Wallet::for($user)->wallets(); $totals = Wallet::for($user)->totalBalanceByCurrency();
📚 Examples
Chain credit, debit, transfer (no idempotency_key)
Wallet::for($user) ->label('main') ->currency('EUR') ->credit('100.00') ->debit('25.00') ->transfer($seller, '30.00', options: [], fromLabel: 'main', toLabel: 'revenue');
Multi-wallet on the same owner
// Fund two wallets Wallet::for($user)->label('main')->currency('EUR')->credit('200.00'); Wallet::for($user)->label('savings')->currency('EUR')->credit('50.00'); // Move funds from main to savings Wallet::for($user) ->label('main') ->currency('EUR') ->transfer($user, '25.00', options: [], fromLabel: 'main', toLabel: 'savings'); // Read balances $mainBalance = Wallet::for($user)->label('main')->currency('EUR')->balance(); $savingsBalance = Wallet::for($user)->label('savings')->currency('EUR')->balance();
🧠 API at a Glance
All amounts are strings (avoid float pitfalls).
Wallet::for(\Illuminate\Database\Eloquent\Model $owner): self Wallet::label(string $label): self Wallet::currency(string $currency): self Wallet::credit(string $amount, array $options = [], ?string $label = null, ?string $currency = null): self Wallet::debit(string $amount, array $options = [], ?string $label = null, ?string $currency = null): self Wallet::transfer(\Illuminate\Database\Eloquent\Model $toOwner, string $amount, array $options = [], ?string $fromLabel = null, ?string $toLabel = 'main', ?string $currency = null): self Wallet::history(int $limit = 50, ?string $label = null, ?string $currency = null): \Illuminate\Support\Collection Wallet::historyBetween(string $from, string $to, ?string $label = null, ?string $currency = null): \Illuminate\Support\Collection Wallet::paginateHistory(int $perPage = 50, ?string $label = null, ?string $currency = null): \Illuminate\Contracts\Pagination\LengthAwarePaginator Wallet::cursorHistory(int $perPage = 50, ?string $label = null, ?string $currency = null): \Illuminate\Contracts\Pagination\CursorPaginator Wallet::balance(?string $label = null, ?string $currency = null): string Wallet::balances(): \Illuminate\Support\Collection Wallet::totalBalanceByCurrency(): \Illuminate\Support\Collection Wallet::wallets(): \Illuminate\Support\Collection Wallet::ensureWallet(string $label, string $currency): \Illuminate\Database\Eloquent\Model
$options supports:
idempotency_keyreference_type,reference_idmeta(array)- optional
currencyvalidation override
🗄️ Data Model
| Table | Key Columns | Constraints |
|---|---|---|
wallet_wallets |
id, ulid, owner_type, owner_id, label, currency, balance, meta, timestamps |
Unique: (owner_type, owner_id, label, currency) |
wallet_entries |
id, wallet_id, ulid, type, status, amount, balance_after, currency, reference_*, meta |
Unique: (wallet_id, idempotency_key) |
wallet_transfers |
id, ulid, from_wallet_id, to_wallet_id, amount, currency, status, idempotency_key, meta |
Unique: idempotency_key |
Table names honor your
table_prefix.
🧾 Enums
Goldoni\LaravelVirtualWallet\Enums\EntryType::CREDIT | DEBIT Goldoni\LaravelVirtualWallet\Enums\EntryStatus::PENDING | COMPLETED | REVERSED Goldoni\LaravelVirtualWallet\Enums\TransferStatus::PENDING | COMPLETED | FAILED
Swap enum classes via config('wallet.enums.*').
🔔 Events
EntryRecorded($entry)TransferCompleted($transfer)
Dispatched after commit for consistency.
🧯 Exceptions
InvalidAmount— non-numeric or non-positive amountInsufficientFunds— would go negative andallow_negativeis falseCurrencyMismatch— mismatched currenciesDuplicateOperation— reusedidempotency_key
✅ Best Practices
- Use
idempotency_keyfor all external or retryable flows. - Keep labels meaningful:
main,savings,revenue, etc. - For multi-currency workflows, set
.currency('USD')or.currency('EUR'). - Pass amounts as strings:
'100.00'.
🧪 Example Controller
use Goldoni\LaravelVirtualWallet\Facades\Wallet; class WalletController { public function deposit(Request $request) { $user = $request->user(); Wallet::for($user) ->label('main') ->currency('EUR') ->credit($request->input('amount'), [ 'idempotency_key' => $request->header('Idempotency-Key') ]); return response()->noContent(); } }
🛠️ Custom Models
Replace Eloquent classes via config('wallet.models.*').
Your replacements must keep compatible columns and relations:
Wallet:owner()andentries()relationsEntry: belongs toWalletviawallet_idTransfer: usesfrom_wallet_idandto_wallet_id
🧹 Coding Standards
- PSR-12 compliant
📄 License
MIT