# Namespaces & Entitlements Core PHP Framework provides a sophisticated namespace and entitlements system for flexible multi-tenant SaaS applications. Namespaces provide universal tenant boundaries, while entitlements control feature access and usage limits. ## Overview ### The Problem Traditional multi-tenant systems force a choice: **Option A: User Ownership** - Individual users own resources - No team collaboration - Billing per user **Option B: Workspace Ownership** - Teams own resources via workspaces - Can't have personal resources - Billing per workspace Both approaches are too rigid for modern SaaS: - **Agencies** need separate namespaces per client - **Freelancers** want personal AND client resources - **White-label operators** need brand isolation - **Enterprise teams** need department-level isolation ### The Solution: Namespaces Namespaces provide a **polymorphic ownership boundary** where resources belong to a namespace, and namespaces can be owned by either Users or Workspaces. ``` ┌─────────────────────────────────────────────────────────────┐ │ │ │ User ────┬──→ Namespace (Personal) ──→ Resources │ │ │ │ │ └──→ Workspace ──→ Namespace (Client A) ──→ Res │ │ └──→ Namespace (Client B) ──→ Res │ │ │ └─────────────────────────────────────────────────────────────┘ ``` **Benefits:** - Users can have personal namespaces - Workspaces can have multiple namespaces (one per client) - Clean billing boundaries - Complete resource isolation - Flexible permission models ## Namespace Model ### Structure ```php Namespace { id: int uuid: string // Public identifier name: string // Display name slug: string // URL-safe identifier description: ?string icon: ?string color: ?string owner_type: string // User::class or Workspace::class owner_id: int workspace_id: ?int // Billing context (optional) settings: ?json is_default: bool // User's default namespace is_active: bool sort_order: int } ``` ### Ownership Patterns #### Personal Namespace (User-Owned) Individual user owns namespace for personal resources: ```php $namespace = Namespace_::create([ 'name' => 'Personal', 'owner_type' => User::class, 'owner_id' => $user->id, 'workspace_id' => $user->defaultHostWorkspace()->id, // For billing 'is_default' => true, ]); ``` **Use Cases:** - Personal projects - Individual freelancer work - Testing/development environments #### Agency Namespace (Workspace-Owned) Workspace owns namespace for client/project isolation: ```php $namespace = Namespace_::create([ 'name' => 'Client: Acme Corp', 'slug' => 'acme-corp', 'owner_type' => Workspace::class, 'owner_id' => $workspace->id, 'workspace_id' => $workspace->id, // Same workspace for billing ]); ``` **Use Cases:** - Agency client projects - White-label deployments - Department/team isolation #### White-Label Namespace SaaS operator creates namespaces for customers: ```php $namespace = Namespace_::create([ 'name' => 'Customer Instance', 'owner_type' => User::class, // Customer user owns it 'owner_id' => $customerUser->id, 'workspace_id' => $operatorWorkspace->id, // Operator billed ]); ``` **Use Cases:** - White-label SaaS - Reseller programs - Managed services ## Using Namespaces ### Model Setup Add namespace scoping to models: ```php id(); $table->foreignId('namespace_id') ->constrained('namespaces') ->cascadeOnDelete(); $table->string('title'); $table->text('content'); $table->string('slug'); $table->timestamps(); $table->index(['namespace_id', 'created_at']); }); ``` ### Automatic Scoping The `BelongsToNamespace` trait automatically handles scoping: ```php // Queries automatically scoped to current namespace $pages = Page::ownedByCurrentNamespace()->get(); // Create automatically assigns namespace_id $page = Page::create([ 'title' => 'Example Page', 'content' => 'Content...', // namespace_id added automatically ]); // Can't access pages from other namespaces $page = Page::find(999); // null if belongs to different namespace ``` ### Namespace Context #### Middleware Resolution ```php // routes/web.php Route::middleware(['auth', 'namespace']) ->group(function () { Route::get('/pages', [PageController::class, 'index']); }); ``` The `ResolveNamespace` middleware sets current namespace from: 1. Query parameter: `?namespace=uuid` 2. Request header: `X-Namespace: uuid` 3. Session: `current_namespace_uuid` 4. User's default namespace #### Manual Context ```php use Core\Mod\Tenant\Services\NamespaceService; $namespaceService = app(NamespaceService::class); // Get current namespace $current = $namespaceService->current(); // Set current namespace $namespaceService->setCurrent($namespace); // Get all accessible namespaces $namespaces = $namespaceService->accessibleByCurrentUser(); // Group by ownership $grouped = $namespaceService->groupedForCurrentUser(); // [ // 'personal' => Collection, // User-owned // 'workspaces' => [ // Workspace-owned // ['workspace' => Workspace, 'namespaces' => Collection], // ... // ] // ] ``` ### Namespace Switcher UI Provide namespace switching in your UI: ```blade
{{ $currentNamespace->name }} @foreach($personalNamespaces as $ns) {{ $ns->name }} @endforeach @foreach($workspaceNamespaces as $group) {{ $group['workspace']->name }} @foreach($group['namespaces'] as $ns) {{ $ns->name }} @endforeach @endforeach
``` ### API Integration Include namespace in API requests: ```bash # Header-based curl -H "X-Namespace: uuid-here" \ -H "Authorization: Bearer sk_live_..." \ https://api.example.com/v1/pages # Query parameter curl "https://api.example.com/v1/pages?namespace=uuid-here" \ -H "Authorization: Bearer sk_live_..." ``` ## Entitlements System Entitlements control **what users can do** within their namespaces. The system answers: *"Can this namespace perform this action?"* ### Core Concepts #### Packages Bundles of features with defined limits: ```php Package { id: int code: string // 'social-creator', 'bio-pro' name: string is_base_package: bool // Only one base package per namespace is_stackable: bool // Can have multiple addon packages is_active: bool is_public: bool // Shown in pricing page } ``` **Types:** - **Base Package**: Core subscription (e.g., "Pro Plan") - **Add-on Package**: Stackable extras (e.g., "Extra Storage") #### Features Capabilities or limits that can be granted: ```php Feature { id: int code: string // 'social.accounts', 'ai.credits' name: string type: enum // boolean, limit, unlimited reset_type: enum // none, monthly, rolling rolling_window_days: ?int parent_feature_id: ?int // For hierarchical limits category: string // 'social', 'ai', 'storage' } ``` **Feature Types:** | Type | Behavior | Example | |------|----------|---------| | **Boolean** | On/off access gate | `tier.apollo`, `host.social` | | **Limit** | Numeric cap on usage | `social.accounts: 5`, `ai.credits: 100` | | **Unlimited** | No cap | `social.posts: unlimited` | **Reset Types:** | Reset Type | Behavior | Example | |------------|----------|---------| | **None** | Usage accumulates forever | Account limits | | **Monthly** | Resets at billing cycle start | API requests per month | | **Rolling** | Rolling window (e.g., last 30 days) | Posts per day | #### Hierarchical Features (Pools) Child features share a parent's limit pool: ``` host.storage.total (1000 MB) ← Parent pool ├── host.cdn ← Draws from parent ├── bio.cdn ← Draws from parent └── social.cdn ← Draws from parent ``` **Configuration:** ```php Feature::create([ 'code' => 'host.storage.total', 'name' => 'Total Storage', 'type' => 'limit', 'reset_type' => 'none', ]); Feature::create([ 'code' => 'bio.cdn', 'name' => 'Bio Link Storage', 'type' => 'limit', 'parent_feature_id' => $parentFeature->id, // Shares pool ]); ``` ### Entitlement Checks Use the entitlement service to check permissions: ```php use Core\Mod\Tenant\Services\EntitlementService; $entitlements = app(EntitlementService::class); // Check if namespace can use feature $result = $entitlements->can($namespace, 'social.accounts', quantity: 3); if ($result->isDenied()) { return back()->with('error', $result->getMessage()); } // Proceed with action... // Record usage $entitlements->recordUsage($namespace, 'social.accounts', quantity: 1); ``` ### Entitlement Result The `EntitlementResult` object provides complete context: ```php $result = $entitlements->can($namespace, 'ai.credits', quantity: 10); // Status checks $result->isAllowed(); // true/false $result->isDenied(); // true/false $result->isUnlimited(); // true if unlimited // Limits $result->limit; // 100 $result->used; // 75 $result->remaining; // 25 // Percentage $result->getUsagePercentage(); // 75.0 $result->isNearLimit(); // true if > 80% // Denial reason $result->getMessage(); // "Exceeded limit for ai.credits" ``` ### Usage Tracking Record consumption after successful actions: ```php $entitlements->recordUsage( namespace: $namespace, featureCode: 'ai.credits', quantity: 10, user: $user, // Optional: who triggered it metadata: [ // Optional: context 'model' => 'claude-3', 'tokens' => 1500, ] ); ``` **Database Schema:** ```php usage_records { id: int namespace_id: int feature_id: int workspace_id: ?int // For workspace-level aggregation user_id: ?int quantity: int metadata: ?json created_at: timestamp } ``` ### Boosts Temporary or permanent additions to limits: ```php Boost { id: int namespace_id: int feature_id: int boost_type: enum // add_limit, enable, unlimited duration_type: enum // cycle_bound, duration, permanent limit_value: ?int // Amount to add consumed_quantity: int // How much used expires_at: ?timestamp status: enum // active, exhausted, expired } ``` **Use Cases:** - One-time credit top-ups - Promotional extras - Beta access grants - Temporary unlimited access **Example:** ```php // Give 1000 bonus AI credits Boost::create([ 'namespace_id' => $namespace->id, 'feature_id' => $aiCreditsFeature->id, 'boost_type' => 'add_limit', 'duration_type' => 'cycle_bound', // Expires at billing cycle end 'limit_value' => 1000, ]); ``` ### Package Assignment Namespaces subscribe to packages: ```php NamespacePackage { id: int namespace_id: int package_id: int status: enum // active, suspended, cancelled, expired starts_at: timestamp expires_at: ?timestamp billing_cycle_anchor: timestamp } ``` **Provision Package:** ```php $entitlements->provisionPackage( namespace: $namespace, package: $package, startsAt: now(), expiresAt: now()->addMonth(), ); ``` **Package Features:** Features are attached to packages with specific limits: ```php // Package definition $package = Package::find($packageId); // Attach features with limits $package->features()->attach($feature->id, [ 'limit_value' => 5, // This package grants 5 accounts ]); // Multiple features $package->features()->sync([ $socialAccountsFeature->id => ['limit_value' => 5], $aiCreditsFeature->id => ['limit_value' => 100], $storageFeature->id => ['limit_value' => 1000], // MB ]); ``` ## Usage Dashboard Display usage stats to users: ```php $summary = $entitlements->getUsageSummary($namespace); // Returns array grouped by category: [ 'social' => [ [ 'feature' => Feature, 'limit' => 5, 'used' => 3, 'remaining' => 2, 'percentage' => 60.0, 'is_unlimited' => false, ], ... ], 'ai' => [...], ] ``` **UI Example:** ```blade @foreach($summary as $category => $features)

