alimarchal/laravel-chart-of-accounts
Production-ready chart of accounts and double-entry accounting for Laravel. One-command install, works with Jetstream (Blade/Livewire) and Breeze (Inertia/React). Includes versioned REST API, RBAC, and core financial reports.
Installation:
composer require alimarchal/laravel-chart-of-accounts
php artisan vendor:publish --provider="AliMarchal\ChartOfAccounts\ChartOfAccountsServiceProvider"
php artisan migrate
First Use Case:
use AliMarchal\ChartOfAccounts\Facades\JournalEntry;
$entry = JournalEntry::create()
->debit('Asset-Cash', 1000, 'USD')
->credit('Equity-OwnerCapital', 1000, 'USD')
->save();
use AliMarchal\ChartOfAccounts\Facades\ChartOfAccounts;
$accounts = ChartOfAccounts::all()->withBalances();
Where to Look First:
config/chart-of-accounts.php (currency settings, default tax rules).database/seeders/ChartOfAccountsSeeder.php (customize default accounts).app/Actions/ (pre-built actions like CreateJournalEntry, ReconcileBankStatement).Account Management:
Account::create() with parent_id for sub-accounts.
Account::create([
'code' => '5000',
'name' => 'Revenue',
'type' => 'income',
'parent_id' => null, // Top-level
]);
ImportAccounts action with CSV/Excel:
use App\Actions\ChartOfAccounts\ImportAccounts;
$importer = new ImportAccounts();
$result = $importer->execute($filePath);
Journal Entries:
$entry = JournalEntry::create()
->debit('Asset-Cash', 1000, 'USD')
->debit('Asset-Cash-EUR', 900, 'EUR') // Separate account for EUR
->credit('Liability-AP', 1900, 'USD') // Auto-converts if needed
->save();
RecurringEntry model:
RecurringEntry::create([
'description' => 'Monthly Rent',
'frequency' => 'monthly',
'journal_entry_data' => json_encode([...]), // Serialized entry data
]);
Financial Reports:
use AliMarchal\ChartOfAccounts\Facades\Reports;
$trialBalance = Reports::trialBalance()->forPeriod('2023-01-01', '2023-12-31')->get();
$incomeStatement = Reports::incomeStatement()->forPeriod('2023-01-01', '2023-12-31')->get();
ReportBuilder class or use query scopes:
$customReport = JournalEntry::query()
->with(['debits.account', 'credits.account'])
->whereHas('debits.account', fn($q) => $q->where('type', 'income'))
->get();
Bank Reconciliation:
ReconcileBankStatement action:
use App\Actions\ChartOfAccounts\ReconcileBankStatement;
$reconciler = new Reconciler($statementFile, $accountId);
$result = $reconciler->execute();
$entry->markAsReconciled($reconciliationNote);
Frontend Integration:
ChartOfAccountsTable component:
use AliMarchal\ChartOfAccounts\Livewire\ChartOfAccountsTable;
<livewire:chart-of-accounts-table :accounts="$accounts" />
GET /api/accounts) and use the provided React hooks:
import { useChartOfAccounts } from '@alimarchal/chart-of-accounts/react';
function AccountsPage() {
const { accounts, loading } = useChartOfAccounts();
// Render accounts...
}
php artisan vendor:publish --tag="chart-of-accounts-nova"
AccountFactory and JournalEntryFactory for unit tests:
use AliMarchal\ChartOfAccounts\Database\Factories\AccountFactory;
$account = AccountFactory::new()->create(['type' => 'asset']);
JournalEntryCreated):
event(new JournalEntryCreated($entry));
Currency Conversion:
exchangeRate config but defaults to a fixed rate if not set.
Fix: Configure config/chart-of-accounts.php with a service like exchangerate-api:
'currency' => [
'default' => 'USD',
'conversion_service' => 'exchangerate-api',
'api_key' => env('EXCHANGE_RATE_API_KEY'),
],
convertCurrency() helper:
$amountInEUR = JournalEntry::convertCurrency(1000, 'USD', 'EUR');
Account Types:
asset vs. liability) can break reports.
Fix: Validate types during creation:
$validTypes = ['asset', 'liability', 'equity', 'income', 'expense'];
if (!in_array($request->type, $validTypes)) {
throw new \InvalidArgumentException("Invalid account type.");
}
AccountType enum for type safety:
use AliMarchal\ChartOfAccounts\Enums\AccountType;
$account = Account::create([
'type' => AccountType::ASSET,
]);
Journal Entry Validation:
JournalEntryValidationException.
Debug: Check the failed() method on the entry:
$entry = JournalEntry::create()
->debit('Asset-Cash', 1000)
->credit('Equity-OwnerCapital', 500); // Will fail (500 != 1000)
if ($entry->fails()) {
dd($entry->errors());
}
dryRun() method to validate before saving:
if (!$entry->dryRun()->isValid()) {
return back()->withErrors($entry->errors());
}
Performance:
// Add to JournalEntry model
public function scopeForPeriod($query, $start, $end) {
return $query->where('date', '>=', $start)
->where('date', '<=', $end)
->orderBy('date');
}
$trialBalance = Cache::remember("trial_balance_{$year}", now()->addHours(1), function () use ($year) {
return Reports::trialBalance()->forYear($year)->get();
});
Multi-Tenancy:
// In Account model
protected static function booted() {
static::addGlobalScope('tenant', function (Builder $builder) {
$builder->where('tenant_id', auth()->user()->tenant_id);
});
}
'debug' => env('APP_ENV') === 'local',
Logs journal entries and reconciliation steps to `storageHow can I help you explore Laravel packages today?