php-framework/docs/build/php/tenancy.md

515 lines
11 KiB
Markdown
Raw Permalink Normal View History

# 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)