syriable/laravel-ledger

An immutable, append-only, double-entry financial ledger engine for Laravel.

Maintainers

Package info

github.com/syriable/laravel-ledger

pkg:composer/syriable/laravel-ledger

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0-rc.1 2026-05-24 12:01 UTC

README

SyriableLedger Logo

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

An immutable, append-only, double-entry financial ledger engine for Laravel. Strongly opinionated, minimal core, strict invariants. It records balanced double-entry transactions atomically and idempotently, and reads them back accurately — and it refuses to do anything that would let your books drift.

use Syriable\Ledger\Facades\Ledger;

Ledger::createLedger(slug: 'platform-main', currency: 'USD');

$result = Ledger::post(new OrderPaidPosting($order));

$result->transaction;   // the recorded Transaction
$result->wasReplayed;   // true if this reference was already posted

Status: 1.0.0-rc.1 — API frozen for the soak period; behaviour will not change before 1.0.0 unless a critical bug demands it. See CHANGELOG and UPGRADING.

Requirements

  • PHP 8.3 or higher (64-bit)
  • Laravel 11, 12, or 13
  • One of: PostgreSQL 12+, MySQL 8+, MariaDB 10.4+, or SQLite 3.31+

The full CI matrix runs against PostgreSQL 16, MySQL 8, and SQLite on PHP 8.3 and 8.4.

Installation

Install the package via Composer:

composer require syriable/laravel-ledger

Publish and run the migrations:

php artisan vendor:publish --tag="ledger-migrations"
php artisan migrate

Optionally publish the config file:

php artisan vendor:publish --tag="ledger-config"

The full config reference (every key, type, and default) lives in docs/05-installation-and-quickstart.md.

Usage

The public surface is three verbs: open, post, reverse.

Open a ledger and accounts

use Syriable\Ledger\Enums\AccountType;
use Syriable\Ledger\Facades\Ledger;

Ledger::createLedger(slug: 'platform-main', currency: 'USD');

$cash = Ledger::for('platform-main')->openAccount(
    code: 'platform.cash.usd',
    type: AccountType::Asset,
    currency: 'USD',
);

$revenue = Ledger::for('platform-main')->openAccount(
    code: 'platform.revenue.usd',
    type: AccountType::Revenue,
    currency: 'USD',
);

Define a Posting

A Posting is the one and only way to write to the ledger. It is a deterministic domain operation that produces a balanced transaction.

php artisan make:posting OrderPaidPosting
use Syriable\Ledger\Data\EntryDraft;
use Syriable\Ledger\Models\Account;
use Syriable\Ledger\Postings\Posting;
use Syriable\Ledger\ValueObjects\Money;
use Syriable\Ledger\ValueObjects\Reference;

final class OrderPaidPosting extends Posting
{
    /**
     * Accounts and amounts are resolved by the caller and passed in.
     * A Posting must never query the database inside entries() — that
     * would break determinism on retry. See docs/04-the-posting-contract.md.
     */
    public function __construct(
        private readonly string $orderId,
        private readonly Account $cash,
        private readonly Account $revenue,
        private readonly Money $total,
    ) {}

    public function ledger(): string       { return 'platform-main'; }
    public function currency(): string     { return $this->total->currency; }
    public function reference(): Reference  { return Reference::for('order.paid', $this->orderId); }

    public function entries(): array
    {
        return [
            EntryDraft::debit($this->cash, $this->total),
            EntryDraft::credit($this->revenue, $this->total),
        ];
    }
}

Post it

$scope   = Ledger::for('platform-main');
$cash    = $scope->account('platform.cash.usd');
$revenue = $scope->account('platform.revenue.usd');

$result = Ledger::post(new OrderPaidPosting(
    orderId: $order->id,
    cash: $cash,
    revenue: $revenue,
    total: Money::of(9_900, 'USD'),
));

Posting is idempotent on the Reference. Posting the same operation twice returns the original transaction with wasReplayed = true — no duplicate write.

