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 (a credit as positive, a debit as negative). 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
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". The journal contributes only the
bookkeeping state — currency, cached balance, transactions, checkpoints —
while the name, the description, who may see it, and when it is created or
archived all live on the owner, where your application already manages
those concerns.
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 honest 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.
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.