← Documentation home Developer Guide

Developer Guide

Architecture, setup, the financial engine, and how to extend the platform.

1. Architecture

A Laravel backend serves two user interfaces:

  • Customer portal — the accounting app, a React 19 + TypeScript SPA rendered through Inertia at /portal. Pages live in resources/js/pages.
  • Platform admin — a server-rendered Blade panel at /admin used by the SaaS operator to manage tenants (suspend, reactivate, impersonate).

Domain logic lives in service classes (app/Services). HTTP controllers stay thin: authorize → validate (Form Requests) → call a service → return an Inertia response or redirect. All money movements flow through the ledger posting service.

2. Stack & requirements

Backend

Laravel 13, PHP 8.4+, Eloquent, Form Requests, Policies.

Frontend

React 19, TypeScript, Inertia, Vite, Tailwind CSS 4.

Database

MySQL 8 (production) / SQLite (local default).

Packages

spatie/laravel-permission (teams), barryvdh/laravel-dompdf.

Required PHP extensions: mbstring, xml, curl, zip, bcmath, intl, json, gd/dom (for PDF), and your database driver (pdo_mysql or pdo_sqlite).

Node.js 20+ is needed to build assets.

3. Local setup

# 1. Install dependencies
composer install
npm install

# 2. Environment
cp .env.example .env
php artisan key:generate

# 3. Database (SQLite default)
touch database/database.sqlite
php artisan migrate

# 4. Seed admin + demo data (optional but recommended)
php artisan db:seed --class=AdminSeeder
php artisan db:seed --class=SiteSettingsSeeder
php artisan db:seed --class=DemoDataSeeder

# 5. Build assets (or run the dev server)
npm run build
# npm run dev

# 6. Serve
php artisan serve
php artisan queue:work      # in a second terminal (queued email)

Demo logins after seeding: demo@<host> (owner), accountant@demo.test, staff@demo.test — password password. Platform admin: admin@example.com / password.

One command composer run dev starts the PHP server, queue listener, log viewer and Vite together (see composer.json).

4. Project structure

app/
├── Console/Commands/       SendTestEmail, SendInvoiceReminders, GenerateRecurringExpenses
├── Enums/                  CompanyRole, Permission, InvoiceStatus, PaymentMethod,
│                           TransactionType, TransactionDirection, LedgerAccountType
├── Http/
│   ├── Controllers/Admin/   platform console (Blade)
│   ├── Controllers/Portal/  accounting app (Inertia)
│   ├── Middleware/          ResolveCurrentCompany, AddRequestContext, AdminAuth, …
│   └── Requests/Portal/     Form Requests
├── Models/                 Company, Customer, Invoice, Payment, Expense, Transaction, …
│   └── Concerns/           BelongsToCompany, Auditable, Voidable
├── Policies/               one policy per model
├── Services/
│   ├── Accounting/         LedgerPostingService, PaymentService, CreditNoteService, ExpenseService
│   ├── Invoicing/          InvoiceCalculator, InvoiceService, InvoiceDeliveryService
│   ├── Reporting/          ReportService, DashboardService
│   ├── Documents/          InvoicePdfService
│   ├── Imports/            CsvImportService
│   ├── Payments/           StripeGateway (checkout + webhook verification)
│   ├── Sms/                SmsManager + drivers (log, twilio, vonage, http)
│   └── Export/             TenantDataExporter
│   └── (CompanyProvisioning, DocumentNumber, AuditLogger, EmailTemplate, EmailLogger)
└── Support/                CompanyContext, Money, FinancialYear, ListQuery

database/migrations · factories · seeders
resources/js/
├── layouts/PortalLayout.tsx
├── components/             DataTable, FormControls, Toast
├── lib/invoiceTotals.ts    client-side totals mirror
└── pages/                  one folder per module
resources/views/            admin blade, emails, documents (PDF), reports
routes/web.php · routes/console.php
tests/Feature · tests/Unit · tests/Concerns

5. Domain model

ModelNotes
CompanyTenant root: currency, symbol/position, financial year, tax, invoice numbering, delivery settings.
UserTenant member; belongs to many companies; spatie roles scoped per company.
Customer, Product, VendorDirectory + catalogue.
Invoice, InvoiceItemStored totals; status derived from payments/credits/dates.
Payment, PaymentAllocationA payment can settle many invoices; leftovers are customer credit.
CreditNoteApplied against an invoice; optional refund out of an account.
BankAccountBalance = opening balance + signed transactions.
LedgerAccountChart of accounts; optional in a transaction.
TransactionSingle-entry ledger line; linked to a bank/ledger account and source document.
Expense, ExpenseCategoryMoney out; attachments; recurrence.
AuditLogImmutable create/update/void trail.
DocumentSequenceAtomic per-company numbering.
EmailTemplate, EmailLogNotification templates and delivery log.

6. Financial engine

Money

All amounts are integer minor units (cents). App\Support\Money is an immutable value object with half-up rounding, currency scales (JPY = 0 decimals) and formatting. Formatting can be overridden per company (custom symbol / position) via Money::configureFormatting(), called by the tenancy middleware.

Invoice calculation

InvoiceCalculator is the single source of truth. Order: line subtotal (qty × price) → line discount → invoice discount (allocated across lines with the rounding remainder on the last line) → per-line tax (exclusive, or extracted when inclusive). The client-side mirror in resources/js/lib/invoiceTotals.ts powers the live preview only — the server always recomputes and stores the totals.

Ledger

