Multi-Tenancy Architecture
Core PHP Framework provides robust multi-tenant isolation using workspace-scoped data. All tenant data is automatically isolated without manual filtering.
Overview
Multi-tenancy ensures that users in one workspace (tenant) cannot access data from another workspace. Core PHP implements this through:
- Automatic query scoping via global scopes
- Workspace context validation
- Workspace-scoped caching
- Request-level workspace resolution
Workspace Model
The Workspace model represents a tenant:
<?php
namespace Mod\Tenant\Models;
use Illuminate\Database\Eloquent\Model;
class Workspace extends Model
{
protected $fillable = [
'name',
'slug',
'domain',
'is_suspended',
'settings',
];
protected $casts = [
'is_suspended' => 'boolean',
'settings' => 'array',
];
public function users()
{
return $this->hasMany(User::class);
}
public function isSuspended(): bool
{
return $this->is_suspended;
}
}Making Models Workspace-Scoped
Basic Usage
Add the BelongsToWorkspace trait to any model:
<?php
namespace Mod\Blog\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Post extends Model
{
use BelongsToWorkspace;
protected $fillable = ['title', 'content'];
}What the Trait Provides
// All queries automatically scoped to current workspace
$posts = Post::all(); // Only returns posts for current workspace
// Create automatically assigns workspace_id
$post = Post::create([
'title' => 'Example',
'content' => 'Content',
// workspace_id added automatically
]);
// Cannot access posts from other workspaces
$post = Post::find(999); // null if belongs to different workspaceMigration
Add workspace_id foreign key to tables:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('content');
$table->timestamps();
$table->index(['workspace_id', 'created_at']);
});Workspace Scope
The WorkspaceScope global scope enforces data isolation:
<?php
namespace Mod\Tenant\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class WorkspaceScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($workspace = $this->getCurrentWorkspace()) {
$builder->where("{$model->getTable()}.workspace_id", $workspace->id);
} elseif ($this->isStrictMode()) {
throw new MissingWorkspaceContextException();
}
}
// ...
}Strict Mode
Strict mode throws exceptions if workspace context is missing:
// config/core.php
'workspace' => [
'strict_mode' => env('WORKSPACE_STRICT_MODE', true),
],Development: Set to true to catch missing context bugs early Production: Keep at true for security
Bypassing Workspace Scope
Sometimes you need to query across workspaces:
// Query all workspaces (use with caution!)
Post::acrossWorkspaces()->get();
// Temporarily disable strict mode
WorkspaceScope::withoutStrictMode(function () {
return Post::all();
});
// Query specific workspace
Post::forWorkspace($otherWorkspace)->get();Workspace Context
Setting Workspace Context
The current workspace is typically set via middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Mod\Tenant\Models\Workspace;
class SetWorkspaceContext
{
public function handle(Request $request, Closure $next)
{
// Resolve workspace from subdomain
$subdomain = $this->extractSubdomain($request);
$workspace = Workspace::where('slug', $subdomain)->firstOrFail();
// Set workspace context for this request
app()->instance('current.workspace', $workspace);
return $next($request);
}
}Retrieving Current Workspace
// Via helper
$workspace = workspace();
// Via container
$workspace = app('current.workspace');
// Via auth user
$workspace = auth()->user()->workspace;Middleware
Apply workspace validation middleware to routes:
// Ensure workspace context exists
Route::middleware(RequireWorkspaceContext::class)->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
});Workspace-Scoped Caching
Overview
Workspace-scoped caching ensures cache isolation between tenants:
// Cache key: workspace:123:posts:recent
// Different workspace = different cache key
$posts = Post::forWorkspaceCached($workspace, 600);HasWorkspaceCache Trait
Add workspace caching to models:
<?php
namespace Mod\Blog\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Concerns\HasWorkspaceCache;
class Post extends Model
{
use BelongsToWorkspace, HasWorkspaceCache;
}Cache Methods
// Cache for specific workspace
$posts = Post::forWorkspaceCached($workspace, 600);
// Cache for current workspace
$posts = Post::ownedByCurrentWorkspaceCached(600);
// Invalidate workspace cache
Post::invalidateWorkspaceCache($workspace);
// Invalidate all caches for a workspace
WorkspaceCacheManager::invalidateAll($workspace);Cache Configuration
// config/core.php
'workspace_cache' => [
'enabled' => env('WORKSPACE_CACHE_ENABLED', true),
'ttl' => env('WORKSPACE_CACHE_TTL', 3600),
'use_tags' => env('WORKSPACE_CACHE_USE_TAGS', true),
'prefix' => 'workspace',
],Cache Tags (Recommended)
Use cache tags for granular invalidation:
// Store with tags
Cache::tags(['workspace:'.$workspace->id, 'posts'])
->put('recent-posts', $posts, 600);
// Invalidate all posts caches for workspace
Cache::tags(['workspace:'.$workspace->id, 'posts'])->flush();
// Invalidate everything for workspace
Cache::tags(['workspace:'.$workspace->id])->flush();Database Isolation Strategies
Shared Database (Recommended)
Single database with workspace_id column:
Pros:
- Simple deployment
- Easy backups
- Cross-workspace queries possible
- Cost-effective
Cons:
- Requires careful scoping
- One bad query can leak data
// All tables have workspace_id
Schema::create('posts', function (Blueprint $table) {
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
// ...
});Separate Databases (Advanced)
Each workspace has its own database:
Pros:
- Complete isolation
- Better security
- Easier compliance
Cons:
- Complex migrations
- Higher operational cost
- No cross-workspace queries
// Dynamically switch database connection
config([
'database.connections.workspace' => [
'database' => "workspace_{$workspace->id}",
// ...
],
]);
DB::connection('workspace')->table('posts')->get();Security Best Practices
1. Always Use WorkspaceScope
Never bypass workspace scoping in application code:
// ✅ Good
$posts = Post::all();
// ❌ Bad - security vulnerability!
$posts = Post::withoutGlobalScope(WorkspaceScope::class)->get();2. Validate Workspace Context
Always validate workspace exists and isn't suspended:
public function handle(Request $request, Closure $next)
{
$workspace = workspace();
if (! $workspace) {
throw new MissingWorkspaceContextException();
}
if ($workspace->isSuspended()) {
abort(403, 'Workspace suspended');
}
return $next($request);
}3. Use Policies for Authorization
Combine workspace scoping with Laravel policies:
class PostPolicy
{
public function update(User $user, Post $post): bool
{
// Workspace scope ensures $post belongs to current workspace
// Policy checks user has permission within that workspace
return $user->can('edit-posts');
}
}4. Audit Workspace Access
Log workspace access for security auditing:
activity()
->causedBy($user)
->performedOn($workspace)
->withProperties(['action' => 'accessed'])
->log('Workspace accessed');5. Test Cross-Workspace Isolation
Write tests to verify data isolation:
public function test_cannot_access_other_workspace_data(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$post = Post::factory()->for($workspace1)->create();
// Set context to workspace2
app()->instance('current.workspace', $workspace2);
// Should not find post from workspace1
$this->assertNull(Post::find($post->id));
}Cross-Workspace Operations
Admin Operations
Admins sometimes need cross-workspace access:
// Check if user is super admin
if (auth()->user()->isSuperAdmin()) {
// Allow cross-workspace queries
$allPosts = Post::acrossWorkspaces()
->where('published_at', '>', now()->subDays(7))
->get();
}Reporting
Generate reports across workspaces:
class GenerateSystemReportJob
{
public function handle(): void
{
$stats = WorkspaceScope::withoutStrictMode(function () {
return [
'total_posts' => Post::count(),
'total_users' => User::count(),
'by_workspace' => Workspace::withCount('posts')->get(),
];
});
// ...
}
}Migrations
Migrations run without workspace context:
public function up(): void
{
WorkspaceScope::withoutStrictMode(function () {
// Migrate data across all workspaces
Post::chunk(100, function ($posts) {
foreach ($posts as $post) {
$post->update(['migrated' => true]);
}
});
});
}Performance Optimization
Eager Loading
Include workspace relation when needed:
// ✅ Good
$posts = Post::with('workspace')->get();
// ❌ Bad - N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->workspace->name; // N+1
}Index Optimization
Add composite indexes for workspace queries:
$table->index(['workspace_id', 'created_at']);
$table->index(['workspace_id', 'status']);
$table->index(['workspace_id', 'user_id']);Partition Tables (Advanced)
For very large datasets, partition by workspace_id:
CREATE TABLE posts (
id BIGINT,
workspace_id BIGINT NOT NULL,
-- ...
) PARTITION BY HASH(workspace_id) PARTITIONS 10;Monitoring
Track Workspace Usage
Monitor workspace-level metrics:
// Query count per workspace
DB::listen(function ($query) {
$workspace = workspace();
if ($workspace) {
Redis::zincrby('workspace:queries', 1, $workspace->id);
}
});
// Get top workspaces by query count
$top = Redis::zrevrange('workspace:queries', 0, 10, 'WITHSCORES');Cache Hit Rates
Track cache effectiveness per workspace:
WorkspaceCacheManager::trackHit($workspace);
WorkspaceCacheManager::trackMiss($workspace);
$hitRate = WorkspaceCacheManager::getHitRate($workspace);Troubleshooting
Missing Workspace Context
MissingWorkspaceContextException: Workspace context required but not setSolution: Ensure middleware sets workspace context:
Route::middleware(RequireWorkspaceContext::class)->group(/*...*/);Wrong Workspace Data
User sees data from different workspaceSolution: Check workspace is set correctly:
dd(workspace()); // Verify correct workspaceCache Bleeding
Cached data appearing across workspacesSolution: Ensure cache keys include workspace ID:
// ✅ Good
$key = "workspace:{$workspace->id}:posts:recent";
// ❌ Bad
$key = "posts:recent"; // Same key for all workspaces!