ringlesoft/laravel-accounting

Laravel Accounting Docs

A double-entry accounting engine for Laravel.

Package API Reference

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.

How the pieces fit together

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.

The Facade

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.


Services

Accounts — 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.

Balances — 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.

Journals — 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.

Posting — PostingService

Named 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).

Periods — 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.

Closing — ClosingService

Posts 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.

Reports — ReportService

LaravelAccounting::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.

Models

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.

The Report base class

Every 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.

Model-level guardrails (Observers)

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.

The Postable trait & PostableModel contract

Turns 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.


Templates — AccountingTemplate & TemplateRegistry

An 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).


DTOs

VoucherData

The 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

VoucherDataItem

One 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.

AccountTemplateEntry

A 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.

BalanceData

A 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 / ClosingResult

Returned 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().

TaxData

account (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).


Enums

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.


Events

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.


Exceptions

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.


Multi-Tenancy

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.

Artisan Commands

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.

Configuration

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.

See Also

  • Available Accounting Templates — the full template catalog, accrual-vs-cash guidance, and how to write your own template
  • Financial Transactions — 100+ standard transactions mapped to the template/method that posts each one
  • README — installation, quick start, and architecture overview