michael-orenda / general-ledger
Audit-grade, append-only General Ledger for Laravel 12 with strict accounting invariants, period locking, and sub-ledger integration.
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/michael-orenda/general-ledger
Requires
- php: ^8.2
- illuminate/database: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
- michael-orenda/chart-of-accounts: ^1.0
- michael-orenda/fiscal: ^1.0
- rminchrist/organisations: ^1.0
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
README
A robust, test-driven General Ledger (GL) package for Laravel 12 designed as a reusable component for accounting systems. Written to be embedded as a package in larger host applications.
Table of contents
- Overview
- Key Concepts
- Requirements
- Installation
- Configuration
- Database Migrations & Seeds
- Models & Relationships
- Services
- Controllers & Routes (API)
- Validation & Business Rules
- Database Constraints & Triggers
- Events & Listeners
- Testing
- Security & Concurrency
- Packaging & Release
- Troubleshooting
- Examples & Use-Cases
- Contributing
- Changelog
- License
Overview
This package provides a General Ledger implementation that follows standard double-entry accounting principles, shaped for multi-organisation, multi-fiscal-period systems. It aims to be:
- Composable: usable as a Laravel package or integrated directly into your mono-repo.
- Test-first: shipped with Testbench-powered tests.
- Safe: includes DB-level protections (checks/triggers), API guards, and service-layer validation.
- Extensible: sub-ledgers can post to the GL through a clear contract.
Primary capabilities:
- Chart of Accounts (COA) -> auto-created ledger accounts
- Posting journal entries (balanced debit/credit)
- Ledger accounts and entries listing & reporting
- Period locking and enforcement
- Opening/closing period handling
- Services to generate opening balances
Key Concepts
- Organisation: top-level tenant scoping most data.
- FiscalYear / FiscalPeriod: fiscal time boundaries. Periods can be
openorclosed. - LedgerAccount: representation of an account in the GL (asset, liability, equity, revenue, expense).
- LedgerEntry: posted transactional rows tied to a ledger account. Entries are created by posting a
JournalEntrywhich contains multiple debit/creditJournalLines. - Sub-ledger: domain modules (loans, payments, payroll) that use a
PostsToGeneralLedgercontract to post entries.
Requirements
- PHP 8.2+
- Laravel 12
- Database: MySQL 5.7+/MariaDB or Postgres (some DB-triggers examples provided are for MySQL; adapt for Postgres)
- ext-mbstring, ext-json
Installation
Install via composer (package name to replace with the published packagist name):
composer require michaelorenda/general-ledger
Publish config, migrations and seeds (if the package provides publishable assets):
php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="config" php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="migrations" php artisan vendor:publish --provider="MichaelOrenda\GeneralLedger\GeneralLedgerServiceProvider" --tag="seeds"
Run migrations:
php artisan migrate
Seed default Chart of Accounts and a sample organisation (optional):
php artisan db:seed --class="\MichaelOrenda\GeneralLedger\Database\Seeders\ChartOfAccountsSeeder" php artisan db:seed --class="\MichaelOrenda\GeneralLedger\Database\Seeders\OrganisationSeeder"
Configuration
config/general-ledger.php (sensible defaults):
return [ 'default_currency' => env('GL_DEFAULT_CURRENCY', 'KES'), 'default_start_of_day' => '00:00:00', 'journal_prefix' => env('GL_JOURNAL_PREFIX', 'JE'), 'locking' => [ 'enable_db_triggers' => true, ], // mapping of account classes/types 'accounts' => [ 'asset' => 1, 'liability' => 2, 'equity' => 3, 'revenue' => 4, 'expense' => 5, ], ];
Explaination of key options:
default_currency: currency used when posting amounts that do not specify currency explicitly.locking.enable_db_triggers: set tofalseif you prefer application-level enforcement only.
Database Migrations & Seeds
The package includes the following recommended tables:
ledger_accounts(id, organisation_id, code, name, account_class, normal_balance, created_at, updated_at)ledger_entries(id, organisation_id, ledger_account_id, journal_id, amount, type (debit|credit), description, fiscal_period_id, created_at)journalsorjournal_entries(id, organisation_id, reference, narration, posted_by, posted_at)fiscal_years(id, organisation_id, start_date, end_date)fiscal_periods(id, organisation_id, fiscal_year_id, start_date, end_date, is_closed, created_at)
Migrations should include indexes on organisation_id, fiscal_period_id and ledger_account_id for performance.
Seeders
ChartOfAccountsSeeder— seeds a canonical set of ledger accounts.LedgerAccountsSeeder— creates account instances per organisation from the COA.
Models & Relationships
Outline of essential Eloquent models and their core relationships:
-
LedgerAccount- belongsTo
Organisation - hasMany
LedgerEntry
- belongsTo
-
LedgerEntry- belongsTo
LedgerAccount - belongsTo
FiscalPeriod - belongsTo
JournalEntry
- belongsTo
-
JournalEntry(orJournal)- hasMany
JournalLineorLedgerEntry
- hasMany
-
FiscalPeriod- belongsTo
FiscalYear - hasMany
LedgerEntry
- belongsTo
Add docblocks and typed properties on each model for clarity and static analysis support.
Services
This section documents all service classes in the General Ledger package, their responsibilities, invariants, and example usage patterns. These services are the only supported way to interact with the ledger at the domain level. Controllers, commands, and sub-ledgers must call services — never manipulate models directly.
Design rule: Services encapsulate business invariants. Models remain persistence-focused.
AccountLedgerService
Purpose
Produces a ledger (running balance) for a single account over a date or fiscal-period range.
Responsibilities
- Fetch ledger entries for a given account
- Sort chronologically
- Compute running balances (debit/credit aware)
- Support date-range or fiscal-period filtering
Typical Use Cases
- Account detail view in accounting UI
- Audit or reconciliation workflows
Example
$ledger = $accountLedgerService->getLedger( organisationId: 1, ledgerAccountId: 101, from: '2025-01-01', to: '2025-01-31' );
Returns
- Collection of ledger rows with:
- entry date
- debit / credit
- running balance
AuditTrailService
Purpose
Provides a read-only, immutable audit trail of all ledger activity.
Responsibilities
- Expose who posted what and when
- Correlate journals, ledger entries, and users
- Support forensic review and compliance
Important Rule
Audit data is never mutated — even reversals create new entries.
Example
$audit = $auditTrailService->forJournal($journalId);
GeneralLedgerReportService
Purpose
Produces the official General Ledger report used for statutory reporting.
Responsibilities
- Aggregate all ledger accounts
- Group by account class
- Respect fiscal-period boundaries
- Produce balances as-of a date or period
Example
$report = $glReportService->generate( organisationId: 1, fiscalPeriodId: 12 );
LedgerQueryService
Purpose
Low-level query abstraction for ledger entries.
Responsibilities
- Centralize common ledger queries
- Avoid query duplication across services
- Apply organisation and period scoping consistently
Example
$entries = $ledgerQueryService ->forOrganisation(1) ->forPeriod(12) ->forAccount(101) ->get();
LedgerPostingService
Purpose
Lowest-level service responsible for inserting ledger entries.
Important
You should never call this directly from controllers. Use
JournalPostingServiceinstead.
Responsibilities
- Insert debit/credit rows
- Enforce period open-state
- Operate strictly inside transactions
Used internally by:
JournalPostingServiceOpeningBalanceServiceReversalJournalService
JournalPostingService
Purpose
Primary entry point for posting accounting transactions.
Responsibilities
- Validate journal structure
- Enforce balancing (debits == credits)
- Validate fiscal period openness
- Persist journal + ledger entries atomically
- Emit
JournalPostedevent
Example
$journalId = $journalPostingService->post([ 'organisation_id' => 1, 'fiscal_period_id' => 12, 'reference' => 'INV-1001', 'narration' => 'Invoice payment', 'lines' => [ ['ledger_account_id' => 10, 'type' => 'debit', 'amount' => 1000], ['ledger_account_id' => 200, 'type' => 'credit', 'amount' => 1000], ], ]);
Throws
DomainException— unbalanced journalLogicException— posting to closed period
JournalReversalService / ReversalJournalService
Purpose
Creates a reversing journal for an existing journal.
Responsibilities
- Clone original journal lines
- Swap debit ↔ credit
- Preserve auditability
- Prevent double-reversal
Example
$reversalJournalId = $journalReversalService->reverse( journalId: 55, reason: 'Invoice cancelled' );
Rule
Reversals must always post into an open fiscal period.
TrialBalanceService
Purpose
Produces a Trial Balance for a given period or date.
Responsibilities
- Aggregate debits and credits per account
- Ensure totals balance
- Act as a validation gate before period close
Example
$trialBalance = $trialBalanceService->generate( organisationId: 1, fiscalPeriodId: 12 );
If the trial balance does not balance, period close must be blocked.
OpeningBalanceService
Purpose
Generates opening balances for a new fiscal period.
Responsibilities
- Carry forward asset/liability balances
- Zero revenue and expense accounts
- Transfer net income to retained earnings
- Enforce idempotency
Example
$openingBalanceService->generate( organisationId: 1, fromPeriodId: 12, toPeriodId: 13 );
PeriodCloseService / FiscalPeriodCloseService
Purpose
Safely closes a fiscal period.
Responsibilities
- Generate Trial Balance
- Validate balance correctness
- Generate opening balances for next period
- Lock the period (DB + application)
- Emit
PeriodClosedevent
Example
$periodCloseService->close( organisationId: 1, fiscalPeriodId: 12 );
Hard Guarantees
- Closed periods are immutable
- Posting attempts fail at service + DB level
All core business logic lives in service classes. Keep controllers thin.
JournalPostingService
Responsibilities:
- Validate payload shape (presence of
fiscal_period_id,organisation_id, balancedlines). - Ensure the target
FiscalPeriodisopenbefore posting. - Persist a
JournalEntryand correspondingLedgerEntryrows within a DB transaction. - Emit domain events (e.g.,
JournalPosted,LedgerUpdated).
Example usage:
$service->post([ 'organisation_id' => 1, 'fiscal_period_id' => 12, 'reference' => 'INV-1001', 'narration' => 'Payment for invoice 1001', 'lines' => [ [ 'ledger_account_id' => 10, 'type' => 'debit', 'amount' => 1000.00, 'description' => 'Cash' ], [ 'ledger_account_id' => 200, 'type' => 'credit', 'amount' => 1000.00, 'description' => 'Revenue' ], ], 'posted_by' => 5, ]);
Important checks inside post():
fiscal_period_idexists and belongs to organisation.- Period is not closed (
$period->is_closed === false). - Sum of debit amounts equals sum of credit amounts.
- Each
ledger_account_idexists and belongs to the organisation (or is globally allowed per config).
Transaction handling:
- Use
DB::transaction()around create operations. - Lock rows where necessary (see concurrency section) to avoid race conditions.
PeriodCloseService
Responsibilities:
- Calculate final balances for the period.
- Move balances into next-period opening balances (create
OpeningBalanceentries). - Prevent further posting to closed periods (DB triggers + application-level flags).
Example:
$periodCloseService->close($organisationId, $fiscalPeriodId);
OpeningBalanceService
Generates the opening balances for a new fiscal period from previous period totals and retained earnings transformations. Should be idempotent — calling it multiple times should not duplicate balances.
Controllers & Routes (API)
Provide API endpoints namespaced under /api/general-ledger or similar. Use API resources for consistent responses.
Example routes:
Route::prefix('api')->group(function () { Route::prefix('general-ledger')->middleware(['auth:api'])->group(function () { Route::get('accounts', [AccountController::class, 'index']); Route::get('accounts/{id}', [AccountController::class, 'show']); Route::post('journals', [JournalController::class, 'store']); Route::get('journals/{id}', [JournalController::class, 'show']); Route::post('periods/{id}/close', [PeriodController::class, 'close']); }); });
Controller responsibilities:
- Validate requests with FormRequests.
- Use service classes to perform operations.
- Return HTTP 400/422 for business validation failures (e.g., unbalanced journal or closed period) and 500 for unexpected errors.
Example API Request — Post Journal
POST /api/general-ledger/journals
Payload:
{
"organisation_id": 1,
"fiscal_period_id": 12,
"reference": "INV-1001",
"narration": "Payment for invoice 1001",
"lines": [
{"ledger_account_id": 10, "type": "debit", "amount": 1000.00, "description": "Cash"},
{"ledger_account_id": 200, "type": "credit", "amount": 1000.00, "description": "Revenue"}
]
}
Success: HTTP 201 with created journal details. Failure: HTTP 422 with validation errors or HTTP 409 if period closed.
Validation & Business Rules
- Journals must be balanced (total debits == total credits).
- Every entry must reference a valid ledger account.
- Cannot post into a closed period — enforce at service-level and with DB-level constraints/triggers.
- Opening balances must be posted to the first period of the fiscal year only through the
OpeningBalanceService. - Avoid floating point rounding errors: store amounts as integers representing the smallest currency unit (e.g., cents) or use
decimal(20,4)depending on requirements.
Database Constraints & Triggers
To prevent accidental posting to closed periods at the DB level (MySQL example):
DELIMITER $$ CREATE TRIGGER prevent_closed_period_posting BEFORE INSERT ON ledger_entries FOR EACH ROW BEGIN IF EXISTS ( SELECT 1 FROM fiscal_periods WHERE id = NEW.fiscal_period_id AND is_closed = 1 ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot post to a closed fiscal period'; END IF; END$$ DELIMITER ;
Create similar triggers for BEFORE UPDATE and BEFORE DELETE depending on policy. In the migration files, add these triggers conditionally (guard with if (Schema::hasTable('fiscal_periods')) { ... }).
Note: not all DBs support triggers or
SIGNALsemantics. If you need Postgres, implement plpgsql triggers or prefer application-level enforcement.
Events & Listeners
Emit meaningful domain events:
JournalPosted(payload: journal id, organisation id, posted_by)PeriodClosed(period id, organisation id)OpeningBalancesGenerated(period id)
Listeners can:
- Update cached ledgers or materialized views
- Send notifications to admins
- Integrate sub-ledger reconciliations
Example event dispatch:
event(new JournalPosted($journal));
Testing
- Use
orchestral/testbenchfor package testing with Laravel. - Include unit tests for: service logic (balanced journals, period checks), model factories, API endpoints.
- Include integration tests that run migrations and seeders.
Suggested tests:
JournalPostingServiceTest— posts valid/invalid journals; asserts DB rows and events emitted.PeriodCloseServiceTest— closes period; checks opening balances created for subsequent period.TriggersMigrationTest— if your environment supports triggers, assert that triggers prevent posting to closed periods.
Run tests:
composer test # or vendor/bin/phpunit
Security & Concurrency
- Always wrap posting in DB transactions.
- Use row-level locks for balances where needed (e.g.,
SELECT ... FOR UPDATE) to prevent concurrent postings causing inconsistent totals. - Validate
organisation_idfor multi-tenant isolation. - Avoid storing user-supplied JSON directly — sanitize structured data.
Packaging & Release
- Use semantic versioning (MAJOR.MINOR.PATCH).
- Create a
README.md(this document),CHANGELOG.mdand tags for releases. - Run static analysis (
phpstan), tests, andcomposer validatebefore tagging.
Suggested git commit message for major release:
chore(release): v1.0.0 — initial stable GL package
Troubleshooting
- "Cannot post to closed fiscal period" — check if period
is_closedflag is set; if you suspect trigger misfire, check migrations created DB triggers. - Undefined variable or method errors — ensure service injection in constructors and proper imports.
- Intelephense type complaints — add docblocks and typed properties; for collection vs int errors, ensure method signatures match usage (pass collections where expected).
If you run into issues, enable detailed logs (set APP_DEBUG=true) and inspect laravel.log.
Examples & Use-Cases
Posting a Sales Invoice (from a SubLedger)
- The invoice module builds a
Journalpayload. - It validates business rules (customer exists, amount positive).
- Calls
JournalPostingService::post($payload). - The GL persists entries, emits
JournalPostedand the invoice marks itselfposted_to_gl.
Closing a Period (end of month)
- Admin triggers
POST /api/general-ledger/periods/{id}/close. - Controller calls
PeriodCloseService::close()inside a transaction. - Service computes retained earnings transfers and creates opening balances for the next period.
fiscal_period.is_closedset totrueand DB triggers now block further inserts.
Contributing
- Fork repository
- Create feature branch:
feature/awesome-thing - Implement code + tests
- Submit PR and ensure CI passes
Coding standards: PSR-12, typed properties where helpful, docblocks for public APIs.
Changelog
- Unreleased: initial comprehensive README and structure guidance.
License
MIT — see LICENSE in the repository.
Appendix: Helpful Snippets
DB Transaction skeleton (service):
DB::transaction(function () use ($payload) { $journal = Journal::create([...]); foreach ($payload['lines'] as $line) { LedgerEntry::create([...]); } });
Check balanced:
$totalDebits = array_sum(array_map(fn($l) => $l['type'] === 'debit' ? $l['amount'] : 0, $lines)); $totalCredits = array_sum(array_map(fn($l) => $l['type'] === 'credit' ? $l['amount'] : 0, $lines)); if (bccomp((string)$totalDebits, (string)$totalCredits, 4) !== 0) { throw new \DomainException('Journal is not balanced.'); }
Trigger creation in migration (MySQL):
Add migration content carefully and conditionally — see up()/down() examples in package migrations.
If you'd like, I can also:
- Produce a printable PDF of this README.
- Generate a
README_short.md(one-page quickstart). - Create the API OpenAPI (Swagger) spec from the routes.
Tell me which of those you want and I will generate them next.