Reverse it

Ledger::reverse($result->transaction, reason: 'chargeback');

A reversal is a new, immutable transaction that inverts the original. A transaction can be reversed at most once, and a reversal cannot itself be reversed — both enforced at the database level. For partial refunds, post a new operation rather than reversing.

Read balances

$cash = Ledger::for('platform-main')->account('platform.cash.usd');

$cash->balance();              // int — signed balance (negative = overdraft)
$cash->balanceMoney();         // Money
$cash->balanceAsOf($moment);   // int — historical balance, from entries
$cash->entries;                // immutable history

When you iterate accounts and call balance() on each, use the withBalance scope to eager-load the projection and avoid an N+1:

$accounts = Account::query()
    ->withBalance()
    ->where('ledger_id', $ledgerId)
    ->get();

$accounts->each(fn (Account $a) => $a->balance());  // zero extra queries

Owner-side ergonomics

Apply the HasAccounts trait to any model that owns accounts:

use Syriable\Ledger\HasAccounts;

class User extends Model
{
    use HasAccounts;

    public function defaultLedgerSlug(): string
    {
        return 'platform-main';
    }
}

$user->openAccount('available.usd', AccountType::Liability, 'USD');
$user->account('available.usd')->balance();

Verify integrity

php artisan ledger:verify                    # all ledgers
php artisan ledger:verify --ledger=platform-main
php artisan ledger:rebuild-balances          # rebuild projections from entries
php artisan ledger:simulate                  # rehearse a marketplace at volume

ledger:verify checks that every transaction balances, every ledger is zero-sum, and every balance projection matches the entries. It exits non-zero on drift — wire it into your scheduler and your CI.

ledger:simulate drives a realistic marketplace lifecycle through the real API at volume and verifies the result against an independent shadow ledger — a one-command way to stress-test the package before trusting it with real money. Run it only against a disposable database, and run php artisan migrate:fresh before each run to avoid reference collisions with previous runs. See docs/09-operations.md.

Core invariants

These are enforced by validators and database constraints. They cannot be relaxed by configuration.

  • Append-only — no financial column on transactions or entries is ever mutated.
  • Always balancedΣ debits == Σ credits per transaction.
  • Single currency per transaction — FX is two linked postings.
  • Entry currency matches account currency.
  • No cross-ledger entries.
  • Amounts are positive integers — direction is encoded by Debit/Credit.
  • Every transaction has a unique idempotency reference.
  • Archived accounts reject new entries (reversals exempted).
  • A transaction can be reversed at most once; reversals cannot be reversed.
  • No soft-deletes on financial models.
  • Money never crosses the float boundary.

See docs/03-invariants.md for the full list and what enforces each one.

Documentation

Full documentation lives in docs/ — start with the documentation index. If you have never worked with a double-entry ledger, read The Posting Contract & Direction of Value first.

Start here

  • Introduction — why this package exists.
  • Concepts — the seven concepts the package is built from.
  • Invariants — the financial rules and what enforces each.

Build with the package

Operate & extend

  • Operations — verify, rebuild, Octane, batch posting, drift recovery, legacy imports.
  • Extension Points — the five ways to extend the package.
  • Events & Exceptions — every event and exception, plus listener guidance.
  • Anti-features — what the package will never do.
  • Testing — how to test a system built on the package.
  • FAQ — quick answers to common questions.

Testing

composer test

The full suite runs against SQLite by default. To exercise the real-database CHECK constraints locally:

DB_DRIVER=pgsql DB_HOST=127.0.0.1 DB_DATABASE=ledger_test DB_USERNAME=postgres composer test
DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_DATABASE=ledger_test DB_USERNAME=root  composer test

Changelog

See CHANGELOG. Upgrading from 0.9.x is covered in UPGRADING.md — most installs need only php artisan migrate.

Contributing

Please see CONTRIBUTING for details — including the explicit list of changes that will be rejected, because this package's invariants are the reason it exists.

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.