LedgerPostingService is the only place money movements are written. It supports in/out postings, transfers (paired entries) and reversals (counter-entries). Balances and reports are always derived from transactions; nothing mutates a balance directly. Voiding reverses via a counter-entry so history stays intact.

Document numbering

DocumentNumberService issues atomic, per-company numbers under a row lock (document_sequences). Gaps are permitted (voided documents keep their number).

7. Multi-tenancy

Every domain table has a company_id and uses the BelongsToCompany trait:

  • A global scope filters queries to the active company.
  • creating stamps company_id from the context.
  • saving throws if you try to persist a record for another company.

App\Support\CompanyContext is a request-scoped singleton holding the active company id. ResolveCurrentCompany (route middleware company) resolves it from the session (or the user's first company) and also sets the spatie permission team and the Money formatting. When no context is active (console, seeds), the scope is a no-op.

Provisioning another company When creating a company from within an active company, switch CompanyContext to the new company for the duration of provisioning (see CompanyProvisioningService::createFor), otherwise the tenant guard blocks writing its categories/accounts/templates.

8. Authorization

Three roles per company: owner, accountant, staff (App\Enums\CompanyRole), each mapping to coarse abilities (App\Enums\Permission). Gates are registered in AppServiceProvider and evaluated against the active company's team.

Use Gate::authorize(Permission::X) in controllers and Gates in policies. Every tenant route is covered by a policy that also checks company membership. UI hiding is never the only control.

9. Queues & scheduler

Queued work (invoice/reminder/statement emails) uses the database queue by default. Run a worker in production:

php artisan queue:work --tries=3 --timeout=90

Scheduled tasks are declared in routes/console.php and need a cron entry:

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

Registered commands:

CommandPurpose
invoices:send-remindersDue-soon and overdue reminders (daily).
expenses:generate-recurringCreates due recurring expenses (daily).
mail:test {email}Verifies outbound mail configuration.

10. Environment variables

VariableNotes
APP_ENVproduction in live environments.
APP_DEBUGfalse in production.
APP_URLFull https URL; the demo email domain derives from its host.
DB_CONNECTIONmysql (prod) or sqlite.
DB_HOST/PORT/DATABASE/USERNAME/PASSWORDDatabase credentials.
SESSION_DRIVERdatabase.
CACHE_STOREdatabase (or redis).
QUEUE_CONNECTIONdatabase (or redis).
FILESYSTEM_DISKDefault disk (uploads use the private local disk in code).
MAIL_MAILERsmtp in production; log locally.
MAIL_HOST/PORT/USERNAME/PASSWORDSMTP credentials.
MAIL_FROM_ADDRESS, MAIL_FROM_NAMEPlatform sender identity.
SMS_DRIVERSMS gateway: log, twilio, vonage, http, null.
TWILIO_*, VONAGE_*Credentials for the Twilio / Vonage drivers.
SMS_HTTP_URL, SMS_HTTP_METHOD, SMS_HTTP_TOKENGeneric HTTP gateway (SSLWireless, BulkSMSBD, …).
DEMO_EMAIL, DEMO_PASSWORDOptional override for the demo account.
AWS_*Only when using an S3-compatible disk (see deployment guide).

11. Testing

php artisan test          # or ./vendor/bin/pest
./vendor/bin/pint         # PHP code style
npm run lint              # TypeScript / React
npm run build             # production assets

Tests use Pest with RefreshDatabase. Helpers in tests/Pest.php:

  • userWithCompany('owner') — returns [$user, $company] with a role.
  • actingAsCompany($user, $company) — authenticates and sets the active company.

tests/Concerns/AssertsTenantIsolation provides assertCrossTenantDenied() used across modules. Two suites guard against regressions the unit tests miss: a per-module feature suite and SmokeTest, which renders every page.

12. Conventions

  • Validation in Form Requests; authorization in Policies / Gates; domain logic in services.
  • Controllers stay thin and return Inertia responses or redirects with a flash message.
  • Tenant models use BelongsToCompany; financial records are voided with a reason and audited, never silently hard-deleted.
  • Never trust the client for money — recompute on the server.
  • Comments only where intent is non-obvious; no emojis in UI, logs or messages.

13. Extending the app

A typical new module:

  1. Migration with a company_id foreign key.
  2. Model using BelongsToCompany (+ Auditable, and Voidable for financial records) and a factory.
  3. Policy mapping abilities to roles; register it in AppServiceProvider.
  4. Form Requests for store/update.
  5. Service for business logic; route any money movement through LedgerPostingService.
  6. Controller + routes under the company middleware.
  7. Inertia pages in resources/js/pages; reuse DataTable and FormControls.
  8. Tests: feature tests plus a tenant-isolation case; add the page to SmokeTest.
Adding a notification Add a key to EmailTemplate::KEYS and a default in EmailTemplateService::DEFAULTS, then send via the relevant service.

14. Security notes

  • Tenant isolation is enforced by global scope + policies, and proven by tests.
  • Uploads are validated by MIME and size and stored on the private disk (storage/app/private), outside the web root; downloads are authorized.
  • Admin and portal are separate guards; sessions are database-backed and regenerated on login.
  • Audit logs are immutable.
  • Set APP_DEBUG=false and serve over HTTPS in production.

15. Debugging

  • storage/logs/laravel.log — application log (emails go here when the mailer is log).
  • php artisan pail — live tail of logs.
  • php artisan queue:failed / queue:retry all — failed jobs.
  • php artisan route:list — confirm routes and middleware.
  • php artisan tinker — inspect data/services interactively.