{{ ucfirst($category) }}

@foreach($features as $item)
{{ $item['feature']->name }}
@if($item['is_unlimited'])
Unlimited
@else
{{ $item['used'] }} / {{ $item['limit'] }} ({{ number_format($item['percentage'], 1) }}%)
@endif
@endforeach
@endforeach ``` ## Billing Integration ### Billing Context Namespaces use `workspace_id` for billing aggregation: ```php // Get billing workspace $billingWorkspace = $namespace->getBillingContext(); // User-owned namespace → User's default workspace // Workspace-owned namespace → Owner workspace // Explicit workspace_id → That workspace ``` ### Commerce Integration Link subscriptions to namespace packages: ```php // When subscription created event(new SubscriptionCreated($subscription)); // Listener provisions package $entitlements->provisionPackage( namespace: $subscription->namespace, package: $subscription->package, startsAt: $subscription->starts_at, expiresAt: $subscription->expires_at, ); // When subscription renewed $namespacePackage->update([ 'expires_at' => $subscription->next_billing_date, 'billing_cycle_anchor' => now(), ]); // Expire cycle-bound boosts Boost::where('namespace_id', $namespace->id) ->where('duration_type', 'cycle_bound') ->update(['status' => 'expired']); ``` ### External Billing Systems API endpoints for external billing (Blesta, Stripe, etc.): ```bash # Provision package POST /api/v1/entitlements { "namespace_uuid": "uuid", "package_code": "social-creator", "starts_at": "2026-01-01T00:00:00Z", "expires_at": "2026-02-01T00:00:00Z" } # Suspend package POST /api/v1/entitlements/{id}/suspend # Cancel package POST /api/v1/entitlements/{id}/cancel # Renew package POST /api/v1/entitlements/{id}/renew { "expires_at": "2026-03-01T00:00:00Z" } # Check entitlements GET /api/v1/entitlements/check ?namespace=uuid &feature=social.accounts &quantity=1 ``` ## Audit Logging All entitlement changes are logged: ```php EntitlementLog { id: int namespace_id: int workspace_id: ?int action: enum // package_provisioned, boost_expired, etc. source: enum // blesta, commerce, admin, system, api user_id: ?int data: json // Context about the change created_at: timestamp } ``` **Actions:** - `package_provisioned`, `package_suspended`, `package_cancelled` - `boost_provisioned`, `boost_exhausted`, `boost_expired` - `usage_recorded`, `usage_denied` **Retrieve logs:** ```php $logs = EntitlementLog::where('namespace_id', $namespace->id) ->latest() ->paginate(20); ``` ## Feature Seeder Define features in seeders: ```php 'tier.apollo', 'name' => 'Apollo Tier', 'type' => 'boolean', 'category' => 'tier', ]); // Social features Feature::create([ 'code' => 'social.accounts', 'name' => 'Social Accounts', 'type' => 'limit', 'reset_type' => 'none', 'category' => 'social', ]); Feature::create([ 'code' => 'social.posts.scheduled', 'name' => 'Scheduled Posts', 'type' => 'limit', 'reset_type' => 'monthly', 'category' => 'social', ]); // AI features Feature::create([ 'code' => 'ai.credits', 'name' => 'AI Credits', 'type' => 'limit', 'reset_type' => 'monthly', 'category' => 'ai', ]); // Storage pool $storagePool = Feature::create([ 'code' => 'host.storage.total', 'name' => 'Total Storage', 'type' => 'limit', 'reset_type' => 'none', 'category' => 'storage', ]); // Child features share pool Feature::create([ 'code' => 'host.cdn', 'name' => 'CDN Storage', 'type' => 'limit', 'parent_feature_id' => $storagePool->id, 'category' => 'storage', ]); } } ``` ## Testing ### Test Namespace Isolation ```php public function test_cannot_access_other_namespace_resources(): void { $namespace1 = Namespace_::factory()->create(); $namespace2 = Namespace_::factory()->create(); $page = Page::factory()->for($namespace1, 'namespace')->create(); // Set context to namespace2 request()->attributes->set('current_namespace', $namespace2); // Should not find page from namespace1 $this->assertNull(Page::ownedByCurrentNamespace()->find($page->id)); } ``` ### Test Entitlements ```php public function test_enforces_feature_limits(): void { $namespace = Namespace_::factory()->create(); $package = Package::factory()->create(); $feature = Feature::factory()->create([ 'code' => 'social.accounts', 'type' => 'limit', ]); $package->features()->attach($feature->id, ['limit_value' => 5]); $entitlements = app(EntitlementService::class); $entitlements->provisionPackage($namespace, $package); // Can create up to limit for ($i = 0; $i < 5; $i++) { $result = $entitlements->can($namespace, 'social.accounts'); $this->assertTrue($result->isAllowed()); $entitlements->recordUsage($namespace, 'social.accounts'); } // 6th attempt denied $result = $entitlements->can($namespace, 'social.accounts'); $this->assertTrue($result->isDenied()); } ``` ## Best Practices ### 1. Always Use Namespace Scoping ```php // ✅ Good - scoped to namespace class Page extends Model { use BelongsToNamespace; } // ❌ Bad - no isolation class Page extends Model { } ``` ### 2. Check Entitlements Before Actions ```php // ✅ Good - check before creating $result = $entitlements->can($namespace, 'social.accounts'); if ($result->isDenied()) { return back()->with('error', $result->getMessage()); } SocialAccount::create($data); $entitlements->recordUsage($namespace, 'social.accounts'); // ❌ Bad - no entitlement check SocialAccount::create($data); ``` ### 3. Use Descriptive Feature Codes ```php // ✅ Good - clear hierarchy 'social.accounts' 'social.posts.scheduled' 'ai.credits.claude' // ❌ Bad - unclear 'accounts' 'posts' 'credits' ``` ### 4. Provide Usage Visibility Always show users their current usage and limits in the UI. ### 5. Log Entitlement Changes All provisioning, suspension, and cancellation should be logged for audit purposes. ## Migration from Workspace-Only If migrating from workspace-only system: ```php // Create namespace for each workspace foreach (Workspace::all() as $workspace) { $namespace = Namespace_::create([ 'name' => $workspace->name, 'owner_type' => Workspace::class, 'owner_id' => $workspace->id, 'workspace_id' => $workspace->id, 'is_default' => true, ]); // Migrate existing resources Resource::where('workspace_id', $workspace->id) ->update(['namespace_id' => $namespace->id]); // Migrate packages WorkspacePackage::where('workspace_id', $workspace->id) ->each(function ($wp) use ($namespace) { NamespacePackage::create([ 'namespace_id' => $namespace->id, 'package_id' => $wp->package_id, 'status' => $wp->status, 'starts_at' => $wp->starts_at, 'expires_at' => $wp->expires_at, ]); }); } ``` ## Learn More - [Multi-Tenancy Architecture →](/architecture/multi-tenancy) - [Entitlements RFC](https://github.com/host-uk/core-php/blob/main/docs/rfc/RFC-004-ENTITLEMENTS.md) - [API Package →](/packages/api) - [Security Overview →](/security/overview)