DEV Community

Cover image for Building Multi-Tenant SaaS with Laravel: The LaraFoundry Way
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building Multi-Tenant SaaS with Laravel: The LaraFoundry Way

Over the past few weeks, I've been building LaraFoundry - a modular SaaS engine for Laravel. The biggest challenge? Getting multi-tenancy right.

In this article, I'll share my approach: database structure, automatic isolation with traits, and comprehensive testing strategies.

The Multi-Tenancy Challenge

When building SaaS applications, you need to isolate data between tenants (companies, organizations, accounts). Get it wrong, and Company A might see Company B's data. 😱

There are three main approaches:

  1. Separate databases per tenant - Full isolation, complex operations
  2. Separate schemas per tenant - Medium isolation, medium complexity
  3. Shared database, row-level isolation - Simple operations, requires discipline

For LaraFoundry, I chose option 3. Here's why.

Why Shared Database?

Pros:

  • ✅ Simpler operations (one database to manage)
  • ✅ Better for 100-1000 tenants
  • ✅ Cost-effective
  • ✅ Easier backups and migrations
  • ✅ Cross-tenant reporting (for admins)

Cons:

  • ⚠️ Requires careful coding (no manual filtering!)
  • ⚠️ Global scopes are critical
  • ⚠️ Testing is essential

For Kohana (my testing ground for LaraFoundry), shared database made sense. If I needed to scale to 10,000+ tenants, I'd reconsider.

Database Schema

Here's the core structure:

CREATE TABLE companies (
    id BIGINT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    subdomain VARCHAR(100) UNIQUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    company_id BIGINT NOT NULL,
    name VARCHAR(255),
    email VARCHAR(255),
    FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
    UNIQUE(company_id, email),
    INDEX(company_id)
);

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    company_id BIGINT NOT NULL,
    user_id BIGINT,
    total DECIMAL(10,2),
    FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
    INDEX(company_id, created_at)
);
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. company_id on every tenant-scoped table - Non-nullable
  2. Foreign keys with CASCADE - Delete company = delete all data
  3. Composite indexes - (company_id, created_at) for performance
  4. Unique constraints - (company_id, email) prevents duplicates within company

The BelongsToCompany Trait

Manual where('company_id', ...) everywhere is error-prone. One mistake = data leak.

Solution: Global scopes via a trait.

<?php

namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;

trait BelongsToCompany
{
    protected static function bootBelongsToCompany(): void
    {
        static::addGlobalScope('company', function (Builder $builder) {
            if (auth()->check() && auth()->user()->company_id) {
                $builder->where(
                    $builder->getModel()->getTable() . '.company_id',
                    auth()->user()->company_id
                );
            }
        });
    }

    public function company()
    {
        return $this->belongsTo(Company::class);
    }

    public function scopeForCompany(Builder $query, int $companyId): Builder
    {
        return $query->withoutGlobalScope('company')
            ->where($this->getTable() . '.company_id', $companyId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

class Order extends Model
{
    use BelongsToCompany;  // ← One line!
}

// Now ALL queries are automatically filtered:
Order::all();  // Only current company's orders
Order::find(1);  // Only if it belongs to current company
Enter fullscreen mode Exit fullscreen mode

Admin queries:

// See all orders across companies
Order::withoutGlobalScope('company')->get();

// Or use dedicated scope
Order::forCompany(5)->get();
Enter fullscreen mode Exit fullscreen mode

The Relationship Gotcha

Global scopes don't auto-apply to relationships. This is a common pitfall:

$order = Order::find(1);  // ✅ Filtered by company
$order->items;  // ⚠️ Might include other companies' items!
Enter fullscreen mode Exit fullscreen mode

Solution: Explicitly scope relationships:

// In Order model
public function items()
{
    return $this->hasMany(OrderItem::class)
        ->where('company_id', auth()->user()->company_id);
}

// Or use a scope
public function items()
{
    return $this->hasMany(OrderItem::class)->forCompany();
}
Enter fullscreen mode Exit fullscreen mode

Explicit is better than implicit for multi-tenancy.

Testing Multi-Tenancy

This is critical. I have 47 tests dedicated to tenant isolation.

Here's a sample with Pest PHP:

it('filters orders by company', function () {
    // Arrange: Create two companies with data
    $companyA = Company::factory()->create();
    $companyB = Company::factory()->create();

    $orderA = Order::factory()->for($companyA)->create();
    $orderB = Order::factory()->for($companyB)->create();

    // Act: Login as user from Company A
    actingAs(User::factory()->for($companyA)->create());

    // Assert: Should only see Company A's order
    expect(Order::count())->toBe(1)
        ->and(Order::first()->id)->toBe($orderA->id);
});

it('prevents cross-company access', function () {
    $companyA = Company::factory()->create();
    $companyB = Company::factory()->create();

    $orderB = Order::factory()->for($companyB)->create();

    actingAs(User::factory()->for($companyA)->create());

    // Should not find Company B's order
    expect(Order::find($orderB->id))->toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

Test categories I use:

  1. Isolation tests - Company A can't see Company B's data
  2. Relationship tests - Relationships are properly scoped
  3. Admin tests - Admins can bypass scopes when needed
  4. Performance tests - Queries use proper indexes

Lessons Learned

1. Start with tests

Write multi-tenancy tests FIRST, before implementing features. It's your safety net.

2. Use database constraints

Don't rely solely on Laravel. Add NOT NULL and foreign keys at the database level.

3. Explicit > implicit for relationships

Global scopes are great, but explicitly scope relationships. Better safe than sorry.

4. Monitor query performance

Composite indexes (company_id, id) are essential. Use Laravel Debugbar or Telescope.

5. Document assumptions

Write comments like // Automatically filtered by BelongsToCompany trait. Future you will thank past you.

What's Next

For LaraFoundry, I'm now working on:

  • Role-based permissions (multi-tenant aware)
  • Activity logging (per company)
  • API versioning with tenant context

Follow my journey on X/Twitter where I share daily updates, code snippets, and challenges.

Building in public has been incredible - the Laravel community is amazing!

Questions? Drop a comment below! 💬


Resources


This article is part of my Building in Public series on creating LaraFoundry, a modular SaaS engine for Laravel. Follow along on X for updates!

Top comments (0)