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:
- Separate databases per tenant - Full isolation, complex operations
- Separate schemas per tenant - Medium isolation, medium complexity
- 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)
);
Key decisions:
- company_id on every tenant-scoped table - Non-nullable
- Foreign keys with CASCADE - Delete company = delete all data
- Composite indexes - (company_id, created_at) for performance
- 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);
}
}
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
Admin queries:
// See all orders across companies
Order::withoutGlobalScope('company')->get();
// Or use dedicated scope
Order::forCompany(5)->get();
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!
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();
}
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();
});
Test categories I use:
- Isolation tests - Company A can't see Company B's data
- Relationship tests - Relationships are properly scoped
- Admin tests - Admins can bypass scopes when needed
- 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
- Laravel Global Scopes Docs
- Pest PHP Docs
- My X/Twitter: @d_isaenko_dev
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)