A double-entry accounting engine for Laravel.
A method-by-method reference for everything the package exposes: the LaravelAccounting facade, its seven underlying services, the Eloquent models, the Postable trait/template system, the DTOs that move data between them, the enums, events, and exceptions.
For the full catalog of posting templates and which real-world transactions they cover, see Available Accounting Templates. This document covers the API surface those templates sit on top of.
Your Model (implements PostableModel, uses Postable trait)
│
│ ->post()
▼
getTemplate() → AccountingTemplate::make($model) → VoucherData
│
│ JournalService::createDraft()
▼
JournalEntry (draft, unposted)
│
│ JournalService::post()
▼
JournalEntry (posted, immutable)
Everything in the package funnels through JournalService::createDraft() + post() — the named PostingService shortcuts, LaravelAccounting::postModel()/postRaw(), and $model->post() are all thin wrappers around that same pair of calls. A posted JournalEntry can only be reversed or voided afterward; JournalEntryObserver blocks any other update at the database level, so there is no path that bypasses this.
use RingleSoft\LaravelAccounting\Facades\LaravelAccounting;
| Method | Returns | Description |
|---|---|---|
accounts() |
AccountService |
Account CRUD, hierarchy, preset lookup |
balances() |
BalanceService |
Balance calculation, trial balance, net income |
journals() |
JournalService |
Create/validate/post/reverse/void journal entries |
reports() |
ReportService |
Generate and cache the built-in reports |
posting() |
PostingService |
~35 named shortcuts for standard transaction types |
periods() |
PeriodService |
Open/close/lock fiscal periods |
templates() |
TemplateRegistry |
Resolve/register posting templates by string key |
closing() |
ClosingService |
Preview/commit period-end closing entries |
chartsOfAccounts() |
Collection |
Shortcut for accounts()->getChartOfAccounts() |
postModel(PostableModel $model) |
JournalEntry |
$model->toVoucher() → createDraft() → post(), equivalent to $model->post() |
postRaw(VoucherData $voucher) |
JournalEntry |
createDraft() → post() on a hand-built voucher, bypassing templates entirely |
reverse(JournalEntry $entry) |
JournalEntry |
Shortcut for journals()->reverse($entry) |
All eight sub-services are resolved fresh from the container on each call (app(XService::class)) rather than cached on the facade instance, so they pick up any container bindings/mocks a test has swapped in.
AccountService| Method | Signature | Notes |
|---|---|---|
create |
(array $data): Account |
Validates via AccountValidator; defaults is_active: true, allows_manual_entries: true, currency_code from config. Strips is_system_account — that flag is seeder-only. |
update |
(Account $account, array $data): Account |
Throws if $data['code'] is set on a system account. |
deactivate |
(Account $account): bool |
Throws on a system account or one with existing transactions — deactivate, don't delete. |
activate |
(Account $account): bool |
|
getHierarchy |
(?int $parentId = null): Collection |
Recursive tree starting at $parentId (root accounts when omitted), each node including children. |
getByType |
(AccountTypeEnum $type): Collection |
Active accounts of one type (Asset/Liability/Equity/Income/Expense). |
getPostableAccounts |
(): Collection |
Active accounts with allows_manual_entries = true. |
all |
(): Collection |
All active accounts, with accountType eager-loaded. |
search |
(string $query): Collection |
Matches code or name, limit 50. |
findByCode |
(string $code): ?Account |
|
findByPreset |
(AccountPreset $preset): ?Account |
|
getLastChildrenByPreset |
(AccountPreset $preset): Collection |
The preset's direct children if it has any, otherwise the preset account itself. Throws AccountNotFoundException if no account carries the preset at all — use this when the preset is expected to exist. |
getAccountsForPresetGroup |
(AccountPreset $preset): Collection |
Same shape as above, but returns an empty collection instead of throwing when the preset isn't seeded — use this for reports/aggregation that should degrade gracefully. |
hasTransactions |
(Account $account): bool |
|
getWithBalance |
(int $accountId, ?DateTimeInterface $asOfDate = null): array |
['account' => Account, 'balance' => float, 'as_of_date' => ...]. |
getChartOfAccounts |
(): Collection |
Full hierarchical tree from the root accounts down, with code/name/type/preset/normal_balance/is_active/children per node. |
query |
(): Builder |
Raw Account::query() escape hatch. |
BalanceService| Method | Signature | Notes |
|---|---|---|
getAccountBalance |
(Account $account, ?Carbon $asOfDate = null): float |
Signed per the account's normal balance (debit accounts: debits − credits; credit accounts: the reverse). Only counts posted, non-voided entries up to the date. |
getTrialBalance |
(?FiscalPeriod $period = null): Collection |
One row per active account with non-zero balance as of the period's end (or now). |
getPeriodMovement |
(Account $account, FiscalPeriod $period): array |
opening_balance, total_debits, total_credits, net_movement, closing_balance for one account within one period. |
updateCachedBalances |
(FiscalPeriod $period, ?int $userId = null): void |
Recomputes and upserts AccountBalance rows for every active account in the period; fires BalanceRecalculated. Called automatically by PeriodService::closePeriod(). |
updateAccountBalance |
(Account $account, FiscalPeriod $period): void |
Same, scoped to one account. |
getOpeningBalance / getClosingBalance |
(Account $account, FiscalPeriod $period): float |
Reads the cached AccountBalance row if present, otherwise computes live. |
verifyTrialBalance |
(?FiscalPeriod $period = null): array |
['is_balanced' => bool, 'total_debits', 'total_credits', 'difference', 'period']. Used by PeriodService to gate closing a period. |
getBalancesByAccountType |
(AccountType $type, ?Carbon $asOfDate = null): Collection |
Bulk, SQL-aggregated balances for every account of a type as of a date — used by Balance Sheet. |
getPeriodBalancesByAccountType |
(AccountType $type, Carbon $start, Carbon $end): Collection |
Same, but for a date range — used by Income Statement and ClosingService. |
getPeriodBalancesByAccountTypeEnum |
(AccountTypeEnum $type, Carbon $start, Carbon $end): Collection |
Convenience overload taking the enum instead of the model. |
getPeriodBalancesByAccountTypeIdAndCodePattern |
(int $typeId, Carbon $start, Carbon $end, ?string $codePattern = null): Collection |
Adds a LIKE filter on account code — used for Cash Flow Statement subcategories. |
getCashAndCashEquivalentsBalance |
(?Carbon $asOfDate = null): float |
Sums every account under the CASH_AND_CASH_EQUIVALENT preset group — the canonical "cash on hand" figure when more than one cash account exists. |
calculateNetIncome |
(Carbon $start, Carbon $end): float |
Revenue − Expenses for the range. The single canonical formula — Income Statement and Cash Flow Statement both call this, so they can never disagree. |
calculateUnclosedEarnings |
(?Carbon $asOfDate = null): float |
Revenue − Expenses accumulated since inception but not yet closed to equity. Not wired into any report; a primitive for a future "Current Year Earnings" Balance Sheet line. |
JournalService| Method | Signature | Notes |
|---|---|---|
createDraft |
(VoucherData $voucher, int|string|null $userId = null): JournalEntry |
Validates the voucher, resolves its fiscal period, creates the entry unposted. Retries once on a transaction_number unique-constraint race (two requests creating the first entry of the day simultaneously). Fires JournalEntryCreated. |
post |
(JournalEntry $entry, ?int $userId = null): bool |
Locks the row (lockForUpdate), re-validates balance/period/parent-account rules under the lock, marks it posted. Throws DuplicatePostingException if already posted. Fires JournalEntryPosted. |
reverse |
(JournalEntry $entry, ?string $reason = null, ?int $userId = null): JournalEntry |
Creates and posts a brand-new entry with every debit/credit swapped, linked via reversal_of_id; marks the original is_reversed. Throws if the entry isn't posted, is already reversed, or is voided. Fires JournalEntryReversed. |
void |
(JournalEntry $entry, ?string $reason = null, int|string|null $userId = null): bool |
Marks a draft (unposted) entry voided. Throws if already voided or already posted — posted entries must go through reverse() instead. Fires JournalEntryVoided. |
validateBalance |
(JournalEntry $entry): bool |
Throws JournalNotBalancedException if debits ≠ credits (0.01 tolerance). |
validatePeriod |
(JournalEntry $entry): bool |
Throws PeriodClosedException if there's no fiscal period for the date, or it's closed/locked. |
validateParentAccounts |
(JournalEntry $entry): bool |
Throws ParentAccountPostingException if any line targets an account that has children — unless allow_posting_on_parents is enabled. |
validateVoucher |
(VoucherData $voucher): void |
Throws InvalidVoucherException for fewer than two entries, JournalNotBalancedException if unbalanced. Called by VoucherData::validate(). |
getEntriesForPeriod |
(FiscalPeriod $period, bool $postedOnly = true): Collection |
|
getTransactions |
(array $filters = [], int $perPage = 20): LengthAwarePaginator |
Paginated list with aggregated account names and debit/credit totals per row, built with correlated subqueries (not GROUP BY) for large datasets. Filters: status (posted|draft|voided|reversed), start_date, end_date, fiscal_period_id, account_id, reference, search. |
Every post()/reverse()/void() call locks the row and re-checks its state under that lock, not the possibly-stale entry the caller passed in — this is what makes two concurrent calls on the same entry safe instead of racing.
PostingServiceNamed shortcuts for the standard transaction types. Each one resolves a registered template (see Templates below) and runs it through createDraft() + post() in a single call — equivalent to calling $model->postWithTemplate($template) with that template already selected for you.
LaravelAccounting::posting()->cashSale($invoice);
// same as:
$invoice->postWithTemplate(TemplateRegistry::make('sale.cash'));
| Method | Template key | Debit → Credit |
|---|---|---|
saleTransaction |
sale.credit |
Accounts Receivable → Sales Revenue |
cashSale |
sale.cash |
Cash → Sales Revenue |
receivingPayment |
payment.receipt |
Cash → Accounts Receivable |
purchaseOnAccount |
purchase.on_account |
Inventory/Expense → Accounts Payable |
cashPurchase |
purchase.cash |
Inventory/Expense → Cash |
paymentToSupplier |
payment.bill |
Accounts Payable → Cash |
expensePayment |
purchase.cash |
Expense → Cash (alias of cashPurchase) |
payroll |
payroll.expense |
Salary Expense → Cash (expenses and pays in one step) |
payWages |
payroll.payment |
Wages Payable → Cash (pays out a previously accrued payroll) |
depreciation |
asset.depreciation |
Depreciation Expense → Accumulated Depreciation |
loanReceived |
loan.receipt |
Cash → Loan Payable |
loanPayment |
loan.payment |
Loan Payable → Cash |
ownerInvestment |
equity.investment |
Cash → Owner's Equity / Share Capital |
ownerWithdrawal |
equity.dividend |
Dividends/Drawings → Cash |
inventoryPurchase($model, bool $onAccount = false) |
purchase.on_account or purchase.cash |
Inventory → Cash/Accounts Payable (overrides the expense preset to INVENTORY) |
costOfGoodsSold |
inventory.adjustment |
Cost of Goods Sold → Inventory |
bankTransfer($model, int $from, int $to) |
— | Custom: $from/$to are unused by the method itself — $model->toVoucher() must already encode both legs |
accruedExpense |
payroll.accrual |
Expense → Accrued Expenses Payable |
prepaidExpense |
expense.prepaid.payment |
Prepaid Expense → Cash |
amortizePrepaidExpense |
expense.prepaid |
Expense → Prepaid Expense |
unearnedRevenue |
revenue.unearned |
Cash → Unearned Revenue |
recognizeAccruedRevenue |
revenue.accrued |
Accrued Revenue → Sales Revenue |
recognizeUnearnedRevenueEarned |
revenue.unearned_recognition |
Unearned Revenue → Sales Revenue |
stockIssuance |
equity.stock_issuance |
Cash → Common Stock + Additional Paid-in Capital |
treasuryStockReissuance |
equity.treasury_reissuance |
Cash → Treasury Stock + Additional Paid-in Capital |
bondIssuance |
debt.bond_issuance |
Cash (net) + Bond Issuance Costs → Bonds Payable |
salesDiscount |
sale.discount |
Cash (net) + Sales Discount → Accounts Receivable |
payrollPaymentDetailed |
payroll.payment_detailed |
Wages Payable + Payroll Tax Expense → Cash + Payroll Tax Payable + Withholding Payable |
writeOffBadDebt |
debt.writeoff |
Allowance for Doubtful Accounts → Accounts Receivable |
recoverBadDebt |
debt.recovery |
Cash → Allowance for Doubtful Accounts |
assetSaleGain / assetSaleLoss |
asset.sale_gain / asset.sale_loss |
Cash + Accum. Depreciation → Fixed Assets + Gain (or + Loss → Fixed Assets) |
assetDisposal |
asset.disposal |
Accum. Depreciation + Loss on Disposal → Fixed Assets |
investmentSaleGain / investmentSaleLoss |
investment.sale_gain / investment.sale_loss |
Cash → Investment + Gain (or Cash + Loss → Investment) |
vatPayment |
tax.vat_payment |
Output VAT → Input VAT (offset) + Cash (net remittance) |
foreignExchangeGain / foreignExchangeLoss |
fx.gain / fx.loss |
Cash/AR → FX Gain (or FX Loss → Cash/AP) |
adjustingEntry |
— | Custom: posts $model->toVoucher() directly, no template involved |
Every method's signature is (PostableModel $model, string|int|null $userId = null): JournalEntry except bankTransfer (extra $fromAccountId/$toAccountId params, unused by the method body — kept for call-site clarity) and inventoryPurchase (extra bool $onAccount). All of them can throw AccountNotFoundException, InvalidVoucherException, JournalNotBalancedException, ParentAccountPostingException (from template resolution), and PeriodClosedException/DuplicatePostingException (from posting).
PeriodService| Method | Signature | Notes |
|---|---|---|
openPeriod |
(FiscalPeriod $period, ?int $userId = null): bool |
No-op if already open. Throws if the period is locked. Fires PeriodOpened. |
closePeriod |
(FiscalPeriod $period, ?int $userId = null): bool |
No-op if already closed. Throws if there are unposted entries or the trial balance doesn't balance. Calls BalanceService::updateCachedBalances() first. Fires PeriodClosed. |
lockPeriod |
(FiscalPeriod $period, ?int $userId = null): bool |
Throws if the period isn't closed yet. A locked period rejects even is_closed/is_locked toggles from FiscalPeriodObserver, except unlocking itself. Fires PeriodLocked. |
unlockPeriod |
(FiscalPeriod $period, ?int $userId = null): bool |
|
rollForwardBalances |
(FiscalPeriod $from, FiscalPeriod $to): void |
Throws PreviousPeriodNotClosedException if $from isn't closed. Recalculates cached balances for both periods. |
createPeriodsForYear |
(FiscalYear $year, ?string $frequency = null): array |
Splits the year into fiscal_periods_count periods (12/4/2/1 → monthly/quarterly/semi-annual/yearly naming), firstOrCreate per period. |
ensurePeriodsExist |
(int $yearsAhead = 1): array |
Idempotent — creates the current fiscal year (and $yearsAhead more) plus their periods if missing. Safe to schedule on a cron. |
getCurrentPeriod |
(): ?FiscalPeriod |
The open period containing now(). |
isDateInOpenPeriod |
(DateTimeInterface $date): bool |
|
getFrequencyFromConfig / getMonthsPerPeriod |
(): string / (): int |
Derived from fiscal_periods_count. |
ClosingServicePosts the standard period-end closing entries: Revenue → Income Summary, Expenses → Income Summary, Income Summary's net result → Retained Earnings, Dividends Declared → Retained Earnings. This is what lets the Balance Sheet actually balance at year-end — without it, Revenue/Expense balances just keep accumulating forever.
Every commit*() method posts through the normal JournalService::createDraft() + post() path — nothing bypasses it. Every preview*() method is a pure read that builds the exact VoucherData that would be posted, without writing anything. Each step reads the ledger's actual current state, not a cached calculation, so calling them in any order, any number of times, is safe — a step with nothing left to close just returns null.
| Method | Signature | Notes |
|---|---|---|
preview |
(FiscalPeriod $period): ClosingEntryPreview |
All four steps' VoucherData (or null), plus totalRevenue, totalExpenses, netIncome, dividendsDeclared. |
commit |
(FiscalPeriod $period, ?int $userId = null): ClosingResult |
All four steps in one DB transaction — either the whole close succeeds or none of it does. Throws PeriodClosedException if the period is already closed (post closing entries before closing the period). |
previewCloseRevenue / commitCloseRevenue |
(FiscalPeriod $period[, ?int $userId]): ?VoucherData / ?JournalEntry |
Zeroes every Income-type account's period balance into Income Summary. Throws AccountNotFoundException if INCOME_SUMMARY isn't seeded and there's revenue to close. |
previewCloseExpenses / commitCloseExpenses |
same shape | Zeroes every Expense-type account's period balance into Income Summary. |
previewCloseIncomeSummary / commitCloseIncomeSummary |
same shape | Reads Income Summary's own posted movement for the period (not a recalculation from Revenue/Expense) — this is what makes it safe to call independently of the two steps above. Posts the net result to Retained Earnings; fires IncomeClosed. |
previewCloseDividends / commitCloseDividends |
same shape | Returns null (not an error) if no DIVIDENDS_DECLARED account exists — most businesses never declare dividends. |
ReportServiceLaravelAccounting::reports()->generate('trial_balance', ['as_of_date' => now()]);
// or the camelCase shortcut (magic __call, resolves to the same generate() call):
LaravelAccounting::reports()->trialBalance(['as_of_date' => now()])->getData();
| Type key | Shortcut method | Parameters |
|---|---|---|
balance_sheet |
balanceSheet() |
as_of_date |
income_statement |
incomeStatement() |
start_date, end_date |
cash_flow_statement |
cashFlowStatement() |
start_date, end_date |
trial_balance |
trialBalance() |
as_of_date |
general_ledger |
generalLedger() |
start_date, end_date, account_id |
account_ledger |
accountLedger() |
account_id, start_date, end_date |
charts_of_accounts |
chartsOfAccounts() |
include_inactive, account_type, show_balances, as_of_date |
Every report is a Report subclass (see Models → Report) and every call to generate()/the camelCase shortcuts is cached (report_cache_enabled, TTL report_cache_ttl), keyed per-tenant so two tenants never see each other's cached figures. Other methods:
| Method | Signature | Notes |
|---|---|---|
register |
(string $key, string $reportClass): self |
Add a custom report; $reportClass must extend Report. |
hasReport / getRegisteredReports |
(string $type): bool / (): array |
|
clearCache |
(?string $reportType = null): void |
Clears one report type's cache, or every type's, scoped to the current tenant. |
export |
(Report $report, string $format = 'array'): string|array|bool |
'array', 'json', or 'csv'. |
getAvailableReports |
(): array |
Machine-readable list of every registered type with its name/description/parameters — the source for the table above. |
| Model | Table | Key fields | Notable relationships |
|---|---|---|---|
Account |
{prefix}_accounts |
code, name, account_type_id, category, parent_id, currency_code, is_active, is_system_account, allows_manual_entries, preset |
accountType, parent, children, journalEntryItems, balances |
AccountType |
{prefix}_account_types |
code, name, normal_balance, report_section |
accounts |
JournalEntry |
{prefix}_journal_entries |
transaction_number (auto), transaction_date, description, reference, postable_id/postable_type, currency_code, exchange_rate, fiscal_period_id, is_posted, is_voided, is_reversed, reversal_of_id, metadata (array cast) |
items, postable (morph), fiscalPeriod, createdBy/postedBy/voidedBy, reversalOf/reversals |
JournalEntryItem |
{prefix}_journal_entry_items |
journal_entry_id, account_id, description, debit_amount, credit_amount, currency_code, exchange_rate, dimension_id/dimension_type |
journalEntry, account, dimension (morph — a free slot for tagging a line to e.g. a cost center) |
FiscalYear |
{prefix}_fiscal_years |
name, start_date, end_date, is_closed |
fiscalPeriods, closedBy |
FiscalPeriod |
{prefix}_fiscal_periods |
fiscal_year_id, name, start_date, end_date, is_closed, is_locked |
fiscalYear, journalEntries, accountBalances, closedBy |
AccountBalance |
{prefix}_account_balances |
account_id, fiscal_period_id, opening_balance, debit_total, credit_total, closing_balance, currency_code |
account, fiscalPeriod — a per-period cache written by BalanceService |
ExchangeRate |
{prefix}_exchange_rates |
from_currency_code, to_currency_code, rate, effective_date |
— |
All of them use HasTablePrefix (table name auto-prefixed from laravel_accounting.table_prefix) and MultiTenant (global scope on tenant_id — see Multi-Tenancy). JournalEntry additionally uses HasUuid (switches the primary key to a UUID when use_uuid is enabled).
JournalEntry also exposes convenience instance methods that just delegate to JournalService: isBalanced(), getTotalDebits()/getTotalCredits(), canBePosted()/canBeVoided()/canBeReversed(), and post()/reverse(?string $reason)/void(?string $reason).
Account exposes hierarchy helpers: getFullCode() (dotted ancestor codes, e.g. "1000.1010"), getAncestors()/getDescendants(), isParentOf()/isChildOf()/isDescendantOf().
Both Account and JournalEntry expose a static validationRules(?int $ignoreId = null): array backed by AccountValidator/JournalEntryValidator, for reuse in your own form requests.
Report base classEvery class in src/Reports/ (BalanceSheet, IncomeStatement, CashFlowStatement, TrialBalance, GeneralLedger, AccountLedger, ChartsOfAccounts) extends this abstract class:
| Method | Notes |
|---|---|
setParameters(array $params): self |
Also calls from()/asOf() for any start_date/end_date/as_of_date key present. |
generate(): self |
Abstract — each report subclass builds its own $data here, reading its parameters via getParameter(). |
getData(): array |
The generated rows/figures. |
toArray(): array |
['title', 'generated_at', 'parameters', 'data'] — what ReportService::export() serializes. |
toJson(): string |
|
asOf(Carbon|string $date) / from(Carbon|string $date) |
Fluent setters for $endDate/$startDate. |
These fire regardless of whether you go through a service or call Eloquent directly — they're the backstop that makes "posted entries are immutable" actually true at the data layer, not just a convention the services happen to follow:
JournalEntryObserver: rejects any update() to a posted entry except the fields reverse()/void() themselves touch (is_voided, voided_at, voided_by_user_id, is_reversed); rejects delete() on a posted entry entirely (reverse or void it instead).AccountObserver: rejects delete() on an account with transactions or children; rejects changing account_type_id on an account with transactions; rejects a circular parent_id.FiscalPeriodObserver: validates start_date < end_date and a ≤366-day span; rejects overlapping periods within the same fiscal year; rejects any change but is_locked on a locked period; rejects date changes on a closed period; rejects delete() on a closed/locked period or one with journal entries.Postable trait & PostableModel contractTurns any Eloquent model into a source of journal entries. Implement the interface, apply the trait, implement getPostableAmount() yourself (it's intentionally not provided — "amount" is domain-specific):
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
{
return $this->total;
}
}
| Method (from the trait, unless noted) | Signature | Notes |
|---|---|---|
getPostableAmount |
(): float |
You must implement this — the trait doesn't. The amount a template's closures receive. |
getReference |
(): ?string |
Returns null by default — override to supply a voucher reference. |
getTemplate |
(): ?AccountingTemplate |
Priority: an explicit withTemplate() call, then the #[DefaultAccountingTemplate] attribute, else null. |
withTemplate |
(AccountingTemplate $template): self |
Pins a template instance on this model, overriding the attribute. |
toVoucher |
(): VoucherData |
getTemplate()->make($this). Throws TemplateNotFoundException if no template is resolvable. |
createJournal |
(): bool |
toVoucher() → createDraft(). Throws JournalEntryAlreadyExistsException if journalEntry already exists. |
postJournal |
(): bool |
Posts the existing draft journalEntry, if any. |
post |
(): bool |
createJournal() + postJournal() in one DB transaction. Catches everything and rethrows as LaravelAccountingException — a raw QueryException message (which embeds SQL and bound values) is never surfaced, only logged via the chained $previous. |
postWithTemplate |
(AccountingTemplate $template): bool |
withTemplate($template) then post() — one call for the "Option B" flow below. |
hasJournalEntry |
(): bool |
|
isPosted |
(): bool |
|
journalEntry |
(): MorphOne |
The JournalEntry this model produced, if any. |
// Option A: default template via the attribute
$invoice = Invoice::create(['total' => 5000]);
$invoice->post();
echo $invoice->journalEntry->transaction_number;
// Option B: explicit template + overridden accounts, bypassing the attribute
use RingleSoft\LaravelAccounting\Templates\CreditSaleTemplate;
use RingleSoft\LaravelAccounting\Enums\AccountPreset;
$invoice->postWithTemplate(new CreditSaleTemplate(cashAccount: AccountPreset::PETTY_CASH));
A model can also implement the optional ResolvesAccountPresets contract (one method, resolveAccountForPreset(AccountPreset $preset): int|string|Account|null) to resolve a preset to a specific account dynamically — e.g. picking a customer-specific receivable account — without hardcoding it into the template. It's consulted after an explicit resolveAccount() override on the template, but before the default preset lookup.
AccountingTemplate & TemplateRegistryAn AccountingTemplate is a reusable debit/credit pattern. TemplateRegistry resolves a short string key (used by PostingService and the '...' keys in the table above) to a template class:
use RingleSoft\LaravelAccounting\Templates\TemplateRegistry;
$template = TemplateRegistry::make('sale.cash'); // no constructor args
$template = TemplateRegistry::make('sale.cash', [taxRate: 0.16]); // positional/named constructor args
TemplateRegistry::register('custom.refund', CustomRefundTemplate::class);
TemplateRegistry::templates(); // the full key => class map, ~40 entries
Building a template means implementing configure() (abstract), declaring entries with debit()/credit():
class CashSaleTemplate extends AccountingTemplate
{
public function __construct(
protected AccountPreset $cashAccount = AccountPreset::CASH,
protected AccountPreset $revenueAccount = AccountPreset::SALES_REVENUE,
protected ?AccountPreset $taxAccount = AccountPreset::SALES_TAX_PAYABLE,
protected float $taxRate = 0.0
) {}
public function configure(): void
{
$this->debit($this->cashAccount)
->credit($this->revenueAccount, fn ($amount) => $amount / (1 + $this->taxRate));
if ($this->taxRate > 0 && $this->taxAccount) {
$this->credit($this->taxAccount, fn ($amount) => $amount * $this->taxRate / (1 + $this->taxRate));
}
}
}
| Method | Signature | Notes |
|---|---|---|
debit / credit |
(AccountPreset $account, ?Closure $amount = null, Closure|string|null $memo = null): self |
$amount receives the model's getPostableAmount() result and returns the (possibly transformed) line amount; omit it to use the full amount unchanged. |
describe / date / reference / currency |
(Closure|value $x): self |
Each accepts either a static value or a Closure(PostableModel): value, resolved at make() time. |
resolveAccount |
(AccountPreset $preset, Account|int|string $account): self |
Pin one preset to a specific account for this call, bypassing the default preset lookup — needed when a preset resolves to an ambiguous parent with multiple children. Logs a warning (not an error) if the account given isn't actually a child of that preset. |
deDuplicate |
(): self |
Merges entries that share the same account + debit/credit type, summing their amounts. |
validate |
(PostableModel $model): void |
No-op by default — override to reject an invalid model/parameter combination early (throw InvalidVoucherException). Runs after configure(), before accounts are resolved. |
make |
(PostableModel $model): VoucherData |
Runs configure() → validate() → resolves every preset to a real account id → builds and validates the VoucherData. |
getEntries |
(): AccountTemplateEntry[] |
The configured entries, before resolution. |
getPostableAccounts (static) |
(): array |
['DEBIT' => [...], 'CREDIT' => [...]] — every real account each preset in this template would currently resolve to, useful for building a picker UI. |
Preset → account resolution order (in make(), per entry): an explicit resolveAccount() override on this template instance → the model's own resolveAccountForPreset() if it implements ResolvesAccountPresets → the default lookup (no children = the account itself; exactly one child = that child; more than one child = ParentAccountPostingException, unless allow_posting_on_parents is enabled).
See Available Accounting Templates for the full ~40-template catalog by category, the accrual-vs-cash account pairing table, and a complete custom-template walkthrough (validation, testing, gotchas).
VoucherDataThe intermediate representation every journal entry is built from — whether it came from a template or you built it by hand.
use RingleSoft\LaravelAccounting\DTOs\VoucherData;
$voucher = VoucherData::make()
->describe('Manual adjustment')
->on(now())
->currency('USD')
->debit($expenseAccountId, 150.00, 'Office supplies')
->credit($cashAccountId, 150.00, 'Office supplies');
$entry = $voucher->createJournal(); // JournalService::createDraft()
| Method | Signature | Notes |
|---|---|---|
make (static) |
(): self |
Empty voucher to build fluently. |
for (static) |
(Model|PostableModel $sourceModel): self |
Pre-attaches a source model (sets postable_id/postable_type on the resulting entry). |
debit / credit |
(int|string $accountId, float $amount, ?string $memo = null): self |
Appends a VoucherDataItem. |
on / describe / reference / currency |
fluent setters | currency() accepts a string ISO code or a CurrencyCode enum case. |
withMetadata |
(array $metadata): self |
Merged into $this->metadata. |
setSource |
(Model|PostableModel $model): self |
|
validate |
(): void |
Runs JournalService::validateVoucher() against this voucher — throws InvalidVoucherException/JournalNotBalancedException. |
createJournal |
(): JournalEntry |
app(JournalService::class)->createDraft($this) — creates but does not post. |
toArray |
(): array |
VoucherDataItemOne debit or credit line: type (EntryType), accountId, amount (always positive — sign comes from type, not the number), memo, dimension. Static constructors debit()/credit(). Helpers: isDebit()/isCredit(), getDebitAmount()/getCreditAmount() (one returns the amount, the other 0), toArray(). Validates in its own constructor — throws InvalidArgumentException for a missing account id or an amount ≤ 0.
AccountTemplateEntryA template's unresolved entry, before the preset becomes a real account id: type, account (an AccountPreset, not yet an id), amount (a Closure(float): float, a fixed float, or null to use the full posted amount unchanged), memo. Static constructors debit()/credit(); getAmount(float $baseAmount): float resolves the closure/value.
BalanceDataA free-standing opening/debits/credits/closing accumulator — not used internally by the services above (which return plain arrays), but available for building your own balance calculations. addDebit()/addCredit(), calculateClosing(string $normalBalance = 'debit'), getNetMovement()/getTotalActivity()/getChange()/getPercentageChange(), toArray()/fromArray().
ClosingEntryPreview / ClosingResultReturned by ClosingService::preview() / commit() respectively — four nullable VoucherData/JournalEntry fields (one per closing step) plus totalRevenue, totalExpenses, netIncome, dividendsDeclared. Both expose isProfit(): bool and hasNothingToClose()/hasNothingClosed(): bool, plus toArray().
TaxDataaccount (AccountPreset), rate, optional amount closure override, optional baseAccount, memo. A building block for templates that need a tax line (see CashSaleTemplate's taxRate parameter for the pattern it supports).
| Enum | Cases | Purpose |
|---|---|---|
AccountPreset |
~250, e.g. CASH, ACCOUNTS_RECEIVABLE, SALES_REVENUE, COST_OF_GOODS_SOLD, RETAINED_EARNINGS, INCOME_SUMMARY |
Refer to a role in the chart of accounts without hardcoding an id. AccountPreset::cases() for the full list; ->type(): AccountTypeEnum and ->normalBalance(): NormalBalance classify any case; AccountPreset::byType(AccountTypeEnum $type): array and ::byCategory(string $category): array filter it (categories: operating_expenses, equity_accounts, inventory, payroll_liability, gain_loss, intangible_assets, bond_accounts); ->isParent(): bool flags presets that should never be assigned directly to a created account (they're structural parents, e.g. FIXED_ASSETS, EXPENSES, ACCOUNTS_PAYABLE). |
AccountTypeEnum |
ASSET, LIABILITY, EQUITY, INCOME, EXPENSE |
The five classical account types. ->accountType(): ?AccountType resolves to the seeded model row; ->getId(): ?int its id. |
EntryType |
DEBIT, CREDIT |
The two sides of every journal line. |
NormalBalance |
DEBIT, CREDIT (lowercase values — matches the account_types migration's enum column) |
Which side increases a given account type's balance. |
TransactionType |
SALE, PURCHASE, TRANSFER, ADJUSTMENT |
A coarse transaction classification; not consumed by the services above, available for your own categorization/reporting. |
PaymentMethods |
CASH, BANK_TRANSFER, CREDIT_CARD, CHECK, MOBILE |
Not consumed by the package's own code — a ready-made vocabulary for your own forms/metadata. |
TaxCalculationMethod |
INCLUSIVE, EXCLUSIVE |
Same — a vocabulary, not wired into any service. |
CurrencyCode |
Every ISO 4217 code | Used by VoucherData::currency()/AccountValidator; supported_currencies in config restricts which are accepted (empty = all). |
TransactionTemplate (src/Enums/TransactionTemplate.php) is a larger taxonomy of ~90 named transaction types mirroring the rows in Financial Transactions. It currently isn't referenced anywhere else in the package — treat it as a reserved vocabulary, not a live dependency of anything above.
All fired via Laravel's standard event() — listen for them the usual way (EventServiceProvider, or Event::listen()).
| Event | Constructor | Fired by |
|---|---|---|
JournalEntryCreated |
(JournalEntry $journalEntry, ?int $userId) |
JournalService::createDraft() |
JournalEntryPosted |
(JournalEntry $journalEntry, ?int $userId) |
JournalService::post() |
JournalEntryReversed |
(JournalEntry $originalEntry, JournalEntry $reversalEntry, ?string $reason, ?int $userId) |
JournalService::reverse() |
JournalEntryVoided |
(JournalEntry $journalEntry, ?string $reason, ?int $userId) |
JournalService::void() |
BalanceRecalculated |
(FiscalPeriod $period, int $accountsUpdated, ?int $userId) |
BalanceService::updateCachedBalances() |
PeriodOpened |
(FiscalPeriod $period, ?int $userId) |
PeriodService::openPeriod() |
PeriodClosed |
(FiscalPeriod $period, ?int $userId) |
PeriodService::closePeriod() |
PeriodLocked |
(FiscalPeriod $period, ?int $userId) |
PeriodService::lockPeriod() |
IncomeClosed |
(FiscalPeriod $period, float $netIncome, ?int $userId) |
ClosingService::commitCloseIncomeSummary() |
The built-in InvalidateAccountingCaches listener (registered in the service provider) subscribes to the relevant events above and clears the report cache automatically — you don't need to call ReportService::clearCache() yourself after normal posting/closing activity.
Every exception the package throws extends the single base class, so catch (LaravelAccountingException $e) catches all of them:
use RingleSoft\LaravelAccounting\Exceptions\LaravelAccountingException;
try {
$invoice->post();
} catch (LaravelAccountingException $e) {
// AccountNotFoundException, JournalNotBalancedException, PeriodClosedException, ...
}
| Exception | Thrown when |
|---|---|
AccountNotFoundException |
A preset has no matching seeded account (AccountService::getLastChildrenByPreset(), template resolution, ClosingService's INCOME_SUMMARY/RETAINED_EARNINGS lookups). |
AccountInactiveException |
Reserved for posting-to-inactive-account checks. |
AccountNotPostableException |
Reserved for posting-to-non-postable-account checks. |
ParentAccountPostingException |
A preset resolves to a parent account with more than one child (ambiguous), or posting targets a parent account directly and allow_posting_on_parents is off. |
JournalNotBalancedException |
A journal entry's or voucher's debits don't equal its credits. |
DuplicatePostingException |
post() called on an already-posted entry; deleting a posted entry via the observer. |
InvalidVoucherException |
A voucher has fewer than two entries, or a template's own validate() rejects the model/parameters. |
PeriodClosedException |
Posting/closing/locking against a closed or locked fiscal period, or no fiscal period exists for the transaction date. |
PeriodLockedException |
Reserved for lock-specific checks (see also PeriodClosedException, which lockPeriod()/FiscalPeriodObserver actually throw today). |
PreviousPeriodNotClosedException |
rollForwardBalances() called with an unclosed source period. |
TemplateNotFoundException |
TemplateRegistry::make() with an unregistered key; $model->toVoucher() with no resolvable template. |
JournalEntryAlreadyExistsException |
createJournal() called on a model that already has a journalEntry. |
TenantRequiredException |
A multi-tenant model is created with no resolvable tenant_id while strict_multi_tenancy_check is enabled. |
CurrencyMismatchException |
Reserved for cross-currency posting checks. |
InvalidAccountTypeException |
Deleting an account with transactions/children; changing account type on an account with transactions; circular parent-child relationship (AccountObserver). |
InvalidTransactionDateException |
Reserved for transaction-date validation. |
InsufficientPermissionException |
Reserved for authorization integration. |
TrialBalanceException |
Reserved for trial-balance-specific failures. |
FiscalPeriodNotFoundException |
Reserved — JournalService::validatePeriod() currently throws PeriodClosedException for a missing period instead. |
A few of these ("Reserved") are defined for API completeness/future use but aren't thrown by any code path yet — don't rely on catching them specifically; catch LaravelAccountingException for forward compatibility instead.
Every model using the MultiTenant trait gets a global scope that filters by the authenticated user's tenant_id (configurable field name via multi_tenancy_field) and auto-fills tenant_id on create from the authenticated user — a caller-supplied tenant_id in mass-assigned data is always overridden by the authenticated user's own tenant, so one tenant's request can never attribute data to another. With no authenticated user (CLI, queue, seeder) and strict_multi_tenancy_check enabled, creating a record with no resolvable tenant_id throws TenantRequiredException; with it disabled (the default), a null tenant_id is allowed and the row becomes visible as "global/shared" to every tenant.
ReportService additionally scopes its cache keys by tenant — not the report queries themselves (those rely entirely on the models' own global scope) — so two tenants requesting the same report type never collide on the same cache entry.
| Command | Purpose |
|---|---|
laravel-accounting:install [--uuid] |
Publishes config/migrations/seeders, offers to run migrations and seed account types + chart of accounts + fiscal periods. |
accounting:update-balances {--period=} {--account=} {--all} |
Recalculates cached AccountBalance rows — for one period, one account, or everything. Useful after a bulk data import or if balance_cache_enabled was off and is now on. |
Published to config/laravel_accounting.php by the installer. The full annotated reference lives in the main README's Configuration section — summarized here for convenience:
table_prefix, multi_tenancy_field, strict_multi_tenancy_check, default_currency_code, transaction_number_prefix, use_uuid, default_fiscal_year_start, fiscal_periods_count, cash_basis (informational only, not enforced), auto_post, balance_cache_enabled, report_cache_enabled, report_cache_ttl, cache_prefix, supported_currencies, account_code_format, allow_posting_on_parents.