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

Eloquent Ifrs Laravel Package

ekmungai/eloquent-ifrs

Eloquent IFRS adds double-entry accounting to Laravel, generating IFRS-compliant financial statements. Supports multiple entities, account categorization, VAT, opening balances, forex revaluation, tamper-protected ledgers, and configurable receivables aging.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish config

composer require "ekmungai/eloquent-ifrs"
php artisan vendor:publish   # pulls config/ifrs.php into your app

The config file is the single most important thing to read first: it defines account type labels/codes, report section titles, transaction type names, and the ifrs.user_model binding. If your User model isn't App\User, fix ifrs.user_model before migrating.

2. Prepare your User model, then migrate

The package extends your users table (adds entity_id, destroyed_at) and references it from ifrs_recycled_objects. It never creates the users table, so your users migration must run first (Laravel's default timestamp works). Then attach the traits:

use IFRS\Traits\IFRSUser;
use IFRS\Traits\Recycling;
use IFRS\Interfaces\Recyclable;

class User extends Authenticatable implements Recyclable
{
    use IFRSUser, Recycling;
}
php artisan migrate

3. Seed the accounting skeleton — in this order

Nothing posts until the context exists:

  1. Entity (the reporting company, your tenant boundary)
  2. Currency → assigned to $entity->currency_id
  3. Account rows for every Account::* type you'll touch (BANK, RECEIVABLE, PAYABLE, OPERATING_REVENUE, OPERATING_EXPENSE, CONTROL, NON_CURRENT_ASSET, …)
  4. Vat records, each pointing at a CONTROL account
  5. ReportingPeriod — posting validates that the transaction date lands in an open period

4. First use case: post a cash sale and read the Balance Sheet

$sale = CashSale::create([
    'account_id' => $bank->id,          // the "other side" — not yet in the ledger
    'date'       => now(),
    'narration'  => 'Cash sale #1',
]);

$line = LineItem::create([
    'account_id' => $revenue->id,
    'quantity'   => 1,
    'amount'     => 100,                // UNIT price, not line total
]);
$line->addVat($outputVat);
$sale->addLineItem($line);
$sale->post();                          // only now is it in the ledger

Then (new BalanceSheet('2026-12-31'))->getSections() — and stop there. toString() is explicitly a debug helper; build your own Blade/JSON rendering on top of getSections().

Where to look first, in order: config/ifrs.php → IFRS\Models\Account constants → IFRS\Transactions\* (the list of transaction types is effectively your business-event vocabulary) → IFRS\Reports\*. The full docs live at ekmungai.github.io/ifrs-docs; the README only covers the happy path.


Implementation Patterns

Wrap every posting in a DB transaction, and post in a domain service, not a controller.

A single post() performs multiple inserts (transaction, line items, VAT lines, assignment of balances). Treat it as an atomic unit:

class LedgerService
{
    public function recordClientInvoice(Invoice $invoice): ClientInvoice
    {
        return DB::transaction(function () use ($invoice) {
            if ($invoice->ifrs_transaction_id) {
                return ClientInvoice::findOrFail($invoice->ifrs_transaction_id);
            }

            $tx = ClientInvoice::create([
                'account_id' => $invoice->client->ifrs_account_id,
                'date'       => $invoice->issued_at,
                'narration'  => "Invoice {$invoice->number}",
            ]);

            foreach ($invoice->lines as $line) {
                $item = LineItem::create([
                    'account_id' => $line->account->ifrs_account_id,
                    'narration'  => $line->description,
                    'quantity'   => $line->qty,
                    'amount'     => $line->unit_price,
                ]);
                if ($line->vat) {
                    $item->addVat($line->vat->ifrsVat);
                }
                $tx->addLineItem($item);
            }

            $tx->post();

            $invoice->update(['ifrs_transaction_id' => $tx->id]);
            return $tx;
        });
    }
}

Keep your own documents table as the source of truth; the ledger as an append-only projection. Posted IFRS transactions are tamper-protected — you don't edit them, you recycle/reverse and re-post. Store ifrs_transaction_id on your invoices/bills/payments tables so you can always trace a document to its ledger entries. This also gives you free idempotency (the if guard above).

Map your business events onto the transaction taxonomy deliberately. CashSale, ClientInvoice, CashPurchase, SupplierBill, ClientReceipt, SupplierPayment, JournalEntry — write this mapping down in a docblock or a config array. It's the highest-value artefact you'll produce, because mis-picking the type silently distorts the Cash Flow Statement and the aging schedule rather than throwing an error.

Settlement is a separate step from posting. Receipts/payments only clear invoices/bills through Assignment:

Assignment::create([
    'assignment_date' => now(),
    'transaction_id'  => $receipt->id,
    'cleared_id'      => $invoice->id,
    'cleared_type'    => $invoice->clearedType,   // never hardcode this string
    'amount'          => 50,
]);

Model this as its own service (settle()) with a rule: never assign more than min($invoice->balance, $receipt->balance). Partial clearing is normal; over-clearing is not.

Multi-entity: make the active entity explicit at every entry point. The package resolves the entity from the authenticated user's entity_id. Anything running outside an HTTP request (queues, scheduler, artisan commands, tests) needs that context established first — authenticate a user or set the context in a middleware/job base class. A nightly report job that silently runs as "no entity" produces empty reports rather than an error.

Cache reports, don't recompute them per page view. getSections() fans out across the ledger. Cache::remember("bs:{$entityId}:{$date}", 600, ...) keyed on entity + date range, and bust the key on any post()/Assignment/period close. Same for TrialBalance, AgingSchedule and CashFlowStatement.

Testing pattern that pays for itself: one LedgerFixtures trait that seeds entity, currency, chart of accounts, VAT rates and an open reporting period, plus an assertion helper:

$this->assertLedgerBalanced(); // sum of debits == sum of credits for the entity

Run it after every posting test. Half of all bugs in this package's integrations are unbalanced postings or a line item attributed to the wrong account type.


Gotchas and Tips

amount is a unit price, not a line total. quantity × amount is the line value. A quantity-4 line at amount => 100 books 400. This is the single most common integration bug; add a assertLineTotal() sanity check in your service.

Nothing exists until post(). Model::create([...]) on a transaction is an intermediate save. If your code path can return before post(), you get an orphaned draft that never reaches the ledger — and no error. Never expose an unposted transaction to the UI as if it were real.

Reporting periods are a hard gate. Transactions dated outside an open ReportingPeriod fail. Common causes: seeding test data with now() while the period is set to a past calendar_year; backfilling historical imports; a period that was never created for the new year. Add a php artisan ifrs:ensure-period or a scheduled job before year-end, or your first January post fails in production.

Entity must have a currency_id before any posting. Easy to miss because Entity::create() succeeds without it. Validate it in your entity provisioning flow.

destroyed_at, not deleted_at. The Recycling trait uses a non-standard column and a separate ifrs_recycled_objects table. Any raw SQL, DB::table queries, BI tooling or withoutGlobalScopes() reads must filter whereNull('destroyed_at') explicitly — otherwise recycled transactions reappear in your numbers. Same warning goes for any read replica or analytics export.

Deleting is recycling; correcting is reversing. Posted entries are protected against direct DB changes by design. Build an "amend document" flow that recycles the original and posts a new one, and never let anyone near php artisan tinker on the ledger.

VAT accounts must be Account::CONTROL. Pointing a Vat record at an operating account breaks the Balance Sheet's control-account line without failing loudly. Assert the account type when you create VAT rates.

cleared_type must come from $transaction->clearedType, never a literal string. It's derived from the model's morph alias; a hardcoded 'ClientInvoice' will write assignments that only half-resolve.

Categorisation drives report quality. Account::* types determine whether an item lands in Operating vs Non-Operating, Current vs Non-Current, and whether the Cash Flow Statement reconciles. Get an accountant (or the docs' account-type table) to review your chart of accounts before go-live — re-typing accounts after the fact means reposting every historical transaction.

Forex and aging are config-driven. Closing rates, aging buckets (Current / 31–60 / 61–90…) and forex-difference posting are set in config/ifrs.php, not in code. If the aging schedule shows one bucket only, you almost certainly never published the config.

Extension points, in increasing order of cost: config overrides (labels, bucket sizes, user model) → subclassing a IFRS\Transactions\* type to change narration/validation → custom Account types defined in config → wrapping everything behind your own LedgerService facade. If you need to reach past the third option, fork.

Ecosystem risk. 367 stars, zero dependents, MIT, active releases — but effectively a single-maintainer package whose domain (IFRS compliance) is legally consequential. Pin the version in composer.json, keep your LedgerService as the only place your app touches the package, and write integration tests around the transaction types you actually use. That way a breaking major release is a one-file change, and a reporting misstatement is caught by a test rather than by an auditor.

Version matrix is strict: PHP 8.2+, Laravel 11/12/13. Check this against your app before the install, not after composer require resolves.

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.
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
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
adachsoft/changelog-linter