settleup/ledger

A general-purpose, double-entry, append-only financial ledger for Laravel.

Maintainers

Package info

github.com/trysettleup/ledger

pkg:composer/settleup/ledger

Fund package maintenance!

SettleUp

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

0.2.0 2026-04-14 18:06 UTC

README

A general-purpose, double-entry, append-only financial ledger for Laravel.

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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.