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: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.9.1 2026-05-21 14:31 UTC

This package is auto-updated.

Last update: 2026-05-21 14:42:23 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

Installation

You can install the package via Composer:

composer require syriable/laravel-ledger

You can publish and run the migrations with:

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

You can publish the config file with:

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

This is the contents of the published config file:

return [

    // If your app uses a single ledger, set its slug here and the
    // HasAccounts trait will resolve it automatically.
    'default_ledger_slug' => env('LEDGER_DEFAULT_SLUG'),

    // Override table names if they collide with existing tables.
    'table_names' => [
        'ledgers' => 'ledgers',
        'accounts' => 'accounts',
        'transactions' => 'transactions',
        'entries' => 'entries',
        'balances' => 'balances',
    ],

    // The package's required validators always run first and cannot be
    // removed. Anything listed here is appended after the required set.
    'validators' => [
        // \App\Ledger\Validators\AmountCeilingValidator::class,
    ],

];

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

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 the docs/ directory. Start with the documentation index.

If you have never worked with a double-entry ledger, read The Posting Contract & Direction of Value first — it explains debit/credit, normal balances, and the rules a Posting must follow, from scratch.

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

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.