alimarchal/laravel-chart-of-accounts
Professional-grade Chart of Accounts and double-entry accounting module for Laravel with one-command install. Supports Jetstream and Breeze, a versioned REST API, 10 financial reports, role-based permissions, and balance enforcement at the database and application layers.
Decide the UI stack before you install. The package boots into one of two frontends and the choice is baked in at install time (views + assets get published):
# Jetstream / Livewire apps
ACCOUNTING_UI_DRIVER=blade
# Breeze / Inertia+React apps (default)
ACCOUNTING_UI_DRIVER=inertia
Set this in .env first. Flipping it later works in principle but means re-running accounting:update and reconciling two sets of published views.
Install and wire the auth model:
composer require alimarchal/laravel-chart-of-accounts
php artisan accounting:install
Then, critically, add the Spatie trait or every permission-gated screen 403s:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
accounting:install is a big hammer: it publishes migrations (including Spatie permission + activitylog), runs migrate, seeds master data, syncs DB views/triggers/procedures, and assigns super-admin to the first user row. Run it against a fresh/dev database first, and read the output — not just the exit code.
First places to look after install:
config/accounting.php — it pins account codes your code will depend on (1101 cash, 1102 bank, 3101 retained earnings, 5201 rounding).accounting_chart_of_accounts — the seeded chart is what your hard-coded codes resolve against. Dump it before writing any posting logic.Alimarchal\LaravelChartOfAccounts\Models\JournalEntry — the record() helper is the lowest-friction entry point into the whole system.First useful thing: post a balanced entry and prove the ledger moved.
use Alimarchal\LaravelChartOfAccounts\Models\JournalEntry;
$entry = JournalEntry::record(
description: 'Office rent — June',
debitAccountCode: '5101',
creditAccountCode: '1101',
amount: 150000,
post: true,
reference: 'RENT-2026-06',
);
Then immediately confirm with the general ledger report and php artisan accounting:verify. Getting comfortable with that round-trip (post → report → verify) is the whole job in miniature.
JournalEntry::record() as a two-line primitive, not a general APIIt creates exactly one debit and one credit line against a single amount. That covers rent, salaries, simple cash receipts — and nothing else. For anything with tax splits, multiple cost centers, or more than two accounts, drop to JournalEntryService / the API and set lines explicitly. Do not chain two record() calls to fake a compound entry: you get two independent entries, two entry numbers, and reversal becomes ambiguous.
The realistic integration is a listener on something like InvoicePaid:
class PostInvoiceLedgerEntry
{
public function handle(InvoicePaid $event): void
{
JournalEntry::record(
description: "Invoice {$event->invoice->number} paid",
debitAccountCode: config('accounting.defaults.cash_account_code'),
creditAccountCode: '1201', // AR
amount: $event->invoice->total,
post: true,
reference: $event->invoice->number,
);
}
}
Put it on a queue if volume is non-trivial — posting touches multiple tables and, once DB-level balance constraints engage, can fail transiently.
There is no idempotency key on the API and no uniqueness guarantee on reference described in the README. A retried job or a double-clicked webhook will double-post. Guard at your boundary:
$exists = JournalEntry::where('reference', $ref)
->whereIn('status', ['posted', 'draft'])
->exists();
abort_if($exists, 409, 'Entry already recorded');
draft → posted → void | reversed. The practical rules:
reverse (creates a counter-entry, preserves history) or void.The UI encodes this ("Save Draft disabled until debits = credits"), but the API does not — server-side balance validation is your responsibility when posting programmatically.
JournalEntry::record() auto-selects the currently open accounting period and throws ModelNotFoundException if there isn't one. That's a silent time bomb in any long-lived app. Either:
accounting:open-period as part of your fiscal rollover (schedule it, don't remember it), ortry {
$entry = JournalEntry::record(...);
} catch (ModelNotFoundException $e) {
report($e);
// queue for next period, or notify finance
}
reference and cost centers as your reporting dimensionreference is the only free-form correlation field on the header, so make it your join key back to your own domain records (invoice number, order ID, payroll run). Cost centers live on lines — set them at creation time if you want departmental P&L, because retrofitting them means reverse-and-repost.
account-balance-snapshots exist so period-end reports don't re-scan the whole ledger. After bulk imports or backfills, run php artisan accounting:rebuild-snapshots or your reports will be quietly wrong. Do this as a routine post-migration step.
GET /api/accounting/v1/journal-entries?filter[status]=posted&filter[entry_date_from]=2026-06-01&sort=-entry_date
All list endpoints support filter[field], sort, and page. This is enough to sync a month's worth of entries into an external system without writing custom endpoints — but note there's no documented cursor pagination, so large exports will be offset-paginated.
Your custom controllers should use the same vocabulary so the permission matrix stays coherent:
Route::middleware('can:journal-entries.create')->group(...);
If you're not using Spatie, set 'use_permissions' => false in config — otherwise @can guards and route middleware will deny everyone.
config/accounting.php shows 'api_prefix' => env('ACCOUNTING_API_PREFIX', 'api/v1/accounting'), but every example in the README uses /api/accounting/v1. Those are different URLs. Confirm the actual registered route with php artisan route:list --path=accounting before you publish client documentation — do not trust the README here.
accounting:install on an existing database is not idempotent-by-constructionIt publishes Spatie migrations (skipping if present), runs migrate, seeds, and reassigns super-admin to the first user. On a database that already has Spatie installed or users with roles, expect conflicts or a silently reassigned super-admin. Inspect the diff before running on anything shared.
JournalEntry::record() resolves codes like '5101' at runtime and throws if absent. If your chart differs from the seeded one, every call site breaks. Centralize codes:
// config/accounting.php or a dedicated enum
'codes' => [
'ar' => env('ACCOUNTING_AR_CODE', '1201'),
'ap' => env('ACCOUNTING_AP_CODE', '2101'),
],
Never scatter string literals through listeners.
float for money is a smell — verify precision at the boundaryThe signature is amount: 150000.0. Whether that's safe depends entirely on the column type and model cast, which the README doesn't state. Before going live: check the migration for decimal(_, 2) vs double, and test a value like 0.1 + 0.2. If in doubt, do arithmetic in integer minor units or bcmath and only convert at the call boundary.
Select2/jQuery are published to public/vendor/accounting/ and "falls back to CDN if assets not published." On a deployment that runs composer install --no-dev and forgets the asset publish step, you get CDN requests — which break under a strict Content-Security-Policy and behave unpredictably in air-gapped environments. Add accounting:update to your deploy script.
accounting:update overwrites your view customizationsIt re-publishes Blade views, assets, config, and JS. If you've edited resources/views/vendor/accounting/**, those edits are gone. Either copy what you need into your own views and override via your app's view namespace resolution order, or keep a tracked patch and document it loudly.
This is the single biggest operational trap. accounting:sync-db-objects installs DB objects that no migrate will recreate. A restore from a schema-only dump, a fresh CI database, or a staging clone will be missing them — and accounting:verify is how you find out. Bake into every environment bootstrap:
php artisan migrate --force
php artisan accounting:sync-db-objects
php artisan accounting:verify
super-admin, admin, accountant, viewer are seeded. Most teams will need a finance-manager or auditor role. Don't hand-edit the DB — use Spatie's API and mirror the naming convention (journal-entries.post, reports.general-ledger.view) so future package updates stay compatible.
Every change lands in accounting_audit_logs via activitylog. On a busy ledger this table dominates storage within months. Decide a retention policy on day one and schedule pruning — don't discover it during an incident.
The package description advertises payroll, tax, AR/AP, multi-company, and WhatsApp. The README documents chart of accounts, journal entries, periods, reconciliation, tax codes/rates, and reporting. Payroll, multi-company isolation, and WhatsApp integration have zero documentation here. For any of those, read the source before committing an architecture to them, and write integration tests that assert the behavior you're relying on.
5 stars, 0 dependents, single maintainer, and a release date that appears to postdate the current date. That's not disqualifying for a self-hosted accounting module — but it does mean you're the QA team. Pin an exact version in composer.json, keep your own feature tests around posting/reversal/period-closing, and budget time to read the service layer rather than assuming the README is a spec.
php artisan accounting:health-check — fast triage.php artisan accounting:verify — integrity (unbalanced entries, orphaned lines).accounting_audit_logs for the record in question — you get the full change trail for free.config/accounting.php (stable).JournalEntry for cross-cutting concerns (notifications, external sync) — cheaper than editing published views.can: middleware and route-name-prefix overrides (ACCOUNTING_ROUTE_PREFIX=settings) let you mount it in your app's settings area without touching the package routes.How can I help you explore Laravel packages today?