settleup / ledger
A general-purpose, double-entry, append-only financial ledger for Laravel.
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/contracts: ^11.0||^12.0||^13.0
- moneyphp/money: ^4.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2026-04-20 16:27:00 UTC
README
A general-purpose, double-entry, append-only financial ledger for Laravel.
Introduction
Ledger is a financial record-keeping package for Laravel applications that need a reliable audit trail of monetary events. It implements double-entry bookkeeping: every transaction contains debit and credit entries that must balance, ensuring your books always add up.
The ledger is append-only. Entries are never modified or deleted. To correct a mistake, you post a new reversing transaction. This gives you a complete, tamper-evident history of every financial event in your system.
Ledger provides the accounting infrastructure -- accounts, transactions, entries, balances, and fiscal periods -- while your application defines the specific accounts and business logic. It ships with a hierarchical chart of accounts: five standard categories (asset, liability, equity, revenue, expense) organised into group and detail accounts using numeric codes. Multi-currency is supported out of the box, with each entry storing both the original currency amount and a base currency equivalent.
Installation
Install the package via Composer:
composer require trysettleup/ledger
Publish and run the migrations:
php artisan vendor:publish --tag="ledger-migrations"
php artisan migrate
Publish the config file:
php artisan vendor:publish --tag="ledger-config"
Configuration
The published config file lives at config/ledger.php:
return [ // The base currency for balance calculations. All entry amounts are // converted to this currency using the entry's exchange rate. 'base_currency' => 'USD', // Default exchange rate applied when none is specified on an entry. 'default_exchange_rate' => 1.0, // Number of characters per hierarchy level in account codes. Codes must // be numeric strings whose length is a positive multiple of this value. // With code_depth of 2: '10' = level 1, '1010' = level 2, '101010' = level 3. 'code_depth' => 2, ];
Usage
Account Hierarchy
Ledger uses a hierarchical chart of accounts with two account types:
- Group accounts organise the chart into a tree. They cannot receive entries -- their balance is computed by rolling up descendant detail accounts.
- Detail accounts are the leaf nodes. They receive entries and maintain a denormalized running balance.
Account codes are numeric strings where the hierarchy level is determined by the configured code_depth. With the default code_depth of 2:
10 Assets (group, level 1)
1010 Current Assets (group, level 2)
101010 Cash (detail, level 3)
101020 Accounts Receivable (detail, level 3)
1020 Fixed Assets (group, level 2)
102010 Equipment (detail, level 3)
Creating Accounts
Create accounts using the Ledger facade. Every account belongs to one of five categories and is either a group or detail account:
use Money\Currency; use SettleUp\Ledger\Enums\AccountCategory; use SettleUp\Ledger\Facades\Ledger; // Create a group account to organise detail accounts beneath it $assets = Ledger::createGroup( code: '10', name: 'Assets', category: AccountCategory::Asset, currency: new Currency('USD'), ); $currentAssets = Ledger::createGroup( code: '1010', name: 'Current Assets', category: AccountCategory::Asset, currency: new Currency('USD'), ); // Create detail (posting) accounts -- these receive entries $cashAccount = Ledger::createDetail( code: '101010', name: 'Cash', category: AccountCategory::Asset, currency: new Currency('USD'), ); $revenueAccount = Ledger::createDetail( code: '4010', name: 'Service Revenue', category: AccountCategory::Revenue, currency: new Currency('USD'), );
The five account categories are: Asset, Liability, Equity, Revenue, and Expense. Asset and Expense accounts have normal debit balances; Liability, Equity, and Revenue accounts have normal credit balances.
Fiscal Periods
Transactions can only be posted within an open fiscal period. Create periods before posting:
use SettleUp\Ledger\Facades\Ledger; $period = Ledger::createPeriod( name: 'Q1 2025', startDate: now()->startOfQuarter(), endDate: now()->endOfQuarter(), ); // Close a period to prevent further posting Ledger::closePeriod($period); // Reopen if needed Ledger::reopenPeriod($period);
Periods cannot overlap. All period status changes are audited with timestamps and an optional actor.
Posting Transactions
Post a balanced transaction using EntryLineItem objects. Debits must equal credits in base currency:
use Money\Money; use SettleUp\Ledger\Facades\Ledger; use SettleUp\Ledger\Support\EntryLineItem; $transaction = Ledger::post( description: 'Invoice #1042 payment received', date: now(), entries: [ EntryLineItem::debit($cashAccount, Money::USD(5000)), EntryLineItem::credit($revenueAccount, Money::USD(5000)), ], ); // $transaction->uuid -- unique identifier // $transaction->entries -- collection of Entry models
You can attach arbitrary metadata and an actor (the authenticated user who performed the action):
$transaction = Ledger::post( description: 'Subscription renewal', date: now(), entries: [ EntryLineItem::debit($cashAccount, Money::USD(9900)), EntryLineItem::credit($revenueAccount, Money::USD(9900)), ], metadata: ['invoice_id' => 1042, 'plan' => 'pro'], actor: $user, );
Multi-Currency
For entries in a currency other than the base currency, provide an exchange rate. The base currency amount is computed automatically:
use Money\Money; use SettleUp\Ledger\Facades\Ledger; use SettleUp\Ledger\Support\EntryLineItem; // Record a EUR 100.00 payment when EUR/USD = 1.08 $transaction = Ledger::post( description: 'Payment from EU customer', date: now(), entries: [ EntryLineItem::debit($cashEurAccount, Money::EUR(10000), exchangeRate: 1.08), EntryLineItem::credit($revenueAccount, Money::USD(10800)), ], );
Each entry stores the original currency amount, the exchange rate, and the computed base currency equivalent. The entry currency must match the account's currency.
Reversals
Since the ledger is append-only, corrections are made by reversing a transaction. This posts a new transaction with opposite entry types:
use SettleUp\Ledger\Facades\Ledger; $reversal = Ledger::reverse($transaction, 'Reverse: duplicate payment'); // The reversal is a new Transaction with entries mirroring the original // but with debits and credits swapped.
Rollup Balances
Query the rolled-up balance of any account. For group accounts, this sums all descendant detail account balances. For detail accounts, it returns the account's own balance:
use SettleUp\Ledger\Facades\Ledger; // Get the rolled-up balance for a group account (sums all descendants) $assetsBalance = Ledger::rollupBalance($assets); // Money object in base currency // Get a detail account's balance $cashBalance = Ledger::rollupBalance($cashAccount);
Rebuilding Balances
If you ever need to recompute account balances from scratch (e.g., after a data repair):
use SettleUp\Ledger\Facades\Ledger; // Rebuild a single account Ledger::rebuildBalance($cashAccount); // Rebuild all accounts Ledger::rebuildBalances();
Account Lifecycle
Accounts go through three statuses: active, inactive, and archived. Only active accounts can receive entries.
use SettleUp\Ledger\Facades\Ledger; // Deactivate an account (prevents new entries) Ledger::deactivateAccount($account); // Re-activate it Ledger::activateAccount($account); // Archive permanently Ledger::archiveAccount($account);
When deactivating or archiving a group account, the status change cascades to all descendants. Activation does not cascade -- children must be re-activated individually.
Polymorphic References
Attach transactions to any Eloquent model by implementing the LedgerReferenceable interface and adding the HasLedgerTransactions trait:
use Illuminate\Database\Eloquent\Model; use SettleUp\Ledger\Concerns\HasLedgerTransactions; use SettleUp\Ledger\Contracts\LedgerReferenceable; class Invoice extends Model implements LedgerReferenceable { use HasLedgerTransactions; }
Then pass the model when posting:
$invoice = Invoice::find(1); $transaction = Ledger::post( description: 'Invoice #1 payment', date: now(), entries: [ EntryLineItem::debit($cashAccount, Money::USD(5000)), EntryLineItem::credit($revenueAccount, Money::USD(5000)), ], referenceable: $invoice, ); // Later, query all ledger transactions for an invoice: $invoice->ledgerTransactions;
Account Ownership
Assign accounts to Eloquent models (e.g., customers or vendors) by implementing the LedgerAccountOwner interface:
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use SettleUp\Ledger\Contracts\LedgerAccountOwner; use SettleUp\Ledger\Models\Account; class Customer extends Model implements LedgerAccountOwner { public function ledgerAccounts(): MorphMany { return $this->morphMany(Account::class, 'owner'); } }
Then pass the owner when creating accounts:
$account = Ledger::createDetail( code: '101010', name: 'Customer Receivable', category: AccountCategory::Asset, currency: new Currency('USD'), owner: $customer, );
Events
The package dispatches events at key points so you can hook into the lifecycle:
| Event | When it fires |
|---|---|
AccountDetailCreated |
After a detail account is created |
AccountGroupCreated |
After a group account is created |
AccountStatusChanged |
After an account status transitions (active/inactive/archived) |
TransactionPosted |
After a transaction is posted and balances are updated |
TransactionReversed |
After a transaction is reversed (fires in addition to TransactionPosted) |
PeriodCreated |
After a fiscal period is created |
PeriodClosed |
After a fiscal period is closed |
PeriodReopened |
After a fiscal period is reopened |
All events are in the SettleUp\Ledger\Events namespace.
Testing
Using the Fake
Swap the ledger for an in-memory fake in your tests. The fake validates entries the same way (balanced transactions, active accounts, period constraints, currency matching) but does not touch the database:
use SettleUp\Ledger\Facades\Ledger; // In your test setUp or at the start of a test: $fake = Ledger::fake(); // ... run your application code that posts transactions ... // Assert a transaction was posted $fake->assertPosted(); // Assert with a callback $fake->assertPosted(fn ($transaction) => $transaction->description === 'Invoice #1042 payment received' ); // Assert nothing was posted $fake->assertNothingPosted(); // Assert exact count $fake->assertPostedCount(2); // Assert a reversal occurred $fake->assertReversed(); $fake->assertReversed(fn ($original, $reversal) => $original->description === 'Invoice #1042 payment received' ); // Assert no reversals $fake->assertNotReversed(); // Assert account creation $fake->assertAccountCreated(); $fake->assertAccountCreated(fn ($account) => $account->code === '101010'); $fake->assertNotAccountCreated(); // You can also query balances on the fake $balance = Ledger::rollupBalance($account);
Running Package Tests
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.