academe / laravel-journal
Accounting journals and double-entry bookkeeping for Eloquent models
Requires
- php: ^8.2
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- moneyphp/money: ^4.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.13
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
README
Accounting journals and double-entry bookkeeping for Eloquent models.
Give any Eloquent model its own accounting journal, post credits and debits to it in moneyphp/money amounts, read back running balances, and — when you need it — enforce proper double-entry bookkeeping across journals grouped into ledgers.
This package is a modernised, journal-centric conversion of consilience/accounting, itself a fork of the original scottlaurent/accounting package. If you're upgrading from either of those, see UPGRADE.md.
Why this package
- Journal-first, not chart-of-accounts-first. Attach a journal to any Eloquent model with one trait and start posting. There is no world model to adopt — no mandatory chart of accounts, entities, or fiscal calendar. Ledgers, enforced double entry, and period locking layer on only when you need them (see the three scenarios).
moneyphp/moneyas the public API. Amounts go in and come out asMoneyvalue objects; storage is integer minor units. No floats and no decimal strings in your application code, and posting the wrong currency to a journal fails loudly rather than corrupting a balance.- Checkpoints: fast balances and closed periods in one mechanism. A checkpoint stores a journal's cumulative totals through a date and locks the period behind it. Balance queries start from the nearest checkpoint and scan only what's posted since — a journal with ten years of history answers as fast as one with ten days — and the entries behind a checkpoint can no longer be edited or deleted.
- Scales with your rigour. The same tables serve a single wallet's running balance, manual double entry between journals, and ledger-enforced double entry across the full accounting equation — adopt each level as your application grows into it.
Structure at a glance
erDiagram
OWNER_MODEL ||--o| JOURNAL : owns
LEDGER |o--o{ JOURNAL : groups
JOURNAL ||--o{ JOURNAL_TRANSACTION : contains
JOURNAL ||--o{ JOURNAL_CHECKPOINT : seals
JOURNAL_TRANSACTION }o--o| REFERENCE_MODEL : references
Loading
Any model can own a journal (the HasJournal owner morph). Transactions
can point back at any other model — an invoice, an order, a product — via
their own reference morph. Checkpoints store a journal's cumulative
totals and lock the period behind them, and journals may — but don't have
to — be grouped under typed ledgers for double-entry reporting.
Requirements
- PHP 8.2+
- Laravel 12+
Installation
composer require academe/laravel-journal php artisan vendor:publish --tag=journal-config php artisan vendor:publish --tag=journal-migrations php artisan migrate
The service provider is auto-discovered. The config publish is optional — the package config is merged automatically. Publishing the migrations is required on fresh installs: the package deliberately does not auto-load its migrations, so nothing is created until you publish and run them. If you are upgrading from consilience/accounting, do not run them — use the rename migration in UPGRADE.md instead.
Quick start
Add the HasJournal trait to any model that should own a journal:
use Academe\LaravelJournal\Concerns\HasJournal; use Illuminate\Database\Eloquent\Model; class User extends Model { use HasJournal; }
Then initialise a journal and start posting:
use Money\Money; $user->initJournal('USD'); $transaction = $user->journal->credit(Money::USD(10000), 'Opening credit'); $user->journal->debit(7500); $balance = $user->journal->currentBalance(); // Money::USD(2500)
To give every new instance of a model a journal automatically, call
initJournal() from the model's created event instead of by hand:
class User extends Model { use HasJournal; protected static function booted(): void { static::created(fn (self $user) => $user->initJournal()); } }
With no arguments, initJournal() uses config('journal.base_currency').
How it works
- Each model instance that uses
HasJournalgets one journal, linked via a polymorphicownerrelation (journals.owner_type/owner_id). - Amounts are stored as integer minor units (cents, pence, and so on),
using
moneyphp/money'sMoneyvalue object as the public API. - Credits are positive, debits are negative when viewed as a signed
amount:
JournalTransaction::$amountreturns the entry as a single signedMoneyvalue. Internally they're kept in separatecreditanddebitcolumns. journals.balanceis a cached column kept in sync automatically whenever aJournalTransactionis saved or deleted. The cached value equalstotalBalance()(it includes future-dated transactions), notcurrentBalance().
The finer points live in the focused pages below: when the cached balance is recomputed and how stale instances behave in balances and configuration, and currency guarding on posting in double entry.
Why journals are owned by models
A journal belongs to your application, not to the package: it is owned
by one of your own models, and that owner is what gives it meaning. The
package handles only the money side — currency, cached balance,
transactions, checkpoints — while everything that makes the account mean
something to your application (its name, its description, who may see
it, when it is created or archived) lives on the owner, where your
application already manages those concerns. That is why the journals
table stores no name and no description: a journal's identity is
entirely delegated to its owner. The owner morph is non-nullable and
unique per (owner_type, owner_id) pair, so the relationship is
strictly one-to-one — a journals row means nothing on its own; it is
"the journal of User #42".
Owners tend to fall into two camps:
- Domain objects that naturally have financial state — the design's
sweet spot. A
Userwith an account balance, aWallet, aGiftCardwith remaining value, anOrderaccruing charges, a driver owed payouts. The journal is the answer to "where did this thing's balance come from?", reached from the object you already have in hand:$user->journal->currentBalance(). - Stand-in models for pure accounting accounts. An account that isn't
a domain object — Cash, Sales, Accounts Receivable — is a small model
whose rows exist to own journals; that's what
CompanyAccountis doing in the ledger examples. This is the cost of the journal-first design: where a chart-of-accounts-first package hands you free-standing named accounts, here a named account is a one-line model plus a row.
One consequence of the unique index: a model that needs several journals — a multi-currency wallet, say — can't own them all directly. Introduce a child model (one row per currency, for example) and hang one journal off each.
Documentation
Focused guides live under docs/:
- Balances — how balances are calculated at journal and ledger level (the two sign conventions), the balance methods, and the staleness of the cached column after posting.
- Formatting and parsing amounts — the
MoneyFormatterhelper:Moneyto string and back, plain or locale-aware. - Referencing models — linking transactions to invoices, orders, products.
- Tags — labelling transactions with key/value metadata.
- Double entry — atomic
TransactionGroupcommits, fetching a group, reversing a group. - Multiple currencies — running more than one currency, FX clearing journals, realised and unrealised gains.
- Ledgers — grouping journals under typed ledgers, the three usage scenarios, custom ledger types.
- Checkpoints — fast balances, closed periods, reopening a period.
- Exceptions — the exception hierarchy and what each exception carries.
- Configuration —
config/journal.php: base currency, model substitution, balance-cache timing, soft deletes.
Upgrading from consilience/accounting or scottlaurent/accounting, or between versions of this package: UPGRADE.md.
For a complete worked example, see
academe/laravel-journal-window-cleaner-demo:
"Shiny & Sons", a VAT-registered window cleaning round with six months
of seeded history, running its bookkeeping on this package. Customer
balances are journals, every charge and payment commits as a balanced
TransactionGroup with the VAT split out, and typed ledgers keep the
accounting equation live — plus month close on checkpoints and a
quarterly VAT return read straight off the VAT journal. SQLite only,
no build step.
Roadmap
Planned follow-up work:
- Checkpoint follow-ons — ledger-level rollup rows, and archiving or pruning of old transaction rows once they're safely behind a checkpoint.
The core stays pure bookkeeping mechanics: higher-level accounting concepts — invoices, payments, allocation and clearing — are for packages layered on top, not this one.
Licence
MIT. See LICENSE.txt.