515 lines
11 KiB
Markdown
515 lines
11 KiB
Markdown
|
|
# Multi-Tenancy
|
||
|
|
|
||
|
|
Core PHP Framework provides robust multi-tenancy with dual-level isolation: **Workspaces** for team/agency management and **Namespaces** for service isolation and billing contexts.
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
The tenancy system supports three common patterns:
|
||
|
|
|
||
|
|
1. **Personal** - Individual users with personal namespaces
|
||
|
|
2. **Agency/Team** - Workspaces with multiple users managing client namespaces
|
||
|
|
3. **White-Label** - Operators creating workspace + namespace pairs for customers
|
||
|
|
|
||
|
|
## Workspaces
|
||
|
|
|
||
|
|
Workspaces represent a team, agency, or organization. Multiple users can belong to a workspace.
|
||
|
|
|
||
|
|
### Creating Workspaces
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Models\Workspace;
|
||
|
|
|
||
|
|
$workspace = Workspace::create([
|
||
|
|
'name' => 'Acme Corporation',
|
||
|
|
'slug' => 'acme-corp',
|
||
|
|
'tier' => 'business',
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Add user to workspace
|
||
|
|
$workspace->users()->attach($user->id, [
|
||
|
|
'role' => 'admin',
|
||
|
|
]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Workspace Scoping
|
||
|
|
|
||
|
|
Use the `BelongsToWorkspace` trait to automatically scope models:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||
|
|
|
||
|
|
class Post extends Model
|
||
|
|
{
|
||
|
|
use BelongsToWorkspace;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Queries automatically scoped to current workspace
|
||
|
|
$posts = Post::all(); // Only posts in current workspace
|
||
|
|
|
||
|
|
// Create within workspace
|
||
|
|
$post = Post::create([
|
||
|
|
'title' => 'My Post',
|
||
|
|
]); // workspace_id automatically set
|
||
|
|
```
|
||
|
|
|
||
|
|
### Workspace Context
|
||
|
|
|
||
|
|
The current workspace is resolved from:
|
||
|
|
|
||
|
|
1. Session (for web requests)
|
||
|
|
2. `X-Workspace-ID` header (for API requests)
|
||
|
|
3. Query parameter `workspace_id`
|
||
|
|
4. User's default workspace (fallback)
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Get current workspace
|
||
|
|
$workspace = workspace();
|
||
|
|
|
||
|
|
// Check if workspace context is set
|
||
|
|
if (workspace()) {
|
||
|
|
// Workspace context available
|
||
|
|
}
|
||
|
|
|
||
|
|
// Manually set workspace
|
||
|
|
Workspace::setCurrent($workspace);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Namespaces
|
||
|
|
|
||
|
|
Namespaces provide service isolation and are the **billing context** for entitlements. A namespace can be owned by a **User** (personal) or a **Workspace** (agency/client).
|
||
|
|
|
||
|
|
### Why Namespaces?
|
||
|
|
|
||
|
|
- **Service Isolation** - Each namespace has separate storage, API quotas, features
|
||
|
|
- **Billing Context** - Packages and entitlements are attached to namespaces
|
||
|
|
- **Agency Pattern** - One workspace can manage many client namespaces
|
||
|
|
- **White-Label** - Operators can provision namespace + workspace pairs
|
||
|
|
|
||
|
|
### Namespace Ownership
|
||
|
|
|
||
|
|
Namespaces use polymorphic ownership:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Models\Namespace_;
|
||
|
|
|
||
|
|
// Personal namespace (owned by User)
|
||
|
|
$namespace = Namespace_::create([
|
||
|
|
'name' => 'Personal',
|
||
|
|
'slug' => 'personal',
|
||
|
|
'owner_type' => User::class,
|
||
|
|
'owner_id' => $user->id,
|
||
|
|
'is_default' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Client namespace (owned by Workspace)
|
||
|
|
$namespace = Namespace_::create([
|
||
|
|
'name' => 'Client: Acme Corp',
|
||
|
|
'slug' => 'client-acme',
|
||
|
|
'owner_type' => Workspace::class,
|
||
|
|
'owner_id' => $workspace->id,
|
||
|
|
'workspace_id' => $workspace->id, // For billing aggregation
|
||
|
|
]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Namespace Scoping
|
||
|
|
|
||
|
|
Use the `BelongsToNamespace` trait for namespace-specific data:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Concerns\BelongsToNamespace;
|
||
|
|
|
||
|
|
class Media extends Model
|
||
|
|
{
|
||
|
|
use BelongsToNamespace;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Queries automatically scoped to current namespace
|
||
|
|
$media = Media::all();
|
||
|
|
|
||
|
|
// With caching
|
||
|
|
$media = Media::ownedByCurrentNamespaceCached(ttl: 300);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Namespace Context
|
||
|
|
|
||
|
|
The current namespace is resolved from:
|
||
|
|
|
||
|
|
1. Session (for web requests)
|
||
|
|
2. `X-Namespace-ID` header (for API requests)
|
||
|
|
3. Query parameter `namespace_id`
|
||
|
|
4. User's default namespace (fallback)
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Get current namespace
|
||
|
|
$namespace = namespace_context();
|
||
|
|
|
||
|
|
// Manually set namespace
|
||
|
|
Namespace_::setCurrent($namespace);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Accessible Namespaces
|
||
|
|
|
||
|
|
Get all namespaces a user can access:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Services\NamespaceService;
|
||
|
|
|
||
|
|
$service = app(NamespaceService::class);
|
||
|
|
|
||
|
|
// Get all accessible namespaces
|
||
|
|
$namespaces = $service->getAccessibleNamespaces($user);
|
||
|
|
|
||
|
|
// Grouped by type
|
||
|
|
$grouped = $service->getGroupedNamespaces($user);
|
||
|
|
// Returns:
|
||
|
|
// [
|
||
|
|
// 'personal' => [...], // User-owned namespaces
|
||
|
|
// 'workspaces' => [ // Workspace-owned namespaces
|
||
|
|
// 'Workspace Name' => [...],
|
||
|
|
// ]
|
||
|
|
// ]
|
||
|
|
```
|
||
|
|
|
||
|
|
## Entitlements Integration
|
||
|
|
|
||
|
|
Namespaces are the billing context for entitlements:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Services\EntitlementService;
|
||
|
|
|
||
|
|
$entitlements = app(EntitlementService::class);
|
||
|
|
|
||
|
|
// Check if namespace has access to feature
|
||
|
|
$result = $entitlements->can($namespace, 'storage', quantity: 1073741824);
|
||
|
|
|
||
|
|
if ($result->isDenied()) {
|
||
|
|
return back()->with('error', $result->getMessage());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Record usage
|
||
|
|
$entitlements->recordUsage($namespace, 'api_calls', quantity: 1);
|
||
|
|
|
||
|
|
// Get current usage
|
||
|
|
$usage = $entitlements->getUsage($namespace, 'storage');
|
||
|
|
```
|
||
|
|
|
||
|
|
[Learn more about Entitlements →](/security/namespaces)
|
||
|
|
|
||
|
|
## Multi-Level Isolation
|
||
|
|
|
||
|
|
You can use both workspace and namespace scoping:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class Invoice extends Model
|
||
|
|
{
|
||
|
|
use BelongsToWorkspace, BelongsToNamespace;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Query scoped to both workspace AND namespace
|
||
|
|
$invoices = Invoice::all();
|
||
|
|
```
|
||
|
|
|
||
|
|
## Workspace Caching
|
||
|
|
|
||
|
|
The framework provides workspace-isolated caching:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Concerns\HasWorkspaceCache;
|
||
|
|
|
||
|
|
class Post extends Model
|
||
|
|
{
|
||
|
|
use BelongsToWorkspace, HasWorkspaceCache;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cache automatically isolated per workspace
|
||
|
|
$posts = Post::ownedByCurrentWorkspaceCached(ttl: 600);
|
||
|
|
|
||
|
|
// Manual workspace caching
|
||
|
|
$value = workspace_cache()->remember('stats', 600, function () {
|
||
|
|
return $this->calculateStats();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Clear workspace cache
|
||
|
|
workspace_cache()->flush();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cache Tags
|
||
|
|
|
||
|
|
When using Redis/Memcached, caches are tagged with workspace ID:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Automatically uses tag: "workspace:{id}"
|
||
|
|
workspace_cache()->put('key', 'value', 600);
|
||
|
|
|
||
|
|
// Clear all cache for workspace
|
||
|
|
workspace_cache()->flush(); // Clears all tags for current workspace
|
||
|
|
```
|
||
|
|
|
||
|
|
## Context Resolution
|
||
|
|
|
||
|
|
### Middleware
|
||
|
|
|
||
|
|
Require workspace or namespace context:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Middleware\RequireWorkspaceContext;
|
||
|
|
|
||
|
|
Route::middleware(RequireWorkspaceContext::class)->group(function () {
|
||
|
|
Route::get('/dashboard', [DashboardController::class, 'index']);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Manual Resolution
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Services\NamespaceService;
|
||
|
|
|
||
|
|
$service = app(NamespaceService::class);
|
||
|
|
|
||
|
|
// Resolve namespace from request
|
||
|
|
$namespace = $service->resolveFromRequest($request);
|
||
|
|
|
||
|
|
// Get default namespace for user
|
||
|
|
$namespace = $service->getDefaultNamespace($user);
|
||
|
|
|
||
|
|
// Set current namespace
|
||
|
|
$service->setCurrentNamespace($namespace);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Workspace Invitations
|
||
|
|
|
||
|
|
Invite users to join workspaces:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Models\WorkspaceInvitation;
|
||
|
|
|
||
|
|
$invitation = WorkspaceInvitation::create([
|
||
|
|
'workspace_id' => $workspace->id,
|
||
|
|
'email' => 'user@example.com',
|
||
|
|
'role' => 'member',
|
||
|
|
'invited_by' => $currentUser->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Send invitation email
|
||
|
|
$invitation->notify(new WorkspaceInvitationNotification($invitation));
|
||
|
|
|
||
|
|
// Accept invitation
|
||
|
|
$invitation->accept($user);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Usage Patterns
|
||
|
|
|
||
|
|
### Personal User (No Workspace)
|
||
|
|
|
||
|
|
```php
|
||
|
|
// User has personal namespace
|
||
|
|
$user = User::find(1);
|
||
|
|
$namespace = $user->namespaces()->where('is_default', true)->first();
|
||
|
|
|
||
|
|
// Can access services via namespace
|
||
|
|
$result = $entitlements->can($namespace, 'storage');
|
||
|
|
```
|
||
|
|
|
||
|
|
### Agency with Clients
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Agency workspace owns multiple client namespaces
|
||
|
|
$workspace = Workspace::where('slug', 'agency')->first();
|
||
|
|
|
||
|
|
// Each client gets their own namespace
|
||
|
|
$clientNamespace = Namespace_::create([
|
||
|
|
'name' => 'Client: Acme',
|
||
|
|
'owner_type' => Workspace::class,
|
||
|
|
'owner_id' => $workspace->id,
|
||
|
|
'workspace_id' => $workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Client's resources scoped to their namespace
|
||
|
|
$media = Media::where('namespace_id', $clientNamespace->id)->get();
|
||
|
|
|
||
|
|
// Workspace usage aggregated across all client namespaces
|
||
|
|
$totalUsage = $workspace->namespaces()->sum('storage_used');
|
||
|
|
```
|
||
|
|
|
||
|
|
### White-Label Operator
|
||
|
|
|
||
|
|
```php
|
||
|
|
// Operator creates workspace + namespace for customer
|
||
|
|
$workspace = Workspace::create([
|
||
|
|
'name' => 'Customer Corp',
|
||
|
|
'slug' => 'customer-corp',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$namespace = Namespace_::create([
|
||
|
|
'name' => 'Customer Corp Services',
|
||
|
|
'owner_type' => Workspace::class,
|
||
|
|
'owner_id' => $workspace->id,
|
||
|
|
'workspace_id' => $workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Attach package to namespace
|
||
|
|
$namespace->packages()->attach($packageId, [
|
||
|
|
'expires_at' => now()->addYear(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Add user to workspace
|
||
|
|
$workspace->users()->attach($userId, ['role' => 'admin']);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
### Setting Workspace Context
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Models\Workspace;
|
||
|
|
|
||
|
|
class PostTest extends TestCase
|
||
|
|
{
|
||
|
|
public function test_creates_post_in_workspace(): void
|
||
|
|
{
|
||
|
|
$workspace = Workspace::factory()->create();
|
||
|
|
Workspace::setCurrent($workspace);
|
||
|
|
|
||
|
|
$post = Post::create(['title' => 'Test']);
|
||
|
|
|
||
|
|
$this->assertEquals($workspace->id, $post->workspace_id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Setting Namespace Context
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Core\Mod\Tenant\Models\Namespace_;
|
||
|
|
|
||
|
|
class MediaTest extends TestCase
|
||
|
|
{
|
||
|
|
public function test_uploads_media_to_namespace(): void
|
||
|
|
{
|
||
|
|
$namespace = Namespace_::factory()->create();
|
||
|
|
Namespace_::setCurrent($namespace);
|
||
|
|
|
||
|
|
$media = Media::create(['filename' => 'test.jpg']);
|
||
|
|
|
||
|
|
$this->assertEquals($namespace->id, $media->namespace_id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Database Schema
|
||
|
|
|
||
|
|
### Workspaces Table
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE workspaces (
|
||
|
|
id BIGINT PRIMARY KEY,
|
||
|
|
uuid VARCHAR(36) UNIQUE,
|
||
|
|
name VARCHAR(255),
|
||
|
|
slug VARCHAR(255) UNIQUE,
|
||
|
|
tier VARCHAR(50),
|
||
|
|
settings JSON,
|
||
|
|
created_at TIMESTAMP,
|
||
|
|
updated_at TIMESTAMP
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Namespaces Table
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE namespaces (
|
||
|
|
id BIGINT PRIMARY KEY,
|
||
|
|
uuid VARCHAR(36) UNIQUE,
|
||
|
|
name VARCHAR(255),
|
||
|
|
slug VARCHAR(255),
|
||
|
|
owner_type VARCHAR(255), -- User::class or Workspace::class
|
||
|
|
owner_id BIGINT,
|
||
|
|
workspace_id BIGINT NULL, -- Billing context
|
||
|
|
settings JSON,
|
||
|
|
is_default BOOLEAN,
|
||
|
|
is_active BOOLEAN,
|
||
|
|
created_at TIMESTAMP,
|
||
|
|
updated_at TIMESTAMP,
|
||
|
|
|
||
|
|
INDEX idx_owner (owner_type, owner_id),
|
||
|
|
INDEX idx_workspace (workspace_id)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Workspace Users Table
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE workspace_user (
|
||
|
|
id BIGINT PRIMARY KEY,
|
||
|
|
workspace_id BIGINT,
|
||
|
|
user_id BIGINT,
|
||
|
|
role VARCHAR(50),
|
||
|
|
joined_at TIMESTAMP,
|
||
|
|
|
||
|
|
UNIQUE KEY (workspace_id, user_id)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### 1. Always Use Scoping Traits
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ✅ Good
|
||
|
|
class Post extends Model
|
||
|
|
{
|
||
|
|
use BelongsToWorkspace;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ❌ Bad - manual scoping
|
||
|
|
Post::where('workspace_id', workspace()->id)->get();
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Use Namespace for Service Resources
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ✅ Good - namespace scoped
|
||
|
|
class Media extends Model
|
||
|
|
{
|
||
|
|
use BelongsToNamespace;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ❌ Bad - workspace scoped for service resources
|
||
|
|
class Media extends Model
|
||
|
|
{
|
||
|
|
use BelongsToWorkspace; // Wrong context
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Cache with Workspace Isolation
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ✅ Good
|
||
|
|
$stats = workspace_cache()->remember('stats', 600, fn () => $this->calculate());
|
||
|
|
|
||
|
|
// ❌ Bad - global cache conflicts
|
||
|
|
$stats = Cache::remember('stats', 600, fn () => $this->calculate());
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Validate Entitlements Before Actions
|
||
|
|
|
||
|
|
```php
|
||
|
|
// ✅ Good
|
||
|
|
public function store(Request $request)
|
||
|
|
{
|
||
|
|
$result = $entitlements->can(namespace_context(), 'posts', quantity: 1);
|
||
|
|
|
||
|
|
if ($result->isDenied()) {
|
||
|
|
return back()->with('error', $result->getMessage());
|
||
|
|
}
|
||
|
|
|
||
|
|
return CreatePost::run($request->validated());
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Learn More
|
||
|
|
|
||
|
|
- [Namespaces & Entitlements →](/security/namespaces)
|
||
|
|
- [Architecture: Multi-Tenancy →](/architecture/multi-tenancy)
|
||
|
|
- [Workspace Caching →](#workspace-caching)
|
||
|
|
- [Testing Multi-Tenancy →](/guide/testing#multi-tenancy)
|