olaoluwasipe / multi-wallet-ledger
Double-entry multi-currency wallet ledger for Laravel
Package info
github.com/olaoluwasipe/laravel-wallet-ledger
pkg:composer/olaoluwasipe/multi-wallet-ledger
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is auto-updated.
Last update: 2026-04-27 11:24:05 UTC
README
Double-entry, multi-currency ledger primitives for Laravel: wallets, accounts, journals, and optional business-level transactions. Amounts are stored in minor units (e.g. cents) per ISO currency code; each journal must balance per currency (total debits equal total credits for that currency).
The package focuses on persistence and invariants (balanced postings, line shape, idempotency). It does not ship UI or balance-sheet views—you build those on top of the models and journal_lines.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13 (
illuminate/*^11|^12|^13)
What it provides
| Layer | Role |
|---|---|
| Wallet | A logical holder: user, system, or provider (extensible via config). May be tied to an owner (owner_type / owner_id morph) or stand alone (e.g. treasury). Optional code disambiguates system wallets (treasury, fees, float). |
| Account | A posting target inside a wallet: account_kind (e.g. available, pending_in) maps to an accounting_type (asset, liability, …) from config. Accounts are identified in practice by wallet + generated code such as USD:available. |
| Journal | One balanced posting event: header + many journal lines. |
| Journal line | Debit or credit to one account, in one currency, in minor units. |
Ledger (WalletLedger\Services\Ledger) |
Validates and persists a JournalDraft inside a DB transaction; optional idempotency via idempotency_key on the journal. Dispatches JournalPosted. |
| WalletRegistry | getOrCreateWallet() and ensureAccount() using your published wallet-ledger config (wallet types, allowed codes, account kinds). |
| TransactionService (optional) | Posts a draft LedgerTransaction + lines to the ledger, links journal_id and journal_line_id, sets status to posted. Requires extra migrations and features.business_transactions. Dispatches TransactionPosted. |
Strict vs lenient validation: validation_mode is strict (default) or lenient. In strict mode, unknown wallet_type, account_kind, or transaction_type values throw. In lenient mode, those checks are skipped (useful while prototyping); unknown account kinds still default accounting_type to asset when missing from config.
Installation
1. Require the package
composer require olaoluwasipe/multi-wallet-ledger
Laravel auto-discovers WalletLedger\WalletLedgerServiceProvider, which:
- Merges
config/wallet-ledger.php - Registers
Ledger,LedgerContract,TransactionService,TransactionServiceContract,WalletRegistry,ConfigValidator - Loads core migrations from the package (
database/migrations) on every boot—no publish step is required for the base tables
2. Run migrations
php artisan migrate
This creates tables whose names use the configured prefix (default wl_): wl_wallets, wl_accounts, wl_journals, wl_journal_lines.
3. (Recommended) Publish configuration
Publish once so you can edit wallet types, account kinds, transaction types, table prefix, and flags:
php artisan vendor:publish --tag=wallet-ledger-config
Edit config/wallet-ledger.php. You can mirror important values in .env:
| Env | Config path | Purpose |
|---|---|---|
WALLET_LEDGER_VALIDATION_MODE |
validation_mode |
strict or lenient |
WALLET_LEDGER_BUSINESS_TRANSACTIONS |
features.business_transactions |
Enable TransactionService |
| (none by default) | table_prefix |
DB table prefix (default wl_) |
Order matters: set table_prefix before the first migration if you do not want the default wl_. Changing prefix after tables exist requires a manual rename or new install.
4. Optional: copy migrations into your app
Normally you rely on the package’s loadMigrationsFrom. If you prefer owning migration files (e.g. to tweak indexes):
php artisan vendor:publish --tag=wallet-ledger-migrations php artisan migrate
Avoid duplicating the same schema: either use package-loaded migrations or published copies, not both.
5. Optional: business transactions
Extra tables: wl_transactions, wl_transaction_lines, and transaction_id on wl_journals.
php artisan vendor:publish --tag=wallet-ledger-transaction-migrations php artisan migrate
Then enable the feature:
WALLET_LEDGER_BUSINESS_TRANSACTIONS=true
or in config/wallet-ledger.php:
'features' => [ 'business_transactions' => true, ],
Without both migrations and this flag, TransactionService::post() throws a clear runtime exception.
Configuration reference
After publishing, config/wallet-ledger.php controls:
table_prefix— Prefix for all package tables (viaWalletLedger\Support\Table::name()).validation_mode—strict|lenient.features.business_transactions— Must be true to useTransactionService.wallet_types— Each key is a allowedwallet_type. Options include:owner_required— If true,getOrCreateWallet()requires an owner model.allowed_codes— For system wallets, optional whitelist ofcodevalues (default includestreasury,fees,float).
account_kinds— Each key is an allowedaccount_kind;accounting_typedrives reporting (asset vs liability, etc.).transaction_types— Keys are allowedtransaction_typevalues onLedgerTransactionwhen strict.
Inject WalletLedger\Services\WalletRegistry and resolve WalletLedger\Contracts\LedgerContract (or Ledger) in your services, seeders, and jobs.
Adding wallets and accounts in code
Use WalletRegistry so config rules (owner required, allowed codes, account kinds) are enforced in strict mode.
System wallet (no owner; use code from allowed_codes):
use WalletLedger\Services\WalletRegistry; $registry = app(WalletRegistry::class); $treasury = $registry->getOrCreateWallet('system', null, 'treasury', 'Treasury'); $treasuryUsd = $registry->ensureAccount($treasury, 'available', 'USD', 'Treasury USD');
User wallet (owner required):
$wallet = $registry->getOrCreateWallet('user', $user, null, 'Primary wallet'); $usdAvailable = $registry->ensureAccount($wallet, 'available', 'USD');
Adding more wallet types (e.g. merchant):
- Add a block under
wallet_typesinconfig/wallet-ledger.phpwithowner_required/allowed_codesas needed. - Clear config cache if applicable:
php artisan config:clear.
Adding more account kinds (e.g. escrow):
- Add an entry under
account_kindswith the correctaccounting_type(asset,liability, etc.) for how you want balances and reports to behave. - Call
ensureAccount($wallet, 'escrow', 'EUR', 'Optional display name').
Account codes are generated as {CURRENCY}:{account_kind} (currency is uppercased). One row per wallet + code combination.
Posting journals (core API)
Resolve WalletLedger\Contracts\LedgerContract and pass a JournalDraft with JournalLineDraft lines:
use WalletLedger\Contracts\LedgerContract; use WalletLedger\DTO\JournalDraft; use WalletLedger\DTO\JournalLineDraft; $ledger = app(LedgerContract::class); $journal = $ledger->post(new JournalDraft( lines: [ new JournalLineDraft($debitAccountId, 'USD', debitMinor: 1000, creditMinor: 0), new JournalLineDraft($creditAccountId, 'USD', debitMinor: 0, creditMinor: 1000), ], idempotencyKey: 'optional_unique_key', // duplicate post returns same journal description: 'Transfer', referenceType: \App\Models\Order::class, // optional morph referenceId: 123, meta: ['source' => 'api'], ));
Rules enforced before insert:
- At least one line; per currency, sum of
debit_minorequals sum ofcredit_minor. - Each line: non-negative amounts, not both debit and credit positive, ISO 3-letter currency.
- All
account_idvalues must exist.
Idempotency: If idempotency_key is set and non-empty, a second post() with the same key returns the existing journal (with lines), without double-posting.
Events: WalletLedger\Events\JournalPosted carries the saved Journal (with relations as loaded).
Business transactions (optional)
- Publish and run transaction migrations (see above).
- Set
features.business_transactionsto true.
Create a WalletLedger\Models\LedgerTransaction with status = draft, a valid transaction_type, and related LedgerTransactionLine rows (balanced, same shape as journal lines). Then:
use WalletLedger\Contracts\TransactionServiceContract; $posted = app(TransactionServiceContract::class)->post($transaction);
The service creates the journal, attaches journal_id to the transaction, sets posted_at and status = posted, and links each transaction line to its journal_line_id. Idempotency is supported on the transaction via idempotency_key when status is already posted.
Inspecting what is in the database
Table names below assume the default prefix wl_. If you changed table_prefix, substitute accordingly (or query config('wallet-ledger.table_prefix') in Tinker).
Laravel Tinker:
php artisan tinker
use WalletLedger\Models\Wallet; use WalletLedger\Models\Account; use WalletLedger\Models\Journal; use WalletLedger\Models\JournalLine; Wallet::with('accounts')->get(); Journal::with('lines.account.wallet')->latest('id')->take(5)->get();
Useful SQL (MySQL / SQLite style):
List wallets:
SELECT id, uuid, wallet_type, owner_type, owner_id, code, name FROM wl_wallets ORDER BY id;
List accounts with wallet:
SELECT a.id, a.code, a.name, a.account_kind, a.accounting_type, w.wallet_type, w.code AS wallet_code FROM wl_accounts a JOIN wl_wallets w ON w.id = a.wallet_id ORDER BY w.id, a.id;
Recent journals with line count:
SELECT j.id, j.uuid, j.description, j.posted_at, COUNT(l.id) AS line_count FROM wl_journals j LEFT JOIN wl_journal_lines l ON l.journal_id = j.id GROUP BY j.id ORDER BY j.id DESC LIMIT 20;
If business transactions are enabled:
SELECT id, uuid, transaction_type, status, journal_id, posted_at FROM wl_transactions ORDER BY id DESC;
Balances, trial balance, and “balance sheet” style views
The package does not compute running balances on write. You derive them by aggregating wl_journal_lines (and optionally filtering by posted_at or journal id for “as of” reports).
Trial balance (per account and currency) — raw debit and credit totals:
SELECT a.id AS account_id, a.code, a.name, a.accounting_type, jl.currency, SUM(jl.debit_minor) AS debit_minor, SUM(jl.credit_minor) AS credit_minor FROM wl_journal_lines jl JOIN wl_accounts a ON a.id = jl.account_id GROUP BY a.id, a.code, a.name, a.accounting_type, jl.currency ORDER BY a.accounting_type, a.code, jl.currency;
Signed balance in minor units depends on accounting_type (normal balance):
- Asset (normal debit):
debit_minor - credit_minor - Liability (normal credit):
credit_minor - debit_minor
Example grouped by accounting side:
SELECT a.accounting_type, jl.currency, SUM(CASE WHEN a.accounting_type = 'asset' THEN jl.debit_minor - jl.credit_minor ELSE jl.credit_minor - jl.debit_minor END) AS balance_minor FROM wl_journal_lines jl JOIN wl_accounts a ON a.id = jl.account_id GROUP BY a.accounting_type, jl.currency;
For a wallet-level “available USD” balance, filter accounts (e.g. account_kind = 'available') and restrict to the wallet’s account_id set, or join through wl_accounts.wallet_id.
Display tips:
- Convert minor → major in the UI:
amount_major = balance_minor / 10^exponent(often 2 for USD; use a money library or ISO currency metadata for exotic exponents). - For a traditional balance sheet, map your
accounting_typevalues to line items (and split current vs non-current in your own metadata or naming convention—the package only stores the stringaccounting_typeonaccounts). - For multi-currency, never sum different
currencyvalues without FX logic; the ledger already isolates balances per currency.
Events
| Event | When |
|---|---|
WalletLedger\Events\JournalPosted |
After a successful Ledger::post(). |
WalletLedger\Events\TransactionPosted |
After TransactionService::post() completes. |
Subscribe in EventServiceProvider or use Laravel’s auto-discovery for listeners.
Testing (package developers)
Default composer test runs fast unit tests (no database).
composer test:integration
Integration tests expect MySQL and create a database named wallet_ledger_package_test unless overridden with WALLET_LEDGER_DB_* variables—see tests/bootstrap.php.
Summary
composer require olaoluwasipe/multi-wallet-ledger→php artisan migratephp artisan vendor:publish --tag=wallet-ledger-config→ adjust wallet/account/transaction definitions- Use
WalletRegistryfor wallets/accounts andLedgerContractfor postings (orTransactionServicewhen business transactions are enabled) - Inspect data via Eloquent models or SQL on
wl_*tables - Build balance sheet and P&L views by aggregating
journal_lineswith youraccounting_typeand currency rules