Version 1.0.0

Double-Entry Ledger for PHP

Product price:
$0.00 USD
0Composer dependencies
8.1+PHP version required
2,313Lines of PHP across 11 files
54Test assertions, all passing

About Double-Entry Ledger for PHP

A double-entry accounting ledger in plain PHP. It keeps accounts and journal entries in a SQLite file, refuses to write an entry whose debits do not equal its credits, and computes the trial balance, general ledger, income statement and balance sheet from the journal every time you ask for one.

Eleven PHP files, 2,313 lines, no Composer, no framework, no server. Unzip it and run php ledger.php init.

The one rule, enforced where it counts

Most small accounting code enforces the balance rule in a form somewhere, or in a validator that runs before save, or in a comment. This one enforces it in Journal::post(), and it does it twice.

The first check sums the input array before a database transaction is opened, so an unbalanced entry never reaches SQLite at all. The second inserts the rows, then sums them straight back out of the database inside the same transaction and compares before COMMIT. If those disagree, the transaction rolls back.

The second check is nearly always redundant, and it is there anyway, because the first one trusts an array in memory and the second one trusts the file on disk. Those are two different claims and only one of them is what your accountant cares about.

Underneath both, the schema carries its own constraints: an amount cannot be negative, and a line cannot hold a debit and a credit at the same time, or neither. Also rejected at write time, each with a message that says what to do instead: an entry with fewer than two lines, an account code that is not in the chart, an account you archived, an entry of all zeroes, and a date that is not a real YYYY-MM-DD.

php ledger.php post --date=2026-01-16 --memo="Off by ten" 
    --debit=1000:100.00 --credit=4000:90.00

Error: Entry rejected: debits 100.00 do not equal credits 90.00 (difference 10.00).

Nothing written. Exit code 1.

No balance column, anywhere

There is no cached total on any table. Every figure in every report is a SUM over journal_lines, computed at the moment you ask for it.

That is a deliberate trade and it costs something. A stored running total is faster to read, and it is also a second source of truth. The first time it drifts from the entries it claims to summarise, the ledger is quietly wrong and nothing in it tells you which number to believe. Recomputing costs a scan of the lines and buys the guarantee that a report and the journal cannot disagree.

The test suite does not take that on trust. It reads PRAGMA table_info for every table and fails if a column named balance, total or running ever appears in the schema.

If you would rather have the cache — a journal large enough that the scan matters, a reporting endpoint under load — then this is the wrong ledger and you should say so now rather than discover it in month nine.

Money is an integer, all the way down

