PHPackages                             othmanhaba/ledger-core - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. othmanhaba/ledger-core

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

othmanhaba/ledger-core
======================

Generic double-entry ledger core package for Laravel applications.

v0.2.4(2mo ago)043MITPHPPHP ^8.3

Since May 2Pushed 2mo agoCompare

[ Source](https://github.com/OthmanHaba/ledger-core)[ Packagist](https://packagist.org/packages/othmanhaba/ledger-core)[ Docs](https://github.com/othmanhaba/ledger-core)[ RSS](/packages/othmanhaba-ledger-core/feed)WikiDiscussions main Synced 3w ago

READMEChangelogDependencies (5)Versions (9)Used By (0)

Ledger Core
===========

[](#ledger-core)

`ledger-core` is a generic, reusable double-entry ledger package for Laravel applications.

It provides the accounting core that many applications need: books, accounts, journal entries, journal lines, balances, currencies, reversals, idempotency, posting validation, reporting, auditability, and optional Filament administration.

The package is intentionally domain-neutral. It does not contain models or services for transfers, customers, providers, branches, remittance, wallets, invoices, orders, subscriptions, payouts, exchanges, or any other host-application workflow. Your application owns those workflows and translates them into ledger journal entries.

Table Of Contents
-----------------

[](#table-of-contents)

- [What This Package Solves](#what-this-package-solves)
- [Core Principles](#core-principles)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Database Model](#database-model)
- [Concepts](#concepts)
- [Quick Start](#quick-start)
- [Creating Ledger Entities](#creating-ledger-entities)
- [Creating Accounts](#creating-accounts)
- [Posting Journal Entries](#posting-journal-entries)
- [Opening Balances](#opening-balances)
- [Idempotency](#idempotency)
- [Reversals](#reversals)
- [Balances](#balances)
- [Reports](#reports)
- [Filament Integration](#filament-integration)
- [Host Application Use Cases](#host-application-use-cases)
- [Extending The Package](#extending-the-package)
- [Events](#events)
- [Exceptions](#exceptions)
- [Testing](#testing)
- [Operational Guidance](#operational-guidance)
- [FAQ](#faq)

What This Package Solves
------------------------

[](#what-this-package-solves)

Most applications eventually need an audit-friendly financial record. A simple `balance` column is easy to start with, but it becomes difficult to reason about when you need corrections, historical reporting, idempotency, multiple accounts, multiple currencies, or audit trails.

`ledger-core` gives you a generic double-entry ledger:

- Every posting has debit and credit lines.
- Every journal entry must balance.
- Every posting is atomic.
- Every posting is idempotent.
- Posted entries are immutable.
- Corrections are made with reversals.
- Balances are cached for fast reads but derived from journal lines.
- Business workflows stay outside the package.

Use this package when your Laravel app needs accounting-style records without hardcoding your business domain into the ledger.

Core Principles
---------------

[](#core-principles)

1. The ledger core is generic.
2. Business-specific logic belongs in the host application.
3. Every posted journal entry must be balanced.
4. Posted journal entries and lines are immutable.
5. Reversal entries correct mistakes.
6. Idempotency is mandatory.
7. Cached balances are updated only by the posting service.
8. Amounts are decimal strings, not PHP floats.
9. Posting is transactional.
10. Filament support is optional.

Requirements
------------

[](#requirements)

- PHP 8.3 or newer
- Laravel 11 or 12
- Eloquent
- `ext-bcmath`
- A database supported by Laravel migrations
- Optional: Filament, if you want admin resources and reports

Installation
------------

[](#installation)

Install the package:

```
composer require othmanhaba/ledger-core
```

The package service provider is auto-discovered by Laravel through Composer.

Publish the config:

```
php artisan vendor:publish --tag=ledger-core-config
```

Publish the migrations:

```
php artisan vendor:publish --tag=ledger-core-migrations
```

Run migrations:

```
php artisan migrate
```

Configuration
-------------

[](#configuration)

The published config file is `config/ledger.php`.

```
return [
    'tables' => [
        'entities' => 'ledger_entities',
        'accounts' => 'ledger_accounts',
        'journal_entries' => 'journal_entries',
        'journal_lines' => 'journal_lines',
        'account_balances' => 'account_balances',
    ],

    'models' => [
        'entity' => LedgerEntity::class,
        'account' => LedgerAccount::class,
        'journal_entry' => JournalEntry::class,
        'journal_line' => JournalLine::class,
        'account_balance' => AccountBalance::class,
    ],

    'currency' => [
        'base_currency' => env('LEDGER_BASE_CURRENCY', 'USD'),
        'require_base_amount_for_multi_currency' => true,
        'scale' => 8,
    ],

    'posting' => [
        'return_existing_on_duplicate_idempotency_key' => true,
        'allow_cross_entity_entries' => false,
        'prevent_negative_balances_by_default' => true,
        'lock_accounts_during_posting' => true,
        'allow_manual_entries_from_filament' => false,
        'allow_posted_metadata_updates' => false,
    ],

    'filament' => [
        'enabled' => true,
        'navigation_group' => 'Ledger',
        'navigation_icon' => 'heroicon-o-calculator',
    ],
];
```

### Important Config Options

[](#important-config-options)

`currency.base_currency`: Default base currency for your installation.

`currency.require_base_amount_for_multi_currency`: When enabled, multi-currency entries must include `base_amount` on every line.

`currency.scale`: Decimal precision used by the package. The default is `8`.

`posting.return_existing_on_duplicate_idempotency_key`: If enabled, reposting the same idempotency key with the same payload returns the existing entry.

`posting.allow_cross_entity_entries`: If disabled, all accounts in one entry must belong to the same ledger entity.

`posting.prevent_negative_balances_by_default`: If enabled, accounts cannot go negative unless the account has `allow_negative = true`.

`posting.lock_accounts_during_posting`: If enabled, affected accounts and balances are locked during posting.

`posting.allow_manual_entries_from_filament`: Controls whether users may create manual journal entries from Filament.

`posting.allow_posted_metadata_updates`: Controls whether posted entry metadata can be edited.

Database Model
--------------

[](#database-model)

### `ledger_entities`

[](#ledger_entities)

Represents an accounting book or entity. This can map to a company, tenant, organization, store, project, department, or any other book owner in the host application. The package does not care what the entity means.

Important fields:

- `uuid`
- `parent_id`
- `name`
- `code`
- `type`
- `base_currency`
- `metadata`
- `is_active`

### `ledger_accounts`

[](#ledger_accounts)

Represents generic accounts inside a ledger entity.

Supported account types:

- `asset`
- `liability`
- `equity`
- `revenue`
- `expense`

Important fields:

- `ledger_entity_id`
- `parent_id`
- `code`
- `name`
- `type`
- `normal_balance`
- `currency`
- `counterparty_type`
- `counterparty_id`
- `is_control_account`
- `is_postable`
- `allow_negative`
- `metadata`
- `is_active`

The package does not provide a chart of accounts. Your application creates whatever accounts it needs.

### `journal_entries`

[](#journal_entries)

Represents the header for a posted accounting event.

Important fields:

- `ledger_entity_id`
- `idempotency_key`
- `payload_hash`
- `reference_type`
- `reference_id`
- `description`
- `status`
- `posted_at`
- `reversed_at`
- `reversed_by_entry_id`
- `metadata`

### `journal_lines`

[](#journal_lines)

Represents the debit and credit lines for a journal entry.

Important fields:

- `journal_entry_id`
- `ledger_account_id`
- `direction`
- `amount`
- `currency`
- `base_amount`
- `exchange_rate`
- `memo`
- `metadata`

### `account_balances`

[](#account_balances)

Stores cached totals and balances for fast reads.

Important fields:

- `ledger_account_id`
- `debit_total`
- `credit_total`
- `balance`
- `currency`
- `last_journal_entry_id`

Do not update this table directly from application code. It is maintained by `JournalPostingService`.

Concepts
--------

[](#concepts)

### Ledger Entity

[](#ledger-entity)

A ledger entity is the book that owns accounts and journal entries.

Examples in a host application might be:

- A company
- A tenant
- A project
- A legal entity
- A regional book
- A separate internal accounting book

The package stores the generic entity only. Your app decides what it represents.

### Account

[](#account)

An account is a bucket where debit and credit movements are posted.

Examples:

- Cash
- Bank
- Accounts receivable
- Accounts payable
- Clearing
- Revenue
- Expense
- Equity

These are examples only. The package does not create or enforce a chart of accounts.

### Normal Balance

[](#normal-balance)

The normal balance decides how the cached `balance` is calculated.

Debit-normal accounts:

```
balance = debit_total - credit_total

```

Credit-normal accounts:

```
balance = credit_total - debit_total

```

Typical defaults:

- Assets: debit
- Expenses: debit
- Liabilities: credit
- Equity: credit
- Revenue: credit

### Journal Entry

[](#journal-entry)

A journal entry is an accounting event. It contains two or more lines. Total debits must equal total credits.

### Journal Line

[](#journal-line)

A journal line is one debit or credit movement against one account.

### Reversal

[](#reversal)

A reversal is a new posted journal entry with opposite lines. Reversal is how you correct posted records. Posted entries are not edited or deleted.

### Idempotency

[](#idempotency)

Idempotency prevents duplicate posting when a job, webhook, command, or API request is retried.

Every journal entry requires an `idempotencyKey`.

Quick Start
-----------

[](#quick-start)

```
use LedgerCore\Data\CreateAccountData;
use LedgerCore\Data\JournalEntryData;
use LedgerCore\Data\JournalLineData;
use LedgerCore\Enums\AccountType;
use LedgerCore\Enums\NormalBalance;
use LedgerCore\Services\LedgerManager;

$ledger = app(LedgerManager::class);

$entity = $ledger->createEntity(
    name: 'Main Book',
    code: 'MAIN',
    baseCurrency: 'USD',
);

$cash = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Cash',
    code: '1000',
    type: AccountType::ASSET,
    normalBalance: NormalBalance::DEBIT,
    currency: 'USD',
));

$equity = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Opening Balance Equity',
    code: '3000',
    type: AccountType::EQUITY,
    normalBalance: NormalBalance::CREDIT,
    currency: 'USD',
));

$entry = $ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'opening-cash:2026',
    description: 'Opening cash balance',
    lines: [
        JournalLineData::debit($cash->id, '1000.00000000', 'USD'),
        JournalLineData::credit($equity->id, '1000.00000000', 'USD'),
    ],
));

$balance = $ledger->getBalance($cash);
```

Creating Ledger Entities
------------------------

[](#creating-ledger-entities)

Use `LedgerManager::createEntity()`.

```
$entity = $ledger->createEntity(
    name: 'Main Book',
    code: 'MAIN',
    type: 'operating',
    baseCurrency: 'USD',
    parentId: null,
    metadata: [
        'external_id' => 'book_123',
    ],
);
```

The `type` field is a free string. It is intentionally not an enum because different applications organize books differently.

Creating Accounts
-----------------

[](#creating-accounts)

Use `LedgerManager::createAccount()` or inject `AccountService`.

```
use LedgerCore\Data\CreateAccountData;
use LedgerCore\Enums\AccountType;
use LedgerCore\Enums\NormalBalance;

$bank = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Bank Account',
    code: '1010',
    type: AccountType::ASSET,
    normalBalance: NormalBalance::DEBIT,
    currency: 'USD',
    parentId: null,
    isControlAccount: false,
    isPostable: true,
    allowNegative: false,
    metadata: [
        'note' => 'Primary settlement account',
    ],
));
```

### Parent Accounts And Postable Accounts

[](#parent-accounts-and-postable-accounts)

You may create account hierarchies:

```
$assets = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Assets',
    code: '1000',
    type: AccountType::ASSET,
    normalBalance: NormalBalance::DEBIT,
    isControlAccount: true,
    isPostable: false,
));

$cash = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Cash',
    code: '1010',
    type: AccountType::ASSET,
    normalBalance: NormalBalance::DEBIT,
    parentId: $assets->id,
    isPostable: true,
));
```

Post only to postable accounts. Control accounts are useful for grouping and reporting.

### Counterparty Fields

[](#counterparty-fields)

Accounts include optional generic counterparty fields:

```
counterpartyType: User::class,
counterpartyId: $user->id,
```

These fields are generic. They let the host application associate accounts with any model without the package knowing what that model means.

Posting Journal Entries
-----------------------

[](#posting-journal-entries)

Use `LedgerManager::post()` with `JournalEntryData`.

```
use LedgerCore\Data\JournalEntryData;
use LedgerCore\Data\JournalLineData;

$entry = $ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'receipt:2026-000001',
    referenceType: 'receipt',
    referenceId: '2026-000001',
    description: 'Receipt posted by host application',
    lines: [
        JournalLineData::debit(
            accountId: $cashAccount->id,
            amount: '250.00000000',
            currency: 'USD',
            memo: 'Cash received',
        ),
        JournalLineData::credit(
            accountId: $revenueAccount->id,
            amount: '250.00000000',
            currency: 'USD',
            memo: 'Revenue recognized',
        ),
    ],
    metadata: [
        'source' => 'api',
    ],
    postedAt: now(),
));
```

### What Happens During Posting

[](#what-happens-during-posting)

The posting service:

1. Starts a database transaction.
2. Checks the `idempotency_key`.
3. Computes and stores a payload hash.
4. Dispatches `JournalEntryPosting`.
5. Runs custom posting validators.
6. Validates that the entry has at least two lines.
7. Validates debit and credit totals.
8. Resolves and locks affected accounts when configured.
9. Ensures accounts are active and postable.
10. Ensures account currencies are compatible.
11. Creates the journal entry.
12. Creates the journal lines.
13. Updates cached account balances.
14. Dispatches `JournalEntryPosted`.
15. Commits the transaction.

If any step fails, nothing is partially posted.

### Single-Currency Entries

[](#single-currency-entries)

For single-currency entries, total debit `amount` must equal total credit `amount`.

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'single-currency-example',
    lines: [
        JournalLineData::debit($cash->id, '100.00000000', 'USD'),
        JournalLineData::credit($revenue->id, '100.00000000', 'USD'),
    ],
));
```

### Split Entries

[](#split-entries)

A single debit can be balanced by multiple credits:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'split-entry-example',
    lines: [
        JournalLineData::debit($cash->id, '1000.00000000', 'USD'),
        JournalLineData::credit($clearing->id, '980.00000000', 'USD'),
        JournalLineData::credit($feeRevenue->id, '20.00000000', 'USD'),
    ],
));
```

Multiple debits can also be balanced by one credit, or by multiple credits.

### Multi-Currency Entries

[](#multi-currency-entries)

When entries contain more than one currency and `require_base_amount_for_multi_currency` is enabled, every line must include `baseAmount`.

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'multi-currency-example',
    lines: [
        JournalLineData::debit(
            accountId: $eurCash->id,
            amount: '100.00000000',
            currency: 'EUR',
            baseAmount: '108.00000000',
            exchangeRate: '1.080000000000',
        ),
        JournalLineData::credit(
            accountId: $usdClearing->id,
            amount: '108.00000000',
            currency: 'USD',
            baseAmount: '108.00000000',
            exchangeRate: '1.000000000000',
        ),
    ],
));
```

For multi-currency entries, debit `baseAmount` totals must equal credit `baseAmount` totals.

Opening Balances
----------------

[](#opening-balances)

Opening balances are normal journal entries. Do not insert rows directly into `account_balances`.

```
$ledger->postOpeningBalance(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'opening:cash:2026',
    description: 'Opening cash balance',
    lines: [
        JournalLineData::debit($cash->id, '100000.00000000', 'USD'),
        JournalLineData::credit($openingBalanceEquity->id, '100000.00000000', 'USD'),
    ],
));
```

Internally, `postOpeningBalance()` calls `post()`. The same validation, idempotency, immutability, and balance rules apply.

Idempotency
-----------

[](#idempotency-1)

Every journal entry must have a unique `idempotencyKey`.

```
$data = new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'external-event:' . $eventUuid,
    lines: [
        JournalLineData::debit($cash->id, '50.00000000', 'USD'),
        JournalLineData::credit($revenue->id, '50.00000000', 'USD'),
    ],
);

$first = $ledger->post($data);
$second = $ledger->post($data);

// Same entry is returned when the payload is identical.
$first->id === $second->id;
```

If the same key is used with a different payload, the package throws `IdempotencyConflictException`.

```
use LedgerCore\Exceptions\IdempotencyConflictException;

try {
    $ledger->post($differentPayloadWithSameKey);
} catch (IdempotencyConflictException $exception) {
    report($exception);
}
```

### Choosing Idempotency Keys

[](#choosing-idempotency-keys)

Good keys are stable, unique, and based on the business event in the host app.

Examples:

```
receipt:2026-000001
invoice-paid:8f5c8e93-8b8f-48c1-9d7e-6e5a0b15c1a1
webhook:stripe:evt_123
opening-balance:main-book:2026
manual-adjustment:2026-000010

```

Avoid keys based only on timestamps or random values when retry safety matters.

Reversals
---------

[](#reversals)

Posted entries cannot be edited or deleted. To correct a posted entry, reverse it.

```
$reversal = $ledger->reverse(
    entry: $entry,
    reason: 'Incorrect amount posted',
);
```

The reversal service:

1. Locks the original entry.
2. Ensures it is posted.
3. Creates a new posted journal entry.
4. Uses opposite debit and credit directions.
5. Links the reversal to the original entry.
6. Marks the original entry as reversed.
7. Updates balances through normal posting.
8. Dispatches `JournalEntryReversed`.

Original entry:

```
Dr Cash      100.00000000
Cr Revenue   100.00000000

```

Reversal entry:

```
Dr Revenue   100.00000000
Cr Cash      100.00000000

```

Balances
--------

[](#balances)

Balances are stored in `account_balances` for fast reads.

Use:

```
$balance = $ledger->getBalance($cash);
```

Or inject `BalanceService`:

```
use LedgerCore\Services\BalanceService;

$balance = app(BalanceService::class)->getBalance($cash);
```

### Balance Formula

[](#balance-formula)

For debit-normal accounts:

```
balance = debit_total - credit_total

```

For credit-normal accounts:

```
balance = credit_total - debit_total

```

### Negative Balances

[](#negative-balances)

By default, the package prevents negative balances when `prevent_negative_balances_by_default` is enabled.

Allow a specific account to go negative:

```
$account = $ledger->createAccount(new CreateAccountData(
    ledgerEntityId: $entity->id,
    name: 'Temporary Clearing',
    type: AccountType::ASSET,
    normalBalance: NormalBalance::DEBIT,
    allowNegative: true,
));
```

Reports
-------

[](#reports)

Use `LedgerManager` for common reports:

```
$trialBalance = $ledger->getTrialBalance($entity->id);

$statement = $ledger->getStatement(
    accountId: $cash->id,
    from: now()->startOfMonth(),
    to: now()->endOfMonth(),
);
```

Use `LedgerReportService` for more control:

```
use LedgerCore\Services\LedgerReportService;

$reports = app(LedgerReportService::class);

$trialBalance = $reports->trialBalance(
    entityId: $entity->id,
    from: now()->startOfYear(),
    to: now(),
);

$generalLedger = $reports->generalLedger($entity->id, [
    'account_id' => $cash->id,
    'from' => now()->startOfMonth(),
    'to' => now()->endOfMonth(),
    'reference_type' => 'receipt',
    'per_page' => 100,
]);

$statement = $reports->accountStatement(
    accountId: $cash->id,
    from: now()->subMonth(),
    to: now(),
);

$balances = $reports->accountBalances($entity->id, [
    'currency' => 'USD',
]);
```

### Trial Balance

[](#trial-balance)

Returns account-level totals:

- account id
- code
- name
- type
- currency
- debit total
- credit total
- balance

### General Ledger

[](#general-ledger)

Returns journal lines with entry and account information, filterable by:

- account
- date range
- reference type
- reference id

### Account Statement

[](#account-statement)

Returns movements for one account over a period.

Filament Integration
--------------------

[](#filament-integration)

Filament support is optional. The package can run without Filament installed.

If Filament is installed, register the plugin:

```
use Filament\Panel;
use LedgerCore\LedgerCorePlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugin(LedgerCorePlugin::make());
}
```

The plugin registers:

- `LedgerEntityResource`
- `LedgerAccountResource`
- `JournalEntryResource`
- `TrialBalancePage`
- `GeneralLedgerPage`
- `AccountStatementPage`
- `LedgerStatsWidget`
- `AccountBalanceOverviewWidget`

### Filament Safety

[](#filament-safety)

- Destructive actions are omitted or require confirmation.
- Posted journal entries are not editable unless metadata updates are explicitly allowed.
- Posted journal lines are read-only.
- Manual journal creation is disabled by default.
- Account `type`, `normal_balance`, and `currency` are disabled when the account already has lines.

Enable manual journal creation only if your operators understand double-entry posting:

```
'posting' => [
    'allow_manual_entries_from_filament' => true,
],
```

Host Application Use Cases
--------------------------

[](#host-application-use-cases)

The package does not know your domain. The host app should create posting services or recipes that convert business events into journal entries.

### Use Case: Generic Cash Receipt

[](#use-case-generic-cash-receipt)

Host application concept:

```
An external event says money was received.

```

Ledger entry:

```
Dr Cash
Cr Revenue or Clearing

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'cash-received:' . $event->uuid,
    referenceType: 'cash_received',
    referenceId: (string) $event->id,
    description: 'Cash received',
    lines: [
        JournalLineData::debit($cashAccountId, '500.00000000', 'USD'),
        JournalLineData::credit($clearingAccountId, '500.00000000', 'USD'),
    ],
));
```

### Use Case: Fee Split

[](#use-case-fee-split)

Host application concept:

```
A gross amount is received, part is principal and part is fee income.

```

Ledger entry:

```
Dr Cash
Cr Clearing
Cr Fee Revenue

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'gross-receipt:' . $event->uuid,
    referenceType: 'gross_receipt',
    referenceId: (string) $event->id,
    description: 'Gross receipt with fee split',
    lines: [
        JournalLineData::debit($cashAccountId, '1000.00000000', 'USD'),
        JournalLineData::credit($clearingAccountId, '980.00000000', 'USD'),
        JournalLineData::credit($feeRevenueAccountId, '20.00000000', 'USD'),
    ],
));
```

### Use Case: Expense Recognition

[](#use-case-expense-recognition)

Ledger entry:

```
Dr Expense
Cr Cash or Payable

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'expense:' . $expense->uuid,
    referenceType: 'expense',
    referenceId: (string) $expense->id,
    description: 'Expense recognized',
    lines: [
        JournalLineData::debit($expenseAccountId, '75.00000000', 'USD'),
        JournalLineData::credit($cashAccountId, '75.00000000', 'USD'),
    ],
));
```

### Use Case: Liability Settlement

[](#use-case-liability-settlement)

Ledger entry:

```
Dr Liability
Cr Cash

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'liability-settlement:' . $settlement->uuid,
    referenceType: 'liability_settlement',
    referenceId: (string) $settlement->id,
    description: 'Liability settled',
    lines: [
        JournalLineData::debit($liabilityAccountId, '300.00000000', 'USD'),
        JournalLineData::credit($cashAccountId, '300.00000000', 'USD'),
    ],
));
```

### Use Case: Currency Exchange

[](#use-case-currency-exchange)

Host application concept:

```
The application exchanges one currency for another and may recognize spread or fee income.

```

Important ledger design:

- Use separate accounts per currency when possible.
- Provide `baseAmount` and `exchangeRate` for multi-currency entries.
- Keep exchange quote, rate source, and execution details in the host application.
- Store only generic `referenceType`, `referenceId`, and metadata in the ledger entry.

Example:

```
A user gives 1,000 USD.
The app gives 4,800 LYD.
The base currency is USD.
The 4,800 LYD output is valued at 990 USD.
The app recognizes 10 USD exchange revenue.

```

Ledger entry:

```
Dr USD Cash              1,000 USD base 1,000 USD
Cr LYD Cash              4,800 LYD base   990 USD
Cr Exchange Revenue         10 USD base    10 USD

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'currency-exchange:' . $exchange->uuid,
    referenceType: 'currency_exchange',
    referenceId: (string) $exchange->id,
    description: 'Currency exchange executed',
    lines: [
        JournalLineData::debit(
            accountId: $usdCashAccountId,
            amount: '1000.00000000',
            currency: 'USD',
            baseAmount: '1000.00000000',
            exchangeRate: '1.000000000000',
            memo: 'USD received',
        ),
        JournalLineData::credit(
            accountId: $lydCashAccountId,
            amount: '4800.00000000',
            currency: 'LYD',
            baseAmount: '990.00000000',
            exchangeRate: '0.206250000000',
            memo: 'LYD delivered',
        ),
        JournalLineData::credit(
            accountId: $exchangeRevenueAccountId,
            amount: '10.00000000',
            currency: 'USD',
            baseAmount: '10.00000000',
            exchangeRate: '1.000000000000',
            memo: 'Exchange spread',
        ),
    ],
    metadata: [
        'rate_source' => 'host_application',
        'quoted_rate' => '4.800000000000',
    ],
));
```

The ledger package does not contain an exchange model, quote engine, rate provider, or settlement workflow. Those belong in the host app.

### Use Case: Order Workflow

[](#use-case-order-workflow)

Host application concept:

```
An order moves through authorization, payment capture, fulfillment, revenue recognition, and possible refund.

```

The host app can post one journal entry per meaningful accounting event.

#### Order Payment Captured

[](#order-payment-captured)

Ledger entry:

```
Dr Cash or Payment Clearing
Cr Customer Deposits or Deferred Revenue

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'order-payment-captured:' . $order->uuid,
    referenceType: 'order',
    referenceId: (string) $order->id,
    description: 'Order payment captured',
    lines: [
        JournalLineData::debit(
            accountId: $paymentClearingAccountId,
            amount: $order->total_amount,
            currency: $order->currency,
        ),
        JournalLineData::credit(
            accountId: $customerDepositAccountId,
            amount: $order->total_amount,
            currency: $order->currency,
        ),
    ],
));
```

#### Order Fulfilled And Revenue Recognized

[](#order-fulfilled-and-revenue-recognized)

Ledger entry:

```
Dr Customer Deposits or Deferred Revenue
Cr Sales Revenue

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'order-fulfilled:' . $order->uuid,
    referenceType: 'order',
    referenceId: (string) $order->id,
    description: 'Order fulfilled and revenue recognized',
    lines: [
        JournalLineData::debit(
            accountId: $customerDepositAccountId,
            amount: $order->total_amount,
            currency: $order->currency,
        ),
        JournalLineData::credit(
            accountId: $salesRevenueAccountId,
            amount: $order->total_amount,
            currency: $order->currency,
        ),
    ],
));
```

#### Order Refund

[](#order-refund)

Ledger entry:

```
Dr Refunds or Sales Returns
Cr Cash or Payment Clearing

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'order-refund:' . $refund->uuid,
    referenceType: 'order_refund',
    referenceId: (string) $refund->id,
    description: 'Order refund posted',
    lines: [
        JournalLineData::debit(
            accountId: $salesReturnsAccountId,
            amount: $refund->amount,
            currency: $refund->currency,
        ),
        JournalLineData::credit(
            accountId: $paymentClearingAccountId,
            amount: $refund->amount,
            currency: $refund->currency,
        ),
    ],
));
```

This package does not decide when an order is fulfilled or whether revenue should be recognized at capture, shipment, delivery, or acceptance. The host application owns that policy.

### Use Case: Marketplace Or Platform Payout

[](#use-case-marketplace-or-platform-payout)

Host application concept:

```
A platform collects funds, keeps a fee, and pays out the remaining amount to another party.

```

Ledger entry when funds are collected:

```
Dr Cash
Cr Payable To Counterparty
Cr Platform Fee Revenue

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'platform-collection:' . $collection->uuid,
    referenceType: 'platform_collection',
    referenceId: (string) $collection->id,
    description: 'Platform collection with fee',
    lines: [
        JournalLineData::debit($cashAccountId, '1000.00000000', 'USD'),
        JournalLineData::credit($counterpartyPayableAccountId, '950.00000000', 'USD'),
        JournalLineData::credit($platformFeeRevenueAccountId, '50.00000000', 'USD'),
    ],
));
```

Ledger entry when payout is sent:

```
Dr Payable To Counterparty
Cr Cash

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'platform-payout:' . $payout->uuid,
    referenceType: 'platform_payout',
    referenceId: (string) $payout->id,
    description: 'Platform payout sent',
    lines: [
        JournalLineData::debit($counterpartyPayableAccountId, '950.00000000', 'USD'),
        JournalLineData::credit($cashAccountId, '950.00000000', 'USD'),
    ],
));
```

### Use Case: Customer Prepayment And Later Application

[](#use-case-customer-prepayment-and-later-application)

Host application concept:

```
A customer pays before the product or service is delivered.

```

When money is received:

```
Dr Cash
Cr Customer Deposits

```

When the deposit is applied:

```
Dr Customer Deposits
Cr Revenue

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'prepayment-received:' . $payment->uuid,
    referenceType: 'prepayment',
    referenceId: (string) $payment->id,
    description: 'Customer prepayment received',
    lines: [
        JournalLineData::debit($cashAccountId, $payment->amount, $payment->currency),
        JournalLineData::credit($customerDepositAccountId, $payment->amount, $payment->currency),
    ],
));

$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'prepayment-applied:' . $application->uuid,
    referenceType: 'prepayment_application',
    referenceId: (string) $application->id,
    description: 'Customer prepayment applied',
    lines: [
        JournalLineData::debit($customerDepositAccountId, $application->amount, $application->currency),
        JournalLineData::credit($revenueAccountId, $application->amount, $application->currency),
    ],
));
```

### Use Case: Internal Reclassification

[](#use-case-internal-reclassification)

Host application concept:

```
An amount was posted to the right side of the ledger but needs to move between accounts.

```

Ledger entry:

```
Dr Correct Account
Cr Original Account

```

Code:

```
$ledger->post(new JournalEntryData(
    ledgerEntityId: $entity->id,
    idempotencyKey: 'reclassification:' . $adjustment->uuid,
    referenceType: 'reclassification',
    referenceId: (string) $adjustment->id,
    description: 'Internal account reclassification',
    lines: [
        JournalLineData::debit($correctAccountId, '125.00000000', 'USD'),
        JournalLineData::credit($originalAccountId, '125.00000000', 'USD'),
    ],
    metadata: [
        'approved_by' => $adjustment->approved_by,
    ],
));
```

Use a reversal instead when the original posted journal entry itself is wrong and should be explicitly negated.

### Use Case: Host-Specific Workflow Service

[](#use-case-host-specific-workflow-service)

Create a service in your application:

```
namespace App\Services;

use App\Models\Receipt;
use LedgerCore\Data\JournalEntryData;
use LedgerCore\Data\JournalLineData;
use LedgerCore\Services\LedgerManager;

final readonly class ReceiptPostingService
{
    public function __construct(private LedgerManager $ledger)
    {
    }

    public function postReceipt(Receipt $receipt): void
    {
        $this->ledger->post(new JournalEntryData(
            ledgerEntityId: $receipt->ledger_entity_id,
            idempotencyKey: 'receipt:' . $receipt->uuid,
            referenceType: 'receipt',
            referenceId: (string) $receipt->id,
            description: 'Receipt posted',
            lines: [
                JournalLineData::debit(
                    accountId: $receipt->cash_account_id,
                    amount: $receipt->amount,
                    currency: $receipt->currency,
                ),
                JournalLineData::credit(
                    accountId: $receipt->clearing_account_id,
                    amount: $receipt->amount,
                    currency: $receipt->currency,
                ),
            ],
        ));
    }
}
```

This service belongs in your application, not in `ledger-core`.

Extending The Package
---------------------

[](#extending-the-package)

### Custom Posting Validator

[](#custom-posting-validator)

Bind `PostingValidatorContract` in your application:

```
use Illuminate\Support\ServiceProvider;
use LedgerCore\Contracts\PostingValidatorContract;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            PostingValidatorContract::class,
            \App\Ledger\AppPostingValidator::class,
        );
    }
}
```

Example validator:

```
namespace App\Ledger;

use LedgerCore\Contracts\PostingValidatorContract;
use LedgerCore\Data\JournalEntryData;
use LedgerCore\Exceptions\LedgerException;

final class AppPostingValidator implements PostingValidatorContract
{
    public function validate(JournalEntryData $entry): void
    {
        if ($entry->idempotencyKey === '') {
            throw new LedgerException('Idempotency key is required.');
        }

        if (count($entry->lines) > 100) {
            throw new LedgerException('Journal entry has too many lines.');
        }
    }
}
```

### Custom Account Resolver

[](#custom-account-resolver)

Bind `AccountResolverContract` if your app needs custom account lookup.

```
use LedgerCore\Contracts\AccountResolverContract;

$this->app->bind(AccountResolverContract::class, AppAccountResolver::class);
```

### Currency Converter

[](#currency-converter)

Bind `CurrencyConverterContract` if your app wants package-level conversion utilities.

```
use LedgerCore\Contracts\CurrencyConverterContract;

$this->app->bind(CurrencyConverterContract::class, AppCurrencyConverter::class);
```

Posting itself does not guess exchange rates. Your app should provide `baseAmount` and `exchangeRate` when needed.

### Custom Models

[](#custom-models)

You may replace model classes through config:

```
'models' => [
    'account' => App\Models\LedgerAccount::class,
],
```

Custom models should extend the package models unless you are deliberately replacing behavior.

Events
------

[](#events)

The package dispatches Laravel events:

### `JournalEntryPosting`

[](#journalentryposting)

Dispatched before a journal entry is created, after idempotency has been checked.

```
use LedgerCore\Events\JournalEntryPosting;

Event::listen(JournalEntryPosting::class, function (JournalEntryPosting $event): void {
    logger()->info('Posting journal entry', [
        'idempotency_key' => $event->data->idempotencyKey,
    ]);
});
```

### `JournalEntryPosted`

[](#journalentryposted)

Dispatched after the journal entry, lines, and balances have been created.

```
use LedgerCore\Events\JournalEntryPosted;

Event::listen(JournalEntryPosted::class, function (JournalEntryPosted $event): void {
    logger()->info('Journal entry posted', [
        'entry_id' => $event->entry->id,
    ]);
});
```

### `JournalEntryReversed`

[](#journalentryreversed)

Dispatched after a reversal entry is posted and the original entry is marked reversed.

```
use LedgerCore\Events\JournalEntryReversed;

Event::listen(JournalEntryReversed::class, function (JournalEntryReversed $event): void {
    logger()->info('Journal entry reversed', [
        'original_id' => $event->original->id,
        'reversal_id' => $event->reversal->id,
    ]);
});
```

Exceptions
----------

[](#exceptions)

Common exceptions:

- `LedgerException`
- `UnbalancedJournalEntryException`
- `DuplicateJournalEntryException`
- `IdempotencyConflictException`
- `AccountCurrencyMismatchException`
- `AccountNotPostableException`
- `InsufficientBalanceException`
- `InvalidReversalException`

Example:

```
use LedgerCore\Exceptions\AccountCurrencyMismatchException;
use LedgerCore\Exceptions\InsufficientBalanceException;
use LedgerCore\Exceptions\UnbalancedJournalEntryException;

try {
    $ledger->post($entryData);
} catch (UnbalancedJournalEntryException $exception) {
    // Show validation feedback or fail the job permanently.
} catch (AccountCurrencyMismatchException $exception) {
    // Fix account mapping or currency selection.
} catch (InsufficientBalanceException $exception) {
    // Stop the workflow or route it to review.
}
```

Testing
-------

[](#testing)

From the package directory:

```
composer install
vendor/bin/pest
```

The test suite covers:

- Entity creation
- Account creation
- Balanced posting
- Unbalanced posting rejection
- Inactive account rejection
- Non-postable account rejection
- Currency mismatch rejection
- Debit-normal balances
- Credit-normal balances
- Idempotent reposting
- Idempotency conflicts
- Reversal posting
- Opposite reversal lines
- Balance updates after reversal
- Posted line immutability
- Account currency immutability after posting
- Trial balance reports
- Account statement reports
- Opening balance posting

Operational Guidance
--------------------

[](#operational-guidance)

### Do Not Update Balances Manually

[](#do-not-update-balances-manually)

Never run application code like this:

```
$account->balance()->update(['balance' => '100.00000000']);
```

Always post a journal entry.

### Do Not Edit Posted Lines

[](#do-not-edit-posted-lines)

Posted lines are the audit record. If something is wrong, reverse the entry and post a corrected entry.

### Keep Account Mapping In Your App

[](#keep-account-mapping-in-your-app)

Your app should decide which accounts are used for each workflow.

Good pattern:

```
$accounts = app(AccountMappingService::class)->forReceipt($receipt);

$ledger->post(new JournalEntryData(
    ledgerEntityId: $receipt->ledger_entity_id,
    idempotencyKey: 'receipt:' . $receipt->uuid,
    lines: [
        JournalLineData::debit($accounts->cash, $receipt->amount, $receipt->currency),
        JournalLineData::credit($accounts->clearing, $receipt->amount, $receipt->currency),
    ],
));
```

Avoid putting receipt, invoice, transfer, wallet, or provider logic inside the ledger package.

### Use Database Transactions Around Host Workflow State

[](#use-database-transactions-around-host-workflow-state)

If your application updates business state and posts to the ledger, wrap both in one transaction when possible.

```
DB::transaction(function () use ($receipt, $ledger): void {
    $receipt->markAsPosted();

    $ledger->post(new JournalEntryData(
        ledgerEntityId: $receipt->ledger_entity_id,
        idempotencyKey: 'receipt:' . $receipt->uuid,
        lines: [
            JournalLineData::debit($receipt->cash_account_id, $receipt->amount, $receipt->currency),
            JournalLineData::credit($receipt->clearing_account_id, $receipt->amount, $receipt->currency),
        ],
    ));
});
```

The ledger posting service also runs its own transaction. Laravel safely nests transactions using savepoints where supported.

### Queue And Webhook Safety

[](#queue-and-webhook-safety)

For jobs and webhooks:

- Use a deterministic idempotency key.
- Catch idempotency conflicts.
- Retry only transient database failures.
- Treat unbalanced entries as application bugs.
- Log the `reference_type`, `reference_id`, and `idempotency_key`.

FAQ
---

[](#faq)

### Does this package create a chart of accounts?

[](#does-this-package-create-a-chart-of-accounts)

No. The package provides account storage and posting rules. Your application creates the accounts it needs.

### Can I use this package for wallets?

[](#can-i-use-this-package-for-wallets)

Yes, but wallet concepts belong in your application. The package should only receive journal entries against generic accounts.

### Can I delete a posted journal entry?

[](#can-i-delete-a-posted-journal-entry)

No. Reverse it.

### Can I edit a posted amount?

[](#can-i-edit-a-posted-amount)

No. Reverse the entry and post a corrected one.

### Can I update cached balances directly?

[](#can-i-update-cached-balances-directly)

No. Cached balances are maintained by `JournalPostingService`.

### Can one journal entry include accounts from multiple entities?

[](#can-one-journal-entry-include-accounts-from-multiple-entities)

Only if `posting.allow_cross_entity_entries` is enabled. The default is `false`.

### Can accounts have no currency?

[](#can-accounts-have-no-currency)

Yes. A null account currency means the package will accept line currencies for that account.

### Can I use UUIDs externally?

[](#can-i-use-uuids-externally)

Yes. Core tables include UUID columns for public references while keeping integer primary keys for database relationships.

### How should I store business references?

[](#how-should-i-store-business-references)

Use `reference_type` and `reference_id`.

```
referenceType: 'receipt',
referenceId: (string) $receipt->id,
```

These are intentionally strings so the host application can reference any model or external event.

### Why are amounts strings?

[](#why-are-amounts-strings)

PHP floats are unsafe for money. This package uses decimal strings, decimal database columns, and `bcmath`.

Minimal Service Reference
-------------------------

[](#minimal-service-reference)

### `LedgerManager`

[](#ledgermanager)

```
$ledger->createEntity(...);
$ledger->createAccount(CreateAccountData $data);
$ledger->post(JournalEntryData $data);
$ledger->postOpeningBalance(JournalEntryData $data);
$ledger->reverse(JournalEntry $entry, ?string $reason = null);
$ledger->getBalance(LedgerAccount|int|string $account);
$ledger->getStatement($accountId, $from = null, $to = null);
$ledger->getTrialBalance($entityId, $from = null, $to = null);
```

### `AccountService`

[](#accountservice)

```
$accounts->create(CreateAccountData $data);
$accounts->findOrFail($id);
$accounts->ensurePostable($account);
$accounts->ensureCurrencyCompatible($account, $currency);
```

### `JournalPostingService`

[](#journalpostingservice)

```
$posting->post(JournalEntryData $data);
```

### `BalanceService`

[](#balanceservice)

```
$balances->initializeBalance($account);
$balances->applyLine($line);
$balances->getBalance($account);
$balances->assertSufficientBalance($account, $amount);
```

### `ReversalService`

[](#reversalservice)

```
$reversals->reverse($entry, $reason);
```

### `LedgerReportService`

[](#ledgerreportservice)

```
$reports->trialBalance($entityId, $from = null, $to = null);
$reports->generalLedger($entityId, $filters);
$reports->accountStatement($accountId, $from = null, $to = null);
$reports->accountBalances($entityId, $filters);
```

License
-------

[](#license)

MIT

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance84

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

8

Last Release

83d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/124089971?v=4)[ Othman Haba (ง'̀-'́)ง ](/maintainers/OthmanHaba)[@OthmanHaba](https://github.com/OthmanHaba)

---

Top Contributors

[![OthmanHaba](https://avatars.githubusercontent.com/u/124089971?v=4)](https://github.com/OthmanHaba "OthmanHaba (11 commits)")

---

Tags

accountingbookkeepingdouble-entryfilamentlaravelledgerphplaravelAccountingdouble entryfilamentbookkeepingledger

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/othmanhaba-ledger-core/health.svg)

```
[![Health](https://phpackages.com/badges/othmanhaba-ledger-core/health.svg)](https://phpackages.com/packages/othmanhaba-ledger-core)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)[wearepixel/laravel-cart

A cart implementation for Laravel

1374.8k](/packages/wearepixel-laravel-cart)[promethys/revive

A 'RecycleBin' page where users can restore or delete permanently soft-deleted models.

163.1k1](/packages/promethys-revive)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
