Skip to main content

Overview

Multi-tenancy allows multiple organizations to use the platform while keeping their data completely isolated. Each tenant operates independently with their own products, categories, orders, and team members.

How It Works

Tenant Resolution

The system identifies the current tenant from the X-Tenant-ID header:
X-Tenant-ID: 123
This header should be included in all API requests that require tenant context.

Automatic Scoping

Models using the HasTenant trait are automatically filtered:
use App\HasTenant;

class Product extends Model
{
    use HasTenant;
    
    protected $fillable = ['name', 'price'];
    // tenant_id automatically managed
}

Tenant-Scoped Models

These models belong to a single tenant:

Catalog

  • Products
  • Categories
  • Product Images

Operations

  • Orders
  • Order Items
  • Ratings

Integrations

  • Webhooks
  • Integration Outbox

Configuration

  • System Config
  • Settings

Cross-Tenant Models

These models exist across tenants:
  • User - Can belong to multiple tenants
  • Tenant - The organization itself
  • TenantUser - Pivot table linking users to tenants

User Roles

Each user has a role per tenant:
  • ADMIN
  • MEMBER
Full Control
  • Add/remove team members
  • Update member roles
  • Manage products and categories
  • Configure webhooks
  • Update tenant settings

Best Practices

1. Always Include X-Tenant-ID

curl -X GET https://api.example.com/api/v2/admin/products \
  -H 'Authorization: Bearer TOKEN' \
  -H 'X-Tenant-ID: 123'

2. Verify Tenant Membership

Before accessing tenant resources:
$isMember = auth()->user()->tenants()
    ->where('tenant_id', $tenantId)
    ->exists();

if (!$isMember) {
    abort(403, 'Access denied');
}

3. Check Role for Sensitive Operations

$isAdmin = auth()->user()->tenants()
    ->where('tenant_id', $tenantId)
    ->wherePivot('role', 'ADMIN')
    ->exists();

if (!$isAdmin) {
    abort(403, 'Admin access required');
}

4. Don’t Manually Set tenant_id

The HasTenant trait handles this automatically:
// ❌ Bad - manual assignment
$product = new Product();
$product->tenant_id = $tenantId;
$product->save();

// ✅ Good - automatic from X-Tenant-ID
$product = Product::create([
    'name' => 'Product Name',
    'price' => 99.99
]);

Security Considerations

Always verify tenant membership before showing data. The system returns empty results for unauthorized tenants rather than throwing errors, maintaining a consistent API experience.

Tenant Isolation

  • Data is automatically scoped by tenant
  • Cross-tenant queries are prevented
  • Users can only access their tenants’ data

Role Verification

  • ADMIN role required for team management
  • Role checks happen at the controller level
  • Middleware validates tenant access

Testing Multi-Tenancy

public function test_user_cannot_see_other_tenant_data()
{
    $tenant1 = Tenant::create(['name' => 'Tenant 1']);
    $tenant2 = Tenant::create(['name' => 'Tenant 2']);
    
    $product1 = Product::forTenant($tenant1->id)
        ->create(['name' => 'Product 1']);
    $product2 = Product::forTenant($tenant2->id)
        ->create(['name' => 'Product 2']);
    
    // Set tenant context
    $this->withHeader('X-Tenant-ID', $tenant1->id);
    
    // Should only see tenant1's products
    $products = Product::all();
    $this->assertCount(1, $products);
    $this->assertEquals('Product 1', $products->first()->name);
}

Common Patterns

Switching Tenants

Users can switch between their tenants by changing the X-Tenant-ID header:
// Get user's tenants
const { tenants } = await api.get('/api/v2/admin/who_am_i');

// Switch to different tenant
api.defaults.headers['X-Tenant-ID'] = tenants[0].id;

Creating Tenant-Scoped Resources

// Create product in current tenant
await axios.post('/api/v2/admin/products', {
  name: 'New Product',
  price: 29.99
}, {
  headers: {
    'Authorization': `Bearer ${token}`,
    'X-Tenant-ID': currentTenantId
  }
});