/projects/aorabooks
2024–2026
Aorabooks — multi-tenant ERP, one PostgreSQL database per tenant
- client:
- Product for a SaaS vendor — SMB ERP & commerce, emerging markets
- role:
- Technical Project Manager & Tech Lead
- Laravel
- PHP
- PostgreSQL
- Redis
- Docker
- Livewire
- Bootstrap
- Stripe
01 — the problem
What was actually at stake
ERP buyers in emerging markets ask a question most SaaS can't answer cleanly: where does our data live, and who else is in it? The usual multi-tenant answer is "a shared table with a tenant_id column" — and in markets where a dealer's ledger, a shop's customers, and a competitor's orders can't sit in the same row, that answer loses the deal. Before a platform like this, the same businesses stitched together separate accounting software, a disconnected POS, and WhatsApp for deliveries, reconciling the three by hand.
Real isolation means a database per tenant, which reads as simple until you run it on a pooled application server. The tenant has to be resolved on every request from its subdomain, the database connection swapped to that tenant's credentials, and the previous connection torn down so the next request served by the same Octane worker can't read the previous tenant's data. On top of that, the point-of-sale has to keep taking money when the shop's internet drops — then reconcile those sales into the correct tenant database, exactly once, when it comes back.
02 — the architecture
How it was built
Every organization gets its own PostgreSQL database. A request enters through OctaneTenantMiddleware, which calls SaasOrganization() to resolve the org from the subdomain (cached with rememberForever, custom domains supported), then dbConnectByDomain() purges the pooled connections and rebinds the pgsql_md connection to that tenant's database name, user, and password before setting it as the default. The reserved app subdomain stays on the central control-plane database that holds organizations, packages, and billing. Connections are purged at the start of each request specifically so Laravel Octane's long-lived workers never serve one tenant's data to the next.
The operational layer is built around the same per-tenant switch. The offline POS posts batched operations to a bulk-sync endpoint that dispatches ProcessOfflineSyncJob onto Redis/Horizon; the job re-enters the correct tenant database, wraps each operation in a transaction, keys every batch by a sync_id so retries update rather than duplicate, and leans on Laravel's 3-attempt retry for transient failures. Roughly twenty scheduled commands (ZATCA status sync, invoice and payment reminders, WhatsApp delivery reports, auto punch-out) each switch into the right tenant database before running. Saudi ZATCA Phase-2 e-invoicing signs each invoice locally — hash and QR persisted immediately so the till can print — then reports or clears it with the tax authority, degrading to NOT_CLEARED instead of failing the sale.
- isolation
- one PostgreSQL database per tenant · `app` subdomain = control plane
- routing
- subdomain → org lookup, cached `rememberForever` · custom domains supported
- runtime
- Laravel Octane · connections purged + rebound per request to stop tenant bleed
- resilience
- offline POS → queued bulk-sync · 3 retries · idempotent by `sync_id`
- integrations
- ZATCA Phase 2 · Stripe/bKash/SSLCommerz/PayPal · Reverb · ZKTeco biometric
03 — the scope
The full build, end to end
I owned the platform end to end as TPM and tech lead: a Laravel 12 modular monolith of 14 modules covering double-entry accounting, POS and sales with ZATCA e-invoicing, purchasing, manufacturing (BOM), inventory with variants and attributes, CRM and route distribution, VAT, HR with biometric attendance and payroll, warranty, affiliate and reseller programs, financial reporting, and the SaaS control plane that provisions tenant databases, subscriptions, packages, and payment gateways. The footprint runs to 448 migrations and 200+ Eloquent models, ~20 scheduled commands and 30 queued jobs, a Sanctum-secured API used by an offline POS, and an operations stack of Octane, Horizon on Redis, Reverb websockets, Pulse, and scheduled Dropbox/Google-Drive backups — packaged for Docker on PostgreSQL 17.
- 14
- modules
- 200+
- models
- 448
- migrations
- 5
- payment rails
modules & features
- Double-entry accounting
- Chart of accounts, journal entries, and ledger management; sales and deliveries post journal entries.
- Sales & POS
- Invoices, receipts, returns, and a point-of-sale with register sessions and offline bulk-sync.
- ZATCA e-invoicing
- Saudi Phase-2: local signing (hash + QR), clearance/reporting to the tax authority, status-sync command.
- Purchases
- Purchase orders, bills, returns, and per-item detail tracking feeding stock and accounting.
- Production
- Manufacturing orders and bills of material with production tracking.
- Inventory & catalog
- Products with variants, attributes, and SKUs; tax groups and stock-management settings.
- CRM & distribution
- Customer/supplier contacts plus route/van-sales distribution with payment-collection reporting.
- HR & attendance
- Employee records, leave, payroll, and biometric attendance via ZKTeco device integration.
- Reporting
- P&L, balance sheet, cash flow, and sales/purchase reports scoped to the tenant's data.
- Warranty
- Product warranty registration and tracking tied to sales.
- Affiliate & reseller
- Referral and commission management plus a white-label reseller program with referral capture.
- Notifications
- In-app, email, and FCM push channels, with Reverb websockets for real-time delivery.
- SaaS control plane
- Tenant/org provisioning (database-per-tenant), subscription billing, packages, coupons, payment gateways.
- Module manager
- Install, license, and toggle addon modules per deployment.
04 — the visuals
What it looks like
screenshot pending (NDA-safe crop)
drop /public/images/projects/aorabooks/01.jpg · 1600×900 · see docs/images.md
screenshot pending (NDA-safe crop)
drop /public/images/projects/aorabooks/02.jpg · 1600×900 · see docs/images.md
05 — from the codebase
From the codebase
The defining decision: true data isolation — a database per tenant, rebound per request, Octane-safe.
// One PostgreSQL database per tenant. Resolve by subdomain, swap the
// connection on every request — and purge first so Octane workers never
// serve one tenant's data to the next.
function dbConnectByDomain(string $domain): bool
{
if ($domain === 'app') { // central / control-plane DB
DB::setDefaultConnection('pgsql');
setDefaultTimezone();
return true;
}
$org = Organization::where('db_removed', false)
->where('domain', $domain)
->first(['db_database', 'db_username', 'db_password']);
if (! $org) {
return false;
}
DB::purge('pgsql');
DB::purge('pgsql_md');
config([
'database.connections.pgsql_md.database' => $org->db_database,
'database.connections.pgsql_md.username' => $org->db_username,
'database.connections.pgsql_md.password' => $org->db_password,
]);
DB::setDefaultConnection('pgsql_md'); // tenant is now the default
return true;
}Resilience: outage-tolerant POS — tenant-scoped, transactional, idempotent by sync_id across 3 retries.
// ProcessOfflineSyncJob — public $tries = 3; public $timeout = 300;
public function handle(): void
{
// Never block the till: the POS sells offline; we reconcile here,
// into the right tenant DB, idempotently.
if (! dbConnectByDomain($this->domain)) {
return;
}
// sync_id makes replays safe: a re-sent batch updates, never duplicates.
$log = OfflineSyncLog::firstOrNew(['sync_id' => $this->syncId]);
$log->fill(['type' => $this->type, 'status' => 'processing'])->save();
DB::beginTransaction();
try {
$result = match ($this->type) {
'session_opened' => $this->processSessionOpen(),
'session_closed' => $this->processSessionClose(),
'sale' => $this->processSale(),
default => "Unknown type: {$this->type}",
};
DB::commit();
$log->update(['status' => 'completed', 'result' => $result]);
} catch (Throwable $e) {
DB::rollBack();
$log->update(['status' => 'failed', 'error_message' => $e->getMessage()]);
throw $e; // re-throw → queue retries
}
}Integration / the hard part: government e-invoicing with cryptographic signing that degrades gracefully.
// ZATCA Phase-2 e-invoicing. Sign locally, then report/clear with the
// tax authority — but a rejection must never fail the sale itself.
public static function clearance(int $invoiceId, int $companyId, string $type = 'invoice')
{
if (! companySetting($companyId, 'enable_e_invoicing', 0)) {
return true; // feature off → no-op
}
$invoiceData = self::mapInvoiceData($invoiceId, $companyId, $type);
$signed = InvoiceSigner::signInvoice(
GeneratorInvoice::invoice($invoiceData)->getXML(),
$certificate
);
// Persist hash + QR up front so the till can print immediately.
$status = $sale->zatcaStatus()->firstOrNew();
$status->zatca_invoice_hash = $signed->getHash();
$status->zatca_qr_code = $signed->getQRCode();
$status->save();
$response = $sale->is_bill_to_bill
? $api->clearance($signed->getXML(), $signed->getHash(), $invoiceData['uuid'])
: $api->reporting($signed->getXML(), $signed->getHash(), $invoiceData['uuid']);
$status->zatca_status = $response->success()
? ($sale->is_bill_to_bill ? 'CLEARED' : 'REPORTED')
: 'NOT_CLEARED'; // degrade, don't throw
$status->save();
}06 — the outcome
What the numbers say
- isolated database per tenant
- 1 DB
- sales blocked when the network drops
- 0
- ZATCA e-invoicing, live
- Phase 2
"Where does our data live?" stopped being an objection and became part of the pitch.