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