Work / SaaS Platform

Multi-Tenant SaaS Platform

Several companies on one Laravel app and one PostgreSQL database. React is the screen. If a query forgets tenant_id, it does not throw. It lists another company’s projects.

  • Multi-tenancy
  • RBAC
  • REST APIs
  • Background jobs
  • Queue processing
  • Redis caching
  • Audit logging
  • Cloud deployment

01

Project overview

I built this as one Laravel codebase with a React UI. More than one company uses it. The hard part was keeping their data apart, including when a job runs later with nobody logged in.

02

While someone is using it

One company was not going to get its own app. Several companies had to share the codebase and the database, and still only see their own projects, members, and files. A report that runs while the user waits on the page was already too slow.

A person signs in, works inside their company, and leaves. Creating a project is finished before the page moves on. An export is not. The screen shows Queued, and the file appears later.

  1. Sign in

    Laravel reads the membership and sets the company before any controller runs. React does not send a company id. The project list is only that company’s rows.

  2. Open a project

    A direct id from another company does not return that project. The query has no row for this tenant, so the page gets nothing to show.

  3. A member acts

    A member can open projects. Invite, export, the audit log, and deleting the company are refused. An owner’s invite writes the member and the audit row in the same transaction, so the screen and the log agree.

  4. Start an export

    The request stores the job with this company id and returns. The user can log out. The worker sets that company again, then writes the file. The screen asks again and shows Queued, then Ready, or the error. It does not sit on the download.

  5. Same project name

    Two companies can both have a project called “Site survey”. The unique index includes tenant_id, so the second insert is a new row, not a clash.

03

Business problem

A shared database is fine until a query forgets the company id. It does not throw. It just shows the wrong projects. Reports and imports were also too slow to run while the user waited on the page.

04

Requirements

  • Each organization sees only its own projects, members and files.
  • Roles differ inside a tenant: owner, manager and member do not share the same actions.
  • Heavy work leaves the web request and can be retried without creating duplicate records.
  • Operators can tell which tenant a failing job belonged to.
  • The same application can be deployed as containers without tenant-specific code branches.

05

Who can do what

The company check runs first. The role check runs second. A member of company A still cannot open company B, and inside their own company they cannot do what an owner can.

Action Owner Manager Member
See this company’s projects Yes Yes Yes
Create and edit a project Yes Yes No
Invite a member Yes Yes No
Change a role or remove a member Yes No No
Run an export or a report Yes Yes No
Read the audit log Yes Yes No
Delete the company Yes No No

None of these actions accept a company id from the browser. The membership on the session decides the company, including when the export runs later on a worker.

06

How it’s wired

React calls a Laravel API. Laravel decides the company from the logged-in membership, not from an id the browser can edit. PostgreSQL holds the records. Redis holds cache, locks, and the queue. Workers run outside the request. Files and the deploy sit on AWS. Docker is how the app and the worker are started.

  1. React
  2. Laravel API
  3. Policies and tenant scope
  4. PostgreSQL
  5. Redis queue
  6. Workers

07

What I used

These are the tools on this project. I didn’t add anything just to fill a stack diagram.

  • Laravel
  • React
  • PostgreSQL
  • Redis
  • AWS
  • Docker

08

Technical challenges

  • Tenant scope has to be the default, including on jobs, exports and signed URLs. A global Eloquent scope that developers can forget on one query is how leaks happen.
  • Shared-schema multi-tenancy keeps operations simpler than a database per customer, but only if every table that holds customer data carries a tenant key and every unique index includes it.
  • Queues need a tenant id on the payload. A worker that “uses the last logged-in tenant” will corrupt data the moment two jobs overlap.

09

Solution

One database schema. tenant_id on every table that belongs to a company. Middleware sets the company before a controller runs. Policies sit on top of that, so a real user in company A still cannot open company B’s project. Jobs save the company id on the payload and set it again when they start. Cache keys start with that id too.

10

Implementation

The Laravel side is split the way the product is split: companies, members, projects, audit. Controllers stay small. If two rows have to succeed together, that happens in one transaction. React only talks to the API and does not get to pick the company id. The audit row is written in the same transaction as the change.

11

Testing

Feature tests create two tenants and assert that a user from one cannot read or update the other, including through list endpoints, direct ids and queued jobs. Policy tests cover each role. A migration test checks that tenant-owned tables have a tenant_id index. Queue tests run a job twice and confirm the unique key prevents a second write.

12

Deployment

The API, the worker and the scheduler are separate containers built from the same image. They differ by command, not by codebase. PostgreSQL and Redis stay outside the web container. Migrations run as a release step before traffic moves. Cloudflare or the load balancer only routes to the web container; workers are not publicly reachable.

13

Outcome

A user stays inside their own company on the page and in the queue. When something fails, I can see which company, which job, and which audit row. That was the point of the build.

14

What I decided

  • Shared schema instead of a database per tenant, because backups, migrations and connection counts stay manageable while the tenant count is still growing. A later move to schema-per-tenant is possible if a customer needs a hard administrative boundary.
  • PostgreSQL rather than a document store, because memberships, projects and permissions are relational and benefit from transactions and composite unique keys.
  • Redis for cache and queues so the first version does not add another broker. If job volume outgrows Redis, the worker boundary is the piece that changes, not the React or policy layer.

Start a project