Skip to content
~/tariqul.islam
/projects

/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.

architecture — at a glance
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

Illustrated architecture overview of Aorabooks — multi-tenant ERP, one PostgreSQL database per tenant
fig. 01 — /projects/aorabooks/cover

screenshot pending (NDA-safe crop)

drop /public/images/projects/aorabooks/01.jpg · 1600×900 · see docs/images.md

fig. 02 — screenshot, primary view

screenshot pending (NDA-safe crop)

drop /public/images/projects/aorabooks/02.jpg · 1600×900 · see docs/images.md

fig. 03 — screenshot, detail view

05 — from the codebase

From the codebase

The defining decision: true data isolation — a database per tenant, rebound per request, Octane-safe.

Per-tenant database switch by subdomain (app/Helpers/SaasHelper.php)
// 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.

Offline POS bulk-sync job (app/Jobs/ProcessOfflineSyncJob.php)
// 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 clearance (app/Services/ZatcaService.php)
// 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.

/projects/aorabooks/next

Need something in this neighborhood?

If this case study sounds like your problem — same domain, same scale, same kind of mess — I've already made the expensive mistakes for you. Tell me where you're stuck.