ton registry instance for the request lifecycle.
3. Model Interception: Models utilizing the scoping trait automatically apply global scopes to queries and inject tenant identifiers during creation.
4. Database Execution: Queries execute with implicit tenant filtering; writes are stamped with the tenant identifier.
Implementation
1. Tenant Registry (Singleton)
The registry acts as the single source of truth for the active tenant. It must be a singleton to ensure consistency across the request lifecycle.
namespace App\Services;
class TenantRegistry
{
protected ?string $activeTenantId = null;
public function resolve(string $tenantId): void
{
$this->activeTenantId = $tenantId;
}
public function current(): ?string
{
return $this->activeTenantId;
}
public function hasActiveTenant(): bool
{
return $this->activeTenantId !== null;
}
public function clear(): void
{
$this->activeTenantId = null;
}
}
2. Resolution Middleware
Middleware populates the registry. This centralizes tenant resolution logic, preventing duplication across controllers.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\TenantRegistry;
use Illuminate\Support\Facades\Auth;
class ResolveTenantMiddleware
{
public function handle(Request $request, Closure $next, TenantRegistry $registry)
{
$user = Auth::user();
if ($user && $user->hasAssociatedTenant()) {
$registry->resolve($user->associatedTenantId());
}
return $next($request);
}
}
3. Organization-Scoped Trait
The trait applies a global scope for reading and a model hook for writing. This ensures that all queries are filtered and all new records are associated with the tenant.
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use App\Services\TenantRegistry;
trait OrganizationScoped
{
public static function bootOrganizationScoped(): void
{
static::addGlobalScope('organization', function (Builder $builder) {
$registry = app(TenantRegistry::class);
if ($registry->hasActiveTenant()) {
$builder->where('org_id', $registry->current());
}
});
static::creating(function (Model $instance) {
$registry = app(TenantRegistry::class);
if ($registry->hasActiveTenant() && empty($instance->org_id)) {
$instance->org_id = $registry->current();
}
});
}
}
4. Model Usage
Apply the trait to any model requiring tenant isolation.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\OrganizationScoped;
class Customer extends Model
{
use OrganizationScoped;
protected $fillable = ['name', 'email', 'org_id'];
}
Architecture Decisions
- Singleton Registry: Ensures that tenant context is resolved once per request and accessible globally without passing parameters. This eliminates the risk of stale or mismatched tenant IDs in nested service calls.
- Global Scopes: Provide automatic filtering for all Eloquent queries, including relationships. This reduces the cognitive load on developers and prevents accidental data exposure.
- Creating Hook: Automates tenant assignment on writes. This prevents scenarios where a record is created without a tenant association due to missing mass-assignment keys or manual instantiation errors.
- Middleware Resolution: Decouples tenant resolution from business logic. Controllers remain focused on domain operations, while infrastructure concerns are handled upstream.
Pitfall Guide
1. Leaking Scopes in Relationships
- Explanation: When eager loading relationships, if the related model does not use the scoping trait, queries may return data from other tenants.
- Fix: Ensure all tenant-scoped models use the trait. Verify relationship definitions do not override global scopes inadvertently.
2. Unique Constraints Without Tenant Key
- Explanation: Defining a unique index on a column like
email without including org_id allows only one email across all tenants, causing collisions.
- Fix: Always use composite unique indexes:
unique(['org_id', 'email']).
3. Caching Tenant-Agnostic Data
- Explanation: Caching query results without including the tenant ID in the cache key can result in serving Tenant A's data to Tenant B.
- Fix: Prefix all cache keys with the tenant ID or use tenant-specific cache tags.
4. Admin Bypass Risks
- Explanation: Super-admin users may need to access all tenants. Global scopes will block this access if not handled.
- Fix: Use
withoutGlobalScope('organization') explicitly in admin contexts. Never disable scopes globally; scope the bypass to specific queries.
5. ID Enumeration Attacks
- Explanation: Using incremental integer IDs for tenants allows attackers to guess valid tenant IDs and attempt unauthorized access.
- Fix: Use UUIDs for tenant identifiers. This increases the entropy and prevents enumeration.
6. Noisy Neighbor Performance
- Explanation: In a shared database, heavy queries from one tenant can impact others sharing the same table.
- Fix: Implement proper indexing strategies on
org_id. Monitor query performance and consider partitioning or read replicas for high-volume tenants.
7. Testing Gaps
- Explanation: Unit tests may pass if they only test within a single tenant context, missing cross-tenant leakage.
- Fix: Write integration tests that create multiple tenants and assert that queries return only scoped data.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-stage B2B SaaS | Shared DB + Contextual Scopes | Low infrastructure cost, rapid development, automated isolation. | Low |
| Enterprise Compliance (GDPR/HIPAA) | Separate Database | Physical isolation required for regulatory compliance. | High |
| Hybrid Requirements | Shared DB + Schema Separation | Balance between isolation and operational efficiency. | Medium |
| High-Volume Tenant | Shared DB + Read Replicas | Mitigates noisy neighbor risk while maintaining shared schema. | Medium |
Configuration Template
Migration Example:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->uuid('org_id')->index();
$table->string('name');
$table->string('email');
$table->timestamps();
$table->unique(['org_id', 'email']);
});
}
public function down(): void
{
Schema::dropIfExists('customers');
}
};
Model Example:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\OrganizationScoped;
class Customer extends Model
{
use OrganizationScoped;
protected $guarded = [];
}
Quick Start Guide
- Create Registry: Define
TenantRegistry class with resolve, current, and hasActiveTenant methods. Register as singleton.
- Define Trait: Create
OrganizationScoped trait applying global scope on org_id and hooking creating event.
- Add Middleware: Implement
ResolveTenantMiddleware to populate registry from authenticated user context. Register in kernel.
- Apply Trait: Add
use OrganizationScoped; to models requiring isolation. Update migrations with org_id and indexes.
- Verify: Run integration tests asserting that queries return only data for the active tenant.
This architecture provides a robust, scalable foundation for multi-tenant applications in Laravel. By automating isolation through contextual scopes, you eliminate the risk of human error while maintaining operational efficiency. The result is a secure, maintainable system that scales with your tenant base.