Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Chart Of Accounts Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/accounting.php — it pins account codes your code will depend on (1101 cash, 1102 bank, 3101 retained earnings, 5201 rounding).
  2. accounting_chart_of_accounts — the seeded chart is what your hard-coded codes resolve against. Dump it before writing any posting logic.
  3. 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.

Implementation Patterns

Treat JournalEntry::record() as a two-line primitive, not a general API

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

Hook it into your domain events, not your controllers

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.

Build your own idempotency

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');

Learn the state machine and respect it

draft → posted → void | reversed. The practical rules:

  • Never edit a posted entry. Use reverse (creates a counter-entry, preserves history) or void.
  • Reverse when the original transaction was real but wrong in amount/account.
  • Void when the entry should never have existed (duplicate, test data, wrong company).
  • Drafts are the only mutable state — use them for anything generated by a human, post them from a scheduled job or approval flow.

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.

Drive periods deliberately

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:

  • run accounting:open-period as part of your fiscal rollover (schedule it, don't remember it), or
  • catch the exception and surface it as a domain error, not a 500.
try {
    $entry = JournalEntry::record(...);
} catch (ModelNotFoundException $e) {
    report($e);
    // queue for next period, or notify finance
}

Use reference and cost centers as your reporting dimension

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

Snapshots are a performance tool, not a correctness one

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.

API consumers: lean on filters, not pagination gymnastics

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.

Gate your own code with the package's permissions

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.

Gotchas and Tips

The API prefix config and the documented base URL disagree

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-construction

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

Hard-coded account codes are load-bearing

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 boundary

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

Silent CDN fallback is a CSP and offline-dev hazard

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 customizations

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

Database views, triggers, and stored procedures are outside your migration history

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

Permissions: 40+ permission strings and four roles is a lot of surface

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.

Audit log volume grows unbounded

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 stated scope is far larger than the documented scope

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.

Maturity signal: pin the version

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.

Debugging workflow when something's off

  1. php artisan accounting:health-check — fast triage.
  2. php artisan accounting:verify — integrity (unbalanced entries, orphaned lines).
  3. Query accounting_audit_logs for the record in question — you get the full change trail for free.
  4. Confirm an open period exists; the number one cause of mystifying "record() doesn't work" is a closed or missing period.

Extension points that won't fight the package

  • Publish and edit config/accounting.php (stable).
  • Model observers on 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.
  • Avoid editing the package's service layer directly; wrap it in your own Actions so upgrades stay cheap.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo