ringlesoft / laravel-accounting
A double-entry accounting engine for Laravel - attach any model via a trait, post through templates or raw vouchers, and get balanced, immutable journal entries with multi-currency, multi-tenancy, fiscal periods, and financial reports built in.
Requires
- php: ^8.1
- illuminate/contracts: ^10.0|^11.0
- illuminate/database: ^10.0|^11.0
- illuminate/routing: ^10.24|^11.0
Requires (Dev)
- larastan/larastan: ^2.0.1
- laravel/pint: ^1.0
- nunomaduro/collision: ^7.8
- orchestra/testbench: ^8.8
- pestphp/pest: ^2.20
- pestphp/pest-plugin-arch: ^2.0
- pestphp/pest-plugin-laravel: ^2.0
- phpstan/extension-installer: ^1.1
- phpstan/phpstan-deprecation-rules: ^1.0
- phpstan/phpstan-phpunit: ^1.0
- spatie/laravel-ray: ^1.26
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 12:04:41 UTC
README
> PHP 8.1+, Laravel: 10+A double-entry accounting engine for Laravel.
Turn any model to a source of accounting transactions. Attach a Postable trait, map it to an AccountingTemplate, and
posting produces a balanced, immutable journal entry.
- Double-entry bookkeeping — every transaction maintains balanced debits and credits
- Immutable records — posted entries can't be edited, only reversed or voided
- Template-based posting — 39 ready-made templates for common transactions (sales, purchases, payroll, depreciation, loans, equity...), or easily write your own
- Multi-currency, multi-tenancy, and fiscal period support
- Reports: Trial Balance, Balance Sheet, Income Statement, Cash Flow Statement, General Ledger, Account Ledger, Chart of Accounts
Project status: Fully functional and under active development, not yet 1.0 (still improving based on feedback).
Table of Contents
- Installation
- Quick Start
- Diving Deeper
- Testing
- Contributing
- License
Installation
- Install using composer
composer require ringlesoft/laravel-accounting
- Run the installer: it publishes the config, migrations and seeders, offers to run migrations, and offers to seed account types + a full chart of accounts + fiscal periods for the current year:
php artisan laravel-accounting:install
Quick Start
Option A. Using PHP attributes
Implement the PostableModel interface and the getPostableAmount() method. Apply the #[DefaultAccountingTemplate]
attribute
use RingleSoft\LaravelAccounting\Contracts\PostableModel; use RingleSoft\LaravelAccounting\Traits\Postable; use RingleSoft\LaravelAccounting\Attributes\DefaultAccountingTemplate; use RingleSoft\LaravelAccounting\Templates\CreditSaleTemplate; #[DefaultAccountingTemplate(CreditSaleTemplate::class)] class Invoice extends Model implements PostableModel { use Postable; public function getPostableAmount(): float // You must implement this { return $this->total; } }
Now you can post your model
$invoice = Invoice::create(['total' => 5000]); $invoice->post(); echo $invoice->journalEntry->transaction_number;
Option B. Manual Template assigning
Post with an explicit template and overridden accounts instead of the default attribute:
use RingleSoft\LaravelAccounting\Templates\CreditSaleTemplate; use RingleSoft\LaravelAccounting\Enums\AccountPreset; $invoice = Invoice::create(['total' => 5000]); $invoice->postWithTemplate(new CreditSaleTemplate( cashAccount: AccountPreset::PETTY_CASH));
Note: Accounts are never posted to a parent.
Diving Deeper
Core Concepts
The mental model is a straight line, and each link below is explained in its own subsection:
- An Account, identified by an Account Preset so you never hardcode IDs, is where money lives.
- An Accounting Template describes the debit/credit shape of a transaction type (e.g. a credit sale debits Accounts Receivable and credits Sales Revenue).
- Your own Eloquent model becomes a transaction source via the Postable trait, which builds Voucher Data from the template and posts it as an immutable Journal Entry (made up of balanced Journal Entry Items).
- Mistakes are fixed by reversing or voiding, never editing.
- Posting only succeeds inside an open Fiscal Period.
- Every account and entry automatically respects multi-currency and multi-tenancy.
- Reports read the ledger back out as Trial Balance, Balance Sheet, Income Statement, etc.
Accounts & Account Presets
This package ships with a complete Chart of Accounts (COA) that you can modify and extend for your own needs. Accounts can be nested (a parent category with child accounts), but accounts are never posted to directly if they have children — posting always targets a specific leaf account.
To avoid hardcoding database IDs or names, every account can carry an Account Preset: an enum of ~250 predefined
roles (CASH, ACCOUNTS_RECEIVABLE, SALES_REVENUE, ...) that templates and your own code refer to instead of a
concrete account. This is what lets an AccountingTemplate be defined once and still resolve to the correct account in
any application's chart of accounts.
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; use RingleSoft\LaravelAccounting\Enums\AccountPreset; $cash = LaravelAccounting::accounts()->findByPreset(AccountPreset::CASH); $child = LaravelAccounting::accounts()->create([ 'code' => '1010', 'name' => 'Petty Cash', 'parent_id' => $parent->id, 'account_type_id' => $assetType->id, 'currency_code' => 'USD', ]); echo $child->getFullCode(); // hierarchical code, e.g. "1000.1010"
Journal Entries, Journal Entry Items & Voucher Data
A Journal Entry plus its balanced debit/credit Journal Entry Items are the immutable ledger components — posted entries can only be reversed or voided, never edited.
Before a Journal Entry exists, a template (or your own code) builds a Voucher Data object: an intermediate DTO
that standardizes how debit/credit lines are described. Journal Entries only ever accept lines in this format, which
is why both posting paths below — a template, or a hand-built VoucherData — end up going through the same
createDraft()/post() pipeline.
Accounting Templates
An accounting template encapsulates the debit/credit pattern for a transaction type. It supports any number of lines
per side, optional conditional lines (e.g. tax), and dynamic amounts via closures.
The package comes with 39 prebuilt accounting templates that cover most of the well known accounting operations. You
can always create yours on the fly, or by extending the AccountingTemplate class — see Creating Custom Templates
in docs/templates-api.md.
The Postable Trait
This is the trait that turns your model into a source of a transaction in your ledger. This opinionated approach to
ledger posting makes the accounting abstraction even more valuable by eliminating the need to manually write posting
code for every type of transaction.
The trait gives any Eloquent model post(), toVoucher(), postWithTemplate(), isPosted(), journalEntry(), and
expects you to override getPostableAmount(), which is the trusted source of what amount is posted (see the
Quick Start above for a minimal example).
Posting Transactions
Once a model uses Postable (or you'd rather skip models entirely), there are several ways to actually post:
Via a template directly
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; use RingleSoft\LaravelAccounting\DTOs\VoucherData; $voucher = VoucherData::make() ->debit($cashAccountId, 500.00, 'Office supplies') ->credit($expenseAccountId, 500.00) ->describe('Office supplies purchase') ->reference('EXP-001'); $entry = LaravelAccounting::journals()->createDraft($voucher); LaravelAccounting::journals()->post($entry, auth()->id());
Via PostingService
For common transactions, PostingService wraps the right template plus createDraft() + post() in one call:
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; LaravelAccounting::posting()->cashSale($sale, auth()->id()); LaravelAccounting::posting()->purchaseOnAccount($purchase, auth()->id()); LaravelAccounting::posting()->payroll($payrollRun, auth()->id());
See the PostingService Method column in docs/templates-api.md for every available method.
Two-step posting (review before posting)
$invoice->createJournal(); // draft only $draft = $invoice->journalEntry; if ($draft->canBePosted()) { $invoice->postJournal(); }
Resolving ambiguous accounts (children of a preset)
Sometimes a preset (e.g. COST_OF_GOODS_SOLD) resolves to an account that has multiple children under it. Since
accounts are never posted to a parent, posting then fails with ParentAccountPostingException — the package refuses to
guess which child you meant. There are two ways to resolve it:
- Pass the template → account mapping as you instantiate the template:
$purchase->postWithTemplate( (new CashPurchaseTemplate())->resolveAccount(AccountPreset::COST_OF_GOODS_SOLD, $cogsSubAccount) );
- Implement
ResolvesAccountPresetson the postable model and return the mapping account viaresolveAccountForPreset():
use RingleSoft\LaravelAccounting\Contracts\ResolvesAccountPresets; #[DefaultAccountingTemplate(PrepaidExpenseTemplate::class)] class Invoice extends Model implements PostableModel, ResolvesAccountPresets { use Postable; public function resolveAccountForPreset(AccountPreset $preset): Account|int|string|null { return $preset === AccountPreset::COST_OF_GOODS_SOLD ? $this->department->cogsAccount : null; } }
See docs/templates-api.md for the full catalog of 39 preset templates (sales, purchases, payroll, depreciation, loans, equity, inventory, bad debt).
Reversing and Voiding
Posted entries are immutable — correct them by reversing (creates an offsetting entry, full audit trail) rather than editing:
$entry = JournalEntry::find(1); if ($entry->canBeReversed()) { // true once posted $reversal = $entry->reverse('Correction needed'); }
void() only applies to a draft, unposted entry (cancelling it before it's ever posted) — once posted,
canBeVoided() returns false and you must reverse() instead.
Fiscal Periods
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; use RingleSoft\LaravelAccounting\Models\FiscalYear; $year = FiscalYear::create(['name' => '2026', 'start_date' => '2026-01-01', 'end_date' => '2026-12-31']); $periods = LaravelAccounting::periods()->createPeriodsForYear($year); // generates periods per fiscal_periods_count LaravelAccounting::periods()->closePeriod($periods[0]); // recalculates & caches balances, then closes LaravelAccounting::periods()->lockPeriod($periods[0]); // freezes it completely (only unlock() can undo)
Posting validates the fiscal period is open; closing validates every entry in it is posted and the trial balance is in balance.
Closing entries
PeriodService::closePeriod() doesn't post anything by itself — it just validates and locks. To actually zero out
Revenue/Expense into Retained Earnings before closing a period (the step that was missing before, and why the Balance
Sheet wouldn't balance at year-end), use ClosingService:
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; $preview = LaravelAccounting::closing()->preview($period); // pure read, nothing posted $preview->netIncome; // signed: positive = profit, negative = loss $preview->closeRevenue; // the exact VoucherData that would be posted, or null if nothing to close $result = LaravelAccounting::closing()->commit($period); // posts all four steps, through the normal JournalService path, in one transaction
Each of the four steps (close Revenue, close Expenses, close Income Summary → Retained Earnings, close Dividends →
Retained Earnings) also has its own previewCloseX()/commitCloseX() pair if you want to run them independently rather
than all at once — e.g. commitCloseRevenue($period). Every step reads the ledger's actual current state, not a cached
calculation, so calling them in any order, or calling the same one twice, is safe: a step with nothing left to close
just returns null. Requires INCOME_SUMMARY and RETAINED_EARNINGS accounts to be seeded (throws
AccountNotFoundException if not); a missing DIVIDENDS_DECLARED account just means that step is skipped, since most
businesses never declare dividends at all. Must run before PeriodService::closePeriod(), since posting is rejected
once a period is closed.
Scheduling period closing
php artisan accounting:close-period wraps the two steps above (ClosingService::commit() then
PeriodService::closePeriod()) into one command built to run unattended: with no options it finds every open
fiscal period whose end_date has already passed and closes each one, continuing past any individual failure rather
than aborting the whole run.
php artisan accounting:close-period # close every due period php artisan accounting:close-period --dry-run # preview net income per period, post/close nothing php artisan accounting:close-period --period=12 # close one specific period regardless of its end date php artisan accounting:close-period --lock # also lock each period immediately after it closes php artisan accounting:close-period --tenant=5 # only consider periods belonging to this tenant_id php artisan accounting:close-period --user=1 # attribute the closing entries/closure to this user id
It exits 0 only if every period it processed closed successfully, and 1 if at least one failed (e.g. an
unbalanced trial balance, or an unposted draft entry still sitting in that period) — check the exit code if you wire
alerting around it. A period that's already closed is reported and skipped rather than erroring.
Schedule it like any other command — in routes/console.php (Laravel 11+):
use Illuminate\Support\Facades\Schedule; Schedule::command('accounting:close-period')->dailyAt('01:00');
or in app/Console/Kernel.php's schedule() method on earlier Laravel versions:
$schedule->command('accounting:close-period')->dailyAt('01:00');
Running it daily (rather than exactly on the period boundary) is deliberate: a period only closes once its end_date
has passed, so a daily run just means it closes the morning after the period actually ends, whenever that falls.
Since this runs outside an authenticated context, it is not scoped to a single tenant by the usual global scope
(see Multi-Currency & Multi-Tenancy below) — one scheduled run naturally closes the
due period for every tenant, which is normally what you want; pass --tenant= if you need to isolate it to one.
Multi-Currency & Multi-Tenancy
$voucher = VoucherData::make() ->debit($accountId, 1000.00) ->credit($accountId, 1000.00) ->currency(\RingleSoft\LaravelAccounting\Enums\CurrencyCode::EUR) ->withMetadata(['exchange_rate' => 1.18]);
Multi-tenancy isn't a toggle — any model using the MultiTenant trait (Account, JournalEntry, FiscalYear, FiscalPeriod,
AccountBalance all do) is always scoped to a tenant_id column via a global scope. multi_tenancy_field only controls
which attribute is read off the authenticated user to populate it on create (defaults to tenant_id); if there's no
authenticated user (queues, CLI) it's left unset.
By default (strict_multi_tenancy_check => true), every record must belong to exactly one resolvable tenant: a row with
a null tenant_id is never visible to an authenticated tenant, and creating a record with no resolvable tenant_id (no
authenticated user, and none set explicitly) throws TenantRequiredException.
Set it to false to allow genuinely global/shared records instead — null-tenant rows become visible to every tenant,
and can be created without a resolvable tenant (e.g. from a seeder or console command) without being rejected.
Recommended is true whenever using multi-tenancy.
Reports
This package generates all the standard (IFRS) reports for accounting systems — made possible by the opinionated
structure of the COA and the use of account presets. Every report goes through
LaravelAccounting::reports()->{reportType}($params), which returns an already-generated report - call ->getData()
(or ->toArray()/->toJson()) directly. $params keys are snake_case and vary per report:
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; $trialBalance = LaravelAccounting::reports() ->trialBalance(['as_of_date' => now()]) ->getData(); $incomeStatement = LaravelAccounting::reports() ->incomeStatement(['start_date' => now()->startOfMonth(), 'end_date' => now()->endOfMonth()]) ->getData(); $ledger = LaravelAccounting::reports() ->accountLedger(['account_id' => $accountId, 'start_date' => '2026-01-01', 'end_date' => '2026-12-31']) ->getData();
| Report | Key params |
|---|---|
| Trial Balance | as_of_date |
| Balance Sheet | as_of_date |
| Income Statement | start_date, end_date |
| Cash Flow Statement | start_date, end_date |
| General Ledger | start_date, end_date, optional account_id |
| Account Ledger | account_id (required), start_date, end_date |
| Chart of Accounts | include_inactive, account_type, show_balances, as_of_date |
Results are cached per report_cache_ttl, keyed by report type + params, and invalidated automatically whenever a
journal entry posts, reverses, or voids - LaravelAccounting::reports()->clearCache($reportType) clears it manually if
needed. Calling ->generate() again on a returned report is safe but recomputes live and bypasses the cache for that
call, so there's no need to.
Configuration
The installer (or a manual vendor:publish, see below) publishes config/laravel_accounting.php into your Laravel
application:
return [ 'table_prefix' => 'la', // DB table prefix for this package's tables 'multi_tenancy_field' => 'tenant_id', 'strict_multi_tenancy_check' => true, // false allows null-tenant rows as shared/global data 'default_currency_code' => 'USD', 'transaction_number_prefix' => 'JE', // e.g. JE-20260101-000001 'use_uuid' => false, // UUID vs auto-increment primary keys 'default_fiscal_year_start' => ['month' => 1, 'day' => 1], 'fiscal_periods_count' => 2, // 12 monthly, 4 quarterly, 2 semi-annual, 1 annual 'cash_basis' => false, // informational only, not enforced - see docs/templates-api.md 'auto_post' => false, 'balance_cache_enabled' => true, 'report_cache_enabled' => true, 'report_cache_ttl' => 3600, 'cache_prefix' => 'laravel_accounting', 'supported_currencies' => [], // empty = allow any ISO 4217 code 'account_code_format' => '/^[0-9]{4}(-[0-9]{2})?$/', 'allow_posting_on_parents' => false, ];
Posting fails with a validation error ("the fiscal period id field is required") if no fiscal period covers today — make sure
FiscalPeriodSeederhas run, or create one viaLaravelAccounting::periods()->createPeriodsForYear($fiscalYear).
Manual Installation and Resources Publishing
If you don't want to run the install command, you can manually publish package resources. This is what the install
command does in the background:
php artisan vendor:publish --tag="accounting-config" php artisan vendor:publish --tag="accounting-migrations" php artisan migrate php artisan db:seed --class=AccountTypeSeeder php artisan db:seed --class=ChartOfAccountsSeeder php artisan db:seed --class=FiscalPeriodSeeder
Troubleshooting & FAQ
"Failed to post to journal. The fiscal period id field is required."
No fiscal period covers the transaction date. Seed one (FiscalPeriodSeeder) or create it directly:
LaravelAccounting::periods()->createPeriodsForYear($fiscalYear).
ArgumentCountError when calling TemplateRegistry::make('key') or YourTemplate::getPostableAccounts()
Both call new $class() with zero arguments. Every constructor parameter on a template needs a default value — see
Gotchas in docs/templates-api.md.
ParentAccountPostingException
A preset (e.g. COST_OF_GOODS_SOLD, ACCOUNTS_RECEIVABLE) resolved to a parent/category account with more than one
child, so the package refuses to guess which one to post to — accounts are never posted to a parent. Pick one with
resolveAccount(), or implement ResolvesAccountPresets on the model —
see Posting Transactions above.
AccountNotFoundException
No seeded account carries the preset a template needs. Check your chart of accounts, or verify with
YourTemplate::getPostableAccounts().
JournalNotBalancedException
Debits ≠ credits. Common causes: a template's configure() was called twice (entries only ever append, never replace —
never call ->configure() yourself), or a closure-based amount produced an unexpected value.
Why doesn't my Balance Sheet balance at year-end?
Run LaravelAccounting::closing()->commit($period) before closing the period — see Closing entries
under Fiscal Periods above. Nothing runs this automatically; PeriodService::closePeriod() won't do
it for you. BalanceService::calculateUnclosedEarnings() still exists as a lower-level primitive if you need the
unclosed-earnings figure without actually posting anything.
Is multi-tenancy optional?
No — it's not a config toggle. Any model using the MultiTenant trait (Account, JournalEntry, FiscalYear,
FiscalPeriod, AccountBalance) is always scoped to tenant_id via a global scope. multi_tenancy_field only
controls which attribute is read off the authenticated user to populate it.
The tenant scope relies on Auth::user() — what happens in a queued job or artisan command, where there's no
authenticated user?
No tenant filter is applied at all in that context — the global scope only ever narrows results to the authenticated
user's own tenant; it never fails open onto other tenants' data. Under the default strict check it never matches a
null-tenant_id row either. But without an authenticated user, there's nothing for it to scope to, so a queued job or
console command operating across tenants is your responsibility to scope explicitly (e.g.
Account::where('tenant_id', $tenantId)->get(), or set tenant_id yourself before saving). Don't assume the package
will scope non-HTTP code for you.
I want some accounts/data shared across every tenant (e.g. a default chart of accounts), not owned by just one.
Set strict_multi_tenancy_check to false. A record with no resolvable tenant — created explicitly with
tenant_id = null, or from a seeder/console command with no authenticated user — is then allowed to exist and becomes
visible to every tenant, instead of being rejected at creation and invisible everywhere except to whoever created it
outside an authenticated context. Tenant-owned rows (a real tenant_id set) are still isolated from each other exactly
as before — this only changes how null-tenant rows behave.
Can I use cash-basis instead of accrual accounting?
The cash_basis config flag is informational only — it isn't enforced anywhere. All shipped templates post on an
accrual basis (revenue/expense recognized when earned/incurred, not when cash moves). See Accounting Basis: Accrual vs
Cash in docs/templates-api.md before building cash-basis behavior yourself.
UUIDs or auto-incrementing IDs?
A one-time choice at install (config('laravel_accounting.use_uuid')). Switching after you have real data requires a
manual data migration — decide before going to production.
How do I write my own template?
See Creating Custom Templates in docs/templates-api.md — covers registration,
validate(), testing it without a database, and common gotchas.
Does the package handle authorization (who's allowed to post, close a period, etc.)?
No — that's left entirely to your application. There are no policy classes or permission checks anywhere in the package;
wrap calls into PostingService/PeriodService/JournalService with your own authorization logic (a policy, a
middleware, a form request) before invoking them.
API Reference
Facade
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting; LaravelAccounting::accounts(); // AccountService LaravelAccounting::balances(); // BalanceService LaravelAccounting::journals(); // JournalService LaravelAccounting::reports(); // ReportService LaravelAccounting::posting(); // PostingService LaravelAccounting::periods(); // PeriodService LaravelAccounting::templates(); // TemplateRegistry LaravelAccounting::closing(); // ClosingService LaravelAccounting::chartsOfAccounts();
Models
Account, AccountType, AccountBalance (cached per-period balances), JournalEntry, JournalEntryItem,
FiscalYear, FiscalPeriod, ExchangeRate.
Services
JournalService (create/validate/post/reverse/void), AccountService (CRUD, hierarchy, preset lookup),
BalanceService (balance calculation, trial balance, net income), ReportService, PostingService (named transaction
shortcuts), PeriodService (open/close/lock fiscal periods), ClosingService (preview/commit period-end closing
entries).
Key Exceptions
All extend LaravelAccountingException: AccountNotFoundException, AccountInactiveException,
ParentAccountPostingException (preset resolves to a parent/category account with no unambiguous child),
JournalNotBalancedException, DuplicatePostingException, PeriodClosedException, InvalidVoucherException,
TemplateNotFoundException. See src/Exceptions/ for the full list.
Testing
composer test
composer test-coverage
Contributing
Let me pave the way, I'll let you know when you can contribute.
Security
If you discover any security-related issues, please email ringunger@gmail.com or use the issue tracker.
Credits
- David Ringle - Developer
License
The MIT License (MIT). Please see License File for more information.
Support
Issues: https://github.com/ringlesoft/laravel-accounting/issues
Contacts
Follow me on X: @ringunger
Email me: ringunger@gmail.com
Website: https://github.com/ringlesoft/laravel-accounting