Amounts are minor units in a PHP int. 12345 means 123.45. There is no float anywhere near an amount in any shipped file, and test.php proves it the only way that stays true over time: it greps the source for floatval, (float), (double), number_format and round(, and fails if it finds one.

Money::parse() reads the decimal string with integer arithmetic, so 0.10 plus 0.20 is exactly 30 and not 0.30000000000000004. It accepts thousands separators, so "1,234.5" becomes 123450. It rejects a third decimal place instead of rounding it away, because silently losing a hundredth of a currency unit is how a set of books stops tying out, six months before anyone notices.

Under --json, amounts stay as minor-unit integers rather than being formatted for you. Converting them on the way out would be the one place a float could get in.

The four reports

Trial balance lists every account with movement, debit balances in one column and credit balances in the other, with totals that are equal because every entry that produced them was.

General ledger for one account gives an opening balance, every line that touched it in the window, a running balance carried down the page, and a closing balance — in the account’s normal direction, so an asset reads positive when it should.

Income statement covers any date window you name, income less expenses.

Balance sheet shows assets against liabilities plus equity as of a date. The part worth knowing about: this ledger does not force a year-end close, so income and expense accounts still carry the period’s activity, and the balance sheet shows their net as a separate equity line called Current earnings. It is the same number the income statement reports for the same window. Post a closing entry into 3900 Retained Earnings and that line falls to zero while the amount appears under 3900 instead. Equity totals the same either way, and the test suite posts a closing entry and asserts precisely that.

Entries are append-only

You cannot edit or delete an entry, which is the only arrangement in which an audit trail is worth having. To correct entry #7, post a new entry that reverses it — the same lines with debit and credit swapped — and then post the right one. All three stay visible in entries:list. That is the feature, not a missing one.

The CLI

php ledger.php init
php ledger.php post --date=2026-01-15 --memo="Invoice 2026-001" --ref=INV-2026-001 
    --debit=1100:9750.00 --credit=4000:9750.00 
    --debit=5000:4100.00 --credit=1200:4100.00
php ledger.php report:trial-balance

That third command is one entry with four lines: the invoice and the cost of what it shipped, posted together, balanced as a whole rather than in pairs.

There is also accounts:list, accounts:add, accounts:archive, entries:list, entries:show, report:ledger, report:income and report:balance-sheet. Two options work on everything: --db=FILE picks the ledger file, --json prints the structure instead of a table.

ledger.php refuses to run under any SAPI except CLI, checks for PHP 8.1 and for pdo_sqlite at startup, and names whichever one is missing rather than failing later with something cryptic.

The same three objects are available as a library — $ledger->accounts, $ledger->journal, $ledger->reports — behind a require of one bootstrap file.

What it does not do

This is the section to read before downloading, because it is shorter than finding out yourself.

  • No multi-currency. No currency column, no rate table, no revaluation. The ledger does not know which currency it holds. Two currencies in one file would produce totals that mean nothing, so the feature is absent rather than half-present.
  • No tax or VAT. No codes, no rates, no return. Post to an account you create for it and work the numbers yourself.
  • No invoicing and no documents. 1100 Accounts Receivable is a number, not a list of customers. Nothing here will produce, send or chase an invoice.
  • No user accounts and no permissions. Whoever can run the file can post to it. There is no record of who posted what.
  • No accruals automation. No recurring entries, no prepayment schedules, no depreciation runs. Accrue by posting the entry.
  • No period locking. Nothing stops a backdated entry into a closed month.
  • Two decimal places, fixed. Not configurable.
  • Not a web application.

It is a ledger. An accounting suite is a much larger thing and this is not the small version of one.

Verify it yourself

php test.php

The suite builds a ledger in your temp directory and posts a realistic month: a capital injection, a purchase on credit, a four-line sale carrying its own cost of goods, a customer receipt, a supplier payment and a rent payment. It prints all four reports, then asserts that the trial balance ties at 36,750.00 on both sides, that assets of 30,850.00 equal liabilities plus equity, that net income of 3,850.00 equals the movement in retained earnings between two balance sheet dates, that a closing entry leaves every total unchanged, and that nine kinds of bad entry are each rejected with no row left behind.

54 assertions. Exit code 0 means they all held. Built and tested on PHP 8.3.33 with SQLite 3.53.2; the floor is PHP 8.1.

MIT licensed. Use it commercially, modify it, ship it inside something you sell.

Double-Entry Ledger for PHP

A double-entry ledger in plain PHP. SQLite file, integer money, and an entry that does not balance never reaches the disk.

MIT licensed Zero dependencies PHP 8.1+ and SQLite
0Composer dependencies
8.1+PHP version required
2,313Lines of PHP across 11 files
54Test assertions, all passing

Key capabilities

Balanced, or not written

Journal::post() sums the input before it opens a transaction, inserts the rows, then sums them back out of SQLite before COMMIT. Disagree at either point and the whole thing rolls back. The schema adds CHECK constraints so no line can be negative or carry a debit and a credit at once.

Nothing is cached

There is no balance column on any table. Every report is a SUM over journal_lines. A stored running total is a second source of truth, and the moment it drifts the ledger is wrong with nothing to say so. test.php reads PRAGMA table_info and fails if such a column ever appears.

Money that cannot drift

Amounts are integers of minor units: 12345 means 123.45. Parsing reads the decimal string with integer arithmetic, so 0.10 plus 0.20 is exactly 30. A third decimal place is rejected rather than quietly rounded away.

Four reports off one journal

Trial balance with debit and credit columns that tie, a general ledger with opening balance and running balance, an income statement for any window, and a balance sheet that shows current earnings as its own equity line so it balances without a forced year-end close.

A CLI you can drive today

init, accounts:list, accounts:add, accounts:archive, post, entries:list, entries:show and the four reports. Every command takes --db to pick the file and --json to print the raw structure, amounts still in minor units.

A boundary, stated plainly

No multi-currency, no VAT, no invoicing, no user accounts, no accruals automation, no period locking. The README says so in its own section so you can rule it out in a minute rather than an afternoon.

Questions & Answers

Is this an accounting package?

No. It keeps a correct set of books and answers four questions about them. It will not produce an invoice, track who owes you money by name, calculate tax, or show anyone a screen. If you want those things, this is the wrong download.

Why is there no balance column?

Because a stored total is a second source of truth. The first time it disagrees with the entries it summarises, the ledger is wrong and nothing in it tells you. Recomputing from journal_lines costs a scan and buys the guarantee that a report and the journal can never disagree. If your journal outgrows a scan, the honest answer is a period close that opens a new file with opening balances, not a cache.

What actually happens when an entry does not balance?

An UnbalancedEntryException is thrown and nothing is written. The check runs twice: once on the input array before a transaction is opened, and once on the inserted rows by summing them back out of SQLite before COMMIT. The second is nearly always redundant. It is there because the first trusts an array in memory and the second trusts the file on disk, and those are different claims.

Can I edit or delete an entry?

No, and that is deliberate. Entries are append-only, which is the only arrangement in which an audit trail means anything. To correct entry #7, post an entry that reverses it (the same lines with debit and credit swapped) and then post the right one. Both stay visible.

Can it handle more than one currency?

No. There is no currency column, no rate table and no revaluation. The ledger does not know which currency it holds. Running two currencies through one file would produce totals that mean nothing, so the feature is absent rather than half-present.

Does it do VAT or sales tax?

No tax codes, no rates, no return. You can create an account for tax collected and post to it yourself, and the reports will show it like any other account, but the ledger will not work out what the number should be.

Why does the balance sheet show a "Current earnings" line?

Because the ledger does not force a year-end close, so income and expense accounts still carry the period's activity. That line is their net, and it is the same number the income statement reports for the same window. Post a closing entry into 3900 Retained Earnings and the line becomes zero while the amount appears under 3900 instead. Equity totals the same either way. The test suite posts a closing entry and asserts exactly that.

Is it safe on a web server?

ledger.php refuses to run under any SAPI other than CLI. It is a terminal tool and a library. There is no authentication in it because there is no interface to authenticate, so if you build a web layer on top, the access control is yours to write.

How many decimal places?

Two, fixed, not configurable. Amounts are integer minor units throughout. A third decimal place is rejected rather than rounded away, because silently losing a hundredth is how a ledger stops tying out.

Will it run on PHP 8.0?

No. It uses readonly properties and match, so 8.1 is a real floor, and the script says so and exits rather than failing halfway through with a parse error. It was built and tested on PHP 8.3.33 with SQLite 3.53.2.

Tutorials

Install

Unzip it. There is no build step and nothing to fetch. You need PHP 8.1 or newer with the pdo_sqlite extension, which most PHP builds enable by default. ledger.php checks both at startup and names whichever one is missing.

php ledger.php init

That creates ledger.db in the current directory and seeds a 15-account chart. Use --db=/path/to/file.db on any command to work somewhere else.

Post your first entries

Every posting names the accounts on both sides. Repeat --debit and --credit as many times as the entry needs.

php ledger.php post --date=2026-01-04 --memo="Owner puts in capital" --ref=OPEN-1 
    --debit=1000:25000.00 --credit=3000:25000.00

php ledger.php post --date=2026-01-08 --memo="Stock on 30 day terms" --ref=PO-104 
    --debit=1200:6400.00 --credit=2000:6400.00

php ledger.php post --date=2026-01-15 --memo="Invoice 2026-001" --ref=INV-2026-001 
    --debit=1100:9750.00 --credit=4000:9750.00 
    --debit=5000:4100.00 --credit=1200:4100.00

The third one is a single entry with four lines: the invoice and the cost of what it shipped, posted together.

Watch it refuse a bad entry

php ledger.php post --date=2026-01-16 --memo="Off by ten" 
    --debit=1000:100.00 --credit=4000:90.00
Error: Entry rejected: debits 100.00 do not equal credits 90.00 (difference 10.00).

Nothing was written and the exit code is 1.

Read the books

php ledger.php report:trial-balance
php ledger.php report:balance-sheet --as-of=2026-01-31
php ledger.php report:income --from=2026-01-01 --to=2026-01-31
php ledger.php report:ledger --account=1000

Add --json to any of them to get the same structure as JSON instead of a table. Amounts stay as integer minor units there, because that is how they are stored.

Extend the chart

php ledger.php accounts:add --code=6300 --name="Software Subscriptions" --type=expense
php ledger.php accounts:list

Types are asset, liability, equity, income and expense. The reports group by type, so an account you add behaves exactly like one that shipped.

Use it as a library

require __DIR__ . '/lib/bootstrap.php';

use BineretLedgerLedger;
use BineretLedgerMoney;

$ledger = Ledger::create('books.db');

$ledger->journal->post('2026-01-04', 'Owner puts in capital', [
    ['account' => '1000', 'debit'  => Money::parse('25000.00')],
    ['account' => '3000', 'credit' => Money::parse('25000.00')],
], 'OPEN-1');

$sheet = $ledger->reports->balanceSheet('2026-01-31');

Run the tests

php test.php

It builds a ledger in your temp directory, posts a month of entries, prints all four reports and runs 54 assertions. Exit code 0 means they all held.

Support

What you get

The full source, the test suite that proves the claims on this page, and a README that documents every command and every limitation. Nothing is obfuscated, minified or held back — the download is the whole product.

The MIT licence lets you use it commercially, modify it, and ship it inside something you sell. Keep the copyright notice.

What support is

If something in the download does not do what this page or the README says it does, that is a defect and we want to know. Send the command you ran, the output you got, and your PHP version from php -v. Bugs in the double-entry logic go to the front of the queue, because a ledger that computes the wrong number is worse than no ledger.

What support is not

This is a free download and it does not come with an SLA, a response-time commitment, or a support contract. We are not going to promise a number nobody has measured.

It also does not include accounting advice. How to structure your chart of accounts, when to recognise revenue, or what your local rules require are questions for your accountant. The software will faithfully record whatever you tell it, including things you should not have told it.

Custom features, integrations, and "can you add multi-currency" are outside free support. The non-goals in the README are a design decision, not a backlog.

Before reporting anything

Run php test.php from the unzipped directory. It exercises the whole system in about a second. If those 54 assertions pass on your machine and your own ledger still misbehaves, the entries are the place to look — start with php ledger.php entries:show <id> and php ledger.php report:trial-balance.

Hot Products

SupportIncluded
Money-backGuaranteed
DocumentationFull guide
Easy installOne-click
Original100% authentic
$0.00USD