abivia/ledger
Abivia Ledger is a Laravel package for double-entry financial record keeping, providing an Eloquent-friendly foundation for organizing accounts and recording ledger transactions. Use it to add accounting-style tracking and balance management to PHP applications.
Install the package and its required bcmath extension, then publish the migrations:
composer require abivia/ledger
php artisan vendor:publish --provider="Abivia\Ledger\LedgerServiceProvider"
php artisan migrate
Open config/ledger.php first — that is where you set the database connection, table prefix, and any multi-domain behavior. Skim the ledger models under Abivia\Ledger\Models (LedgerAccount, LedgerJournal, LedgerEntry, LedgerTransaction, LedgerRoot) so you understand that accounts and journals are entities referenced by short human codes, not IDs.
Every entry point to the package goes through the Ledger facade. The canonical "hello world" is to create a root ledger, a chart of accounts, a journal, and a balanced transaction:
use Abivia\Ledger\Facades\Ledger;
$root = Ledger::createRoot([
'name' => 'Acme Corp',
'currency' => 'CAD',
]);
Ledger::addAccount(['code' => '1000', 'name' => 'Cash', 'type' => 'asset']);
Ledger::addAccount(['code' => '4000', 'name' => 'Sales Revenue', 'type' => 'revenue']);
Ledger::addJournal(['code' => 'GJ', 'name' => 'General Journal']);
$txn = Ledger::addTransaction([
'journal' => 'GJ',
'date' => now()->toDateString(),
'description' => 'Cash sale',
]);
Ledger::addEntry($txn, ['code' => '1000', 'debit' => '100.00']);
Ledger::addEntry($txn, ['code' => '4000', 'credit' => '100.00']);
$txn->post();
That is the first use case worth wiring up on day one — a tiny seed script or artisan command that stands up a chart of accounts and posts one balanced transaction. Everything else (subledgers, reports, reversals) builds on that shape.
Wrap the facade in your own service. The facade is global and assumes a single active root/domain at a time. For anything non-trivial, create app/Accounting/LedgerService.php around it and inject that. It keeps controllers thin and gives you a single place to centralize the setDomain() / setRoot() behavior for multi-tenant installs.
Treat accounts and journals as seed data. Put the chart of accounts and journal definitions into a seeder or migration rather than creating them at runtime. Lookup is by code, so downstream code should reference '1000', not an auto-increment ID. This lets you keep business logic decoupled from database rows.
Build transactions entry-by-entry, then validate. The pattern that scales is: create the transaction header, append entries with addEntry, then call post(). Between the adds you can keep the transaction in a "raw" state and only promote it when a balance check passes. Do not sprinkle post() calls inside loops.
Model subledger references on the entry code. For A/R and A/P, abivia/ledger supports an extended code on the entry (for example a customer code appended to the control account). Keep that convention in one constant so your reconciliation queries match your posting code exactly.
Query balances instead of summing entries yourself. The package exposes balance lookups keyed by account code and date range. Use those for trial balances, aging schedules, and dashboards; do not roll your own SUM(debit - credit) against ledger_entries unless you also honor the package's domain and journal filters.
Pair every posted correction with a reversing transaction. Posted entries are effectively immutable. When a user "edits" an invoice, write a reversing transaction against the original journal and post a fresh one. This is a pattern, not a hard requirement, but doing it any other way will produce a ledger that cannot be audited.
Integrate through jobs, not requests. Posting a transaction touches multiple tables and runs balance validation. Dispatch a PostJournalEntry job rather than posting inside an HTTP handler, especially for imports, invoice finalization, and payment reconciliation.
bcmath, and passing a PHP float will produce rounding drift you will only notice at period close. Normalize every amount with number_format($v, 2, '.', '') (or a bcadd) before handing it to addEntry.post(), not at addEntry. If you write a helper that adds a debit and forgets the matching credit, the error surfaces late. Assert the debit total equals the credit total in your wrapper before calling post(), and log the offending transaction payload when the assertion fails.code is a hierarchy delimiter. Codes such as 1000.01 nest under 1000. If you are generating codes programmatically, confirm the separator matches the package's expectation, otherwise balance() on the parent will silently return zero.migrate:fresh --seed rather than trying to unpost by hand.addTransaction accepts an explicit date but does not interpret fiscal periods. If you need "post into period 2024-03 only", enforce it in your service layer — the package will happily accept a backdated transaction and shift every historic balance report you have.post() throws, dump the LedgerTransaction and its LedgerEntry relations through Eloquent; the facade's exception messages are terse. The state field on the transaction tells you how far it got.migrate on a shared database, because renaming later means hand-editing schema.How can I help you explore Laravel packages today?