Skip to content

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 →

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

Released under the EUPL-1.2 License.