Skip to Content
Course content

176: Building a Multi-Tenant SaaS Backend Structure

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I once worked with a junior dev who was building a project management SaaS. He had a "Tasks" table and a "Tenants" table. Everything seemed great during local development because he was only testing with one account. Then, he pushed to staging, created two different client accounts, and realized that when Client A logged in and clicked "View All Tasks," they saw Client B's private project milestones and internal notes. He'd committed the cardinal sin of multi-tenant development: forgetting the tenant_id in a WHERE clause.

// The "Nightmare" Code
public function index() {
    // He thought he was getting the current user's tasks...
    // But he actually fetched every single task in the entire database
    $tasks = Task::all(); 
    
    return view('tasks.index', ['tasks' => $tasks]);
}

The Danger of Manual Filtering

The bug here is obvious once you see it, but it's incredibly easy to repeat. If you rely on yourself (or your team) to remember to add ->where('tenant_id', $tenantId) to every single query in your application, you will eventually miss one. In a SaaS environment, a missing WHERE clause isn't just a bug; it's a critical security breach that can get your company sued or shut down.

The fix isn't just to add the missing line of code to that one controller. The fix is to move the responsibility of tenant isolation out of the controller and into the data layer itself, so it happens automatically.

Enforcing Isolation with Global Scopes

If you're using an ORM like Eloquent in Laravel (or a similar pattern in other PHP frameworks), the most robust way to handle this is through a Global Scope. I prefer this because it makes the isolation "invisible" to the developer writing the business logic.

// 1. Create a Trait to handle the logic
trait BelongsToTenant {
    protected static function bootBelongsToTenant() {
        static::addGlobalScope('tenant', function ($builder) {
            // We assume there is a TenantManager class that knows who is logged in
            $builder->where('tenant_id', TenantManager::getTenantId());
        });

        // Automatically assign tenant_id when creating new records
        static::creating(function ($model) {
            $model->tenant_id = TenantManager::getTenantId();
        });
    }
}

// 2. Use it in your Model
class Task extends Model {
    use BelongsToTenant;
}

Now, when you call Task::all(), the underlying SQL becomes SELECT * FROM tasks WHERE tenant_id = 5. You don't have to think about it, and you can't "forget" it. I've found this approach saves hours of debugging and prevents those midnight panic calls when a client reports seeing someone else's data.

Choosing Your Isolation Strategy

While the shared-database approach (row-level isolation) we just discussed is the most common for early-stage SaaS, it's not the only way. Depending on your clients' needs—especially if you're dealing with healthcare or high-finance data—you might need a stricter boundary.

  • Shared Database, Shared Schema: What we just did. Fast to scale, easy to migrate, but carries the risk of data leaks if your scopes are buggy.
  • Shared Database, Separate Schemas: Each tenant gets their own schema (e.g., in PostgreSQL). It's a middle ground that provides better isolation but makes running migrations across 1,000 tenants a headache.
  • Database per Tenant: The gold standard for security. Each client has a completely separate physical database. If one database gets corrupted, others are safe. However, it's expensive to maintain and a nightmare to manage connections at scale.

My advice? Start with row-level isolation and a strict Global Scope. Only move to separate databases if you have a specific contractual requirement for "physical data separation" or if your database size per tenant is so massive that it's slowing down the rest of your users.




📋 Practical Task

Implement an Automated Tenant-Aware Document Repository

You are building a "Secure Document Storage" SaaS. You have a Document model and a TenantManager class that stores the current active tenant_id in the session.

Your Task:

  1. Create a PHP Trait named Multitenant that implements a mechanism to automatically filter all queries by the tenant_id provided by TenantManager::getId().
  2. Ensure that whenever a new Document is saved to the database, the tenant_id is automatically injected so the developer doesn't have to set it manually in the controller.
  3. Implement a method within the trait or model called withoutTenantScope() that allows an administrator (and only an administrator) to fetch documents across all tenants for reporting purposes.

Provide the code for the Trait and the Model implementation.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.