Building Multi-Tenant Support in a User-Based SaaS App (case study)

By Oleksandr Andrushchenko, Published on

Introduction

Many SaaS applications begin with a very simple idea: one user signs up, creates data, and owns that data. This model works extremely well in the early stages of a product. It is easy to reason about, easy to implement, and easy to secure.

However, as the product grows, a common requirement appears: team collaboration. Multiple users need access to the same resources. Managers need oversight. Organizations need separation.

This case study explains how a purely user-based SaaS application was transformed into a multi-tenant system. The explanation is written in a practical way, focusing on concepts junior developers can clearly understand.

Phase 1: The Original User-Based Architecture

At the beginning, there was no concept of accounts or organizations. There were only users.

users
id | email | password | created_at | ...

All business resources belonged directly to a user:

projects
id | user_id | name | created_at | ...

User-based to multi-tenant model
User-based to multi-tenant model

How Authorization Worked

Authorization was extremely straightforward. To check if a user could access a resource, the system simply compared IDs:

if ($project->user_id == auth()->user()->id) {
    // allow access
}

In plain English:

  • “Does this project belong to the currently logged-in user?”
  • If yes → allow access
  • If no → deny access

This model is simple and safe for single-user ownership. But it has a big limitation: a resource can only belong to one person.

Why This Became a Problem

  • Two users cannot manage the same project
  • A manager cannot oversee multiple team members
  • There is no concept of organization or company
  • Scaling toward enterprise customers becomes difficult

At this point, we needed to rethink ownership entirely.

Phase 2: Introducing Accounts (Tenant Boundaries)

The key architectural decision was this:

Resources should belong to accounts, not users.

An account represents an organization or workspace. Users become members of accounts. Accounts own the data.

New Accounts Table

accounts
id | name | created_at | updated_at

Important: originally, this table did not exist. It was introduced as a completely new concept.

Think of it like this:

  • Users = people
  • Accounts = organizations
  • Resources = things owned by organizations

Connecting Users and Accounts

Because one user can belong to multiple accounts, and one account can have multiple users, we need a many-to-many relationship.

The account_user Pivot Table

Schema::create('account_user', function (Blueprint $table) {
    $table->unsignedBigInteger('account_id');
    $table->unsignedBigInteger('user_id');
    $table->json('roles');
    $table->primary(['account_id', 'user_id']);
    $table->timestamps();
});

Why This Design Matters

  • Composite primary key ensures the same user cannot be added twice to the same account.
  • JSON roles column allows storing multiple roles without modifying the database schema.
  • The relationship is flexible and future-proof.

Example roles JSON:

["owner", "admin"]

This means the user has both owner and admin privileges inside that account.

Refactoring Resource Ownership

The most important change was removing user_id from resource ownership.

Old Structure

projects
id | user_id | name | ...

New Structure

projects
id | account_id | name | ...

This change simplifies the mental model:

  • Resources belong to accounts
  • Users access resources through membership
  • No more direct user ownership

We intentionally did not keep user_id. Authorship was not required — only ownership.

Data Migration Strategy

Since accounts did not exist before, we had to create them for existing users.

Step 1: Create One Account Per Existing User

Every user received their own account during migration.

Step 2: Insert Membership

INSERT INTO account_user (account_id, user_id, roles)
VALUES (?, ?, '["owner"]');

Each user became the owner of their new account.

Step 3: Move Resource Ownership

UPDATE projects
SET account_id = ?
WHERE user_id = ?;

Step 4: Remove user_id Column

After verifying the data migration, the user_id column was removed from resource tables.

Authorization Refactor: The Key Conceptual Shift

Before

$project->user_id == auth()->user()->id

Identity-based ownership.

After

in_array(
  $project->account_id,
  auth()->user()->accounts->pluck('id')->toArray()
);

Membership-based ownership.

Why This Matters

  • Authorization is no longer about “who created it”
  • It is about “which account owns it”
  • Users gain access through membership

This small code change represents a major architectural evolution.

Role-Based Access Control

Because roles are stored in JSON, we can easily extend permissions.

if (!in_array('admin', $accountUser->roles)) {
  abort(403);
}

This enables:

  • Different permission levels per account
  • Team hierarchies
  • Future policy-based authorization

Final Outcome

  • Users can belong to multiple accounts
  • Accounts own all resources
  • Authorization is scalable and clean
  • The system supports real-world team collaboration

Key Takeaway

The biggest lesson is this:

Multi-tenancy is not just a database change — it is a shift in how you think about ownership.

Instead of asking:
“Does this belong to this user?”

You start asking:
“Does this belong to an account this user is a member of?”

Once you understand that mental model, multi-tenant architecture becomes much easier to design and maintain.