Case StudyLaravel · React · Inertia.js · Multi-Tenancy · SaaS

FocusApp

Building a production-grade, multi-tenant ERP and CRM platform from scratch — with database-per-tenant isolation, real-time collaboration, zero-downtime deploys, and GST-ready accounting.

5
Major Modules Built from Scratch
17-step
Zero-Downtime Deploy Pipeline
3
Infrastructure Layers (Staging · Prod · Dedicated)
v1.5.3
5 Releases Shipped in Under 5 Months
Abstract infrastructure and platform architecture

About the Project

What is FocusApp?

FocusApp is a fully hosted, subscription-based ERP and CRM platform built for SMBs that need sales pipeline management, task workflows, production tracking, email campaigns, and accounting — all under one roof, scoped entirely to their own tenant.

The product is multi-tenant by design: every customer gets an isolated subdomain (client.focusapp.cloud), their own database, their own encryption key, their own set of permissions governed by the subscription plan they are on, and no visibility into any other tenant's data.

The challenge was not just building the features. It was building a platform architecture that could hold all of them — reliably, securely, and with the ability to deploy continuously without taking any tenant offline.

The Core Problem

Most off-the-shelf ERP tools are either too rigid or too expensive for growing businesses. None of them offered multi-tenant isolation, dynamic plan-based permissions, SPA-quality UX, zero-downtime CI/CD, PWA mobile support, and GST accounting in a single product. It had to be built.

SMB
Market Focus
SaaS
Delivery Model

Isolation Model

Database-per-tenant

A mis-scoped query fails with a connection error — not a data leak.

Permission Model

Plan-driven, live updates

Upgrade a client in the admin panel — their instance reflects it within minutes.

Deploy Model

17-step zero-downtime

Single git push deploys both nodes, byte-identical assets, no tenant interruption.

Engineering Deep-Dive

Eight Hard Problems, Eight Real Solutions

Each challenge required a deliberate architectural decision — not just a library install. Here is what we built and why.

01

Multi-Tenancy Without a Bloated ORM

Problem

True database-per-tenant multi-tenancy means every query must be automatically scoped to the correct tenant's database. One mis-scoped query is a data breach.

Solution

Built on stancl/tenancy with subdomain middleware. Each tenant gets a dedicated MySQL database and a per-tenant encryption key generated at provisioning time. A CompanyRequiredMiddleware further narrows scope so a misrouted request fails loudly rather than leaking data silently.

stancl/tenancyMySQLSubdomain Routing
02

Dynamic Permissions Tied to Subscription Plans

Problem

Permission systems are usually static — seeded once, done. But in a SaaS product where tiers unlock different modules, permissions must change when a plan changes — instantly, without developer involvement.

Solution

A two-layer architecture: a central Admin Panel defines plans and permission groups; a permissions:update artisan command fetches the current plan via JWT-authenticated API and upserts it into the tenant DB, clearing all caches. Runs on every deploy and can be triggered via a GitLab CI variable alone.

Artisan CommandJWT AuthGitLab CIRedis Cache
03

Modern SPA Feel Without Ditching the Server

Problem

A pure React SPA means a full JS bundle hydration before first paint — poor for a CRM with heavy data tables. Pure Blade means no shared component state and no React ecosystem.

Solution

Inertia.js as the bridge. The server renders initial page data (no API round-trip on first load), Inertia handles client-side navigation (no full page reloads), and React components manage all interactivity. Core high-traffic pages migrated first; a v2.0 consolidation path was documented as explicit technical debt.

Inertia.jsReact 18Laravel 11Vite
04

Real-Time Collaboration Across Tenants

Problem

Task comments, status changes, and import progress needed to appear instantly. Polling at the scale of hundreds of concurrent users across tenants would saturate the server.

Solution

Laravel Reverb (first-party WebSocket server) backed by Redis. Each tenant's events broadcast on private, tenant-scoped channels — a comment on tenant-a never appears on tenant-b. Laravel Echo on the React frontend subscribes and updates UI state without re-rendering the page.

Laravel ReverbRedisWebSocketLaravel Echo
05

Email Infrastructure at Scale

Problem

Transactional emails (OTPs, device approvals) and bulk marketing emails are fundamentally different workloads. Mixing them on one SMTP lets marketing bounce rates contaminate transactional delivery.

Solution

Brevo for transactional email with per-tenant domain verification and webhook secret validation. A separate bulk mail system with a step-based wizard, per-credit deduction only on confirmed delivery, live job cancellation, and a low-credit warning banner before the tenant hits zero.

BrevoZoho MailQueue JobsWebhooks
06

Zero-Downtime Deployments Across Three Environments

Problem

A Laravel app with migrations, permission reseeding, and a Vite build is not trivially zero-downtime. On a two-server load-balanced production cluster, both nodes must serve byte-identical assets without serving a mix of old and new code mid-deploy.

Solution

A 17-step GitLab CI script: pull → composer install → Vite build (with OOM fix) → maintenance mode → migrations → permissions update → cache rebuild → maintenance mode off → PHP-FPM reload. The primary node builds and tarballs the frontend; the CI runner transfers it to the replica — the replica never runs npm run build.

GitLab CISelf-hosted RunnerPHP-FPMSupervisor
07

Mobile-First PWA on a Complex CRM

Problem

Field sales reps use the app on mobile. A CRM with rich-text editors, audio recording, drag-and-drop attachments, and real-time task feeds is hard to make feel native on a phone browser.

Solution

PWA manifest with iOS/Android install support and a Service Worker for offline caching. Absolute positioning for iOS viewport bar compensation, keyboard offset handling so inputs are never hidden behind the software keyboard, and prefetch visit handling to skip loaders on hover-triggered navigation.

PWAService WorkerWeb Push APISafari Compat
08

GST-Ready Accounting Without Contaminating the CRM

Problem

Indian SMBs need GST-ready accounting — outward/inward supply aggregation, GSTR-3B summaries, per-financial-year scoping — bolted onto a CRM without polluting the contact data model.

Solution

A double-entry-style module with auto-provisioned ledgers from contacts and suppliers, sale/purchase invoices with HSN codes and GST breakdowns, and a GstSummaryService that aggregates supplies per period and exports GSTR-3B/GSTR-1 compatible JSON and CSV. All company-scoped with atomic DB transactions.

MySQL TransactionsGSTFinancial Year ScopingGSTR Export
Abstract server infrastructure and data pipeline

From Architecture → Implementation → Production

Technology Stack

What it's built on

Every technology was chosen for a specific reason — cost at scale, first-party integration, or architectural fit.

Backend

Laravel 11 · PHP 8.4

Frontend

React 18 · Inertia.js · Vite · Tailwind CSS

Real-Time

Laravel Reverb · Laravel Echo · Redis

Database

MySQL (per-tenant)

Search

MeiliSearch

Storage

DigitalOcean Spaces (S3-compatible)

Email

Brevo · Zoho Mail

Payments

Razorpay (webhooks)

Infrastructure

DigitalOcean Droplets · Managed DB · Pulumi (IaC)

CI/CD

GitLab CI · Self-hosted runner

PWA

Service Worker · Web Push API

Why Inertia.js?

Full SPA would have required API versioning, token management, and CORS — tripling the surface area for a team focused on shipping features. Inertia gives 90% of the SPA feel at 30% of the architectural complexity.

Why Laravel Reverb?

First-party integration, no per-connection pricing, self-hosted within the DigitalOcean network (zero egress cost for WebSocket traffic). For a multi-tenant product with hundreds of persistent connections, Pusher would have made real-time economically unviable at scale.

Why database-per-tenant?

Shared schema with a tenant_id column is faster to build but dangerous to maintain. One missing where clause is a data breach. Database-per-tenant makes isolation architectural — a mis-scoped query fails with a connection error, not a data leak.

Outcome

Before and After

5 major versions. 1 platform. Zero-downtime in production since go-live.

Deployment

Before

Manual SSH, ad-hoc steps

After

17-step CI/CD, zero-downtime, auto on push

Permissions

Before

Hardcoded permissions — code change required

After

Admin panel → CI variable → live in minutes

Multi-Tenancy

Before

Single-tenant or shared-schema

After

Database-per-tenant, per-tenant encryption keys

Frontend

Before

Server-rendered Blade only

After

Inertia.js SPA with React, full PWA

Collaboration

Before

Refresh to see updates

After

WebSocket-powered live tasks, comments, imports

Email

Before

Single SMTP for everything

After

Transactional + bulk separated, credit-metered

FocusApp went from a prototype with hardcoded permissions and manual deploys to a production SaaS serving real tenants — with isolated databases, live collaboration, native mobile UX, GST-ready accounting, and a CI pipeline that deploys the entire stack in a single git push.

Have a complex platform to build or scale?

We specialise in architecting and shipping full-stack SaaS products — from day one to production. Let's talk.