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 inresources/js/pages. - Platform admin — a server-rendered Blade panel at
/adminused 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.
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
| Model | Notes |
|---|---|
| Company | Tenant root: currency, symbol/position, financial year, tax, invoice numbering, delivery settings. |
| User | Tenant member; belongs to many companies; spatie roles scoped per company. |
| Customer, Product, Vendor | Directory + catalogue. |
| Invoice, InvoiceItem | Stored totals; status derived from payments/credits/dates. |
| Payment, PaymentAllocation | A payment can settle many invoices; leftovers are customer credit. |
| CreditNote | Applied against an invoice; optional refund out of an account. |
| BankAccount | Balance = opening balance + signed transactions. |
| LedgerAccount | Chart of accounts; optional in a transaction. |
| Transaction | Single-entry ledger line; linked to a bank/ledger account and source document. |
| Expense, ExpenseCategory | Money out; attachments; recurrence. |
| AuditLog | Immutable create/update/void trail. |
| DocumentSequence | Atomic per-company numbering. |
| EmailTemplate, EmailLog | Notification 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.
creatingstampscompany_idfrom the context.savingthrows 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.
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:
| Command | Purpose |
|---|---|
invoices:send-reminders | Due-soon and overdue reminders (daily). |
expenses:generate-recurring | Creates due recurring expenses (daily). |
mail:test {email} | Verifies outbound mail configuration. |
10. Environment variables
| Variable | Notes |
|---|---|
APP_ENV | production in live environments. |
APP_DEBUG | false in production. |
APP_URL | Full https URL; the demo email domain derives from its host. |
DB_CONNECTION | mysql (prod) or sqlite. |
DB_HOST/PORT/DATABASE/USERNAME/PASSWORD | Database credentials. |
SESSION_DRIVER | database. |
CACHE_STORE | database (or redis). |
QUEUE_CONNECTION | database (or redis). |
FILESYSTEM_DISK | Default disk (uploads use the private local disk in code). |
MAIL_MAILER | smtp in production; log locally. |
MAIL_HOST/PORT/USERNAME/PASSWORD | SMTP credentials. |
MAIL_FROM_ADDRESS, MAIL_FROM_NAME | Platform sender identity. |
SMS_DRIVER | SMS gateway: log, twilio, vonage, http, null. |
TWILIO_*, VONAGE_* | Credentials for the Twilio / Vonage drivers. |
SMS_HTTP_URL, SMS_HTTP_METHOD, SMS_HTTP_TOKEN | Generic HTTP gateway (SSLWireless, BulkSMSBD, …). |
DEMO_EMAIL, DEMO_PASSWORD | Optional 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:
- Migration with a
company_idforeign key. - Model using
BelongsToCompany(+Auditable, andVoidablefor financial records) and a factory. - Policy mapping abilities to roles; register it in
AppServiceProvider. - Form Requests for store/update.
- Service for business logic; route any money movement through
LedgerPostingService. - Controller + routes under the
companymiddleware. - Inertia pages in
resources/js/pages; reuseDataTableandFormControls. - Tests: feature tests plus a tenant-isolation case; add the page to
SmokeTest.
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=falseand serve over HTTPS in production.
15. Debugging
storage/logs/laravel.log— application log (emails go here when the mailer islog).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.