quvel / tenant
Multi-tenant support for the Quvel framework
Installs: 8
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/quvel/tenant
Requires
- php: ^8.4
- illuminate/database: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- larastan/larastan: ^3.8
- laravel/horizon: ^5.31
- laravel/pint: ^1.25
- orchestra/testbench: ^10.6
- phpmd/phpmd: ^2.15
- phpunit/phpunit: ^12.4
- psalm/plugin-laravel: ^3.0
- rector/rector: ^2.2
- squizlabs/php_codesniffer: ^4.0
- vimeo/psalm: ^6.13
README
Multi-tenant support for Laravel applications with flexible tenant resolution, database isolation, and service scoping.
Installation
Step 1: Install the Package
Install from Packagist:
composer require quvel/tenant
Step 2: Publish Configuration
Publish the configuration file to config/tenant.php:
php artisan vendor:publish --tag=tenant-config
Step 3: Publish and Run Migrations
Publish the migrations:
php artisan vendor:publish --tag=tenant-migrations
This publishes:
2024_01_01_000000_create_tenants_table- Main tenants table2024_01_02_000000_add_tenant_id_to_tables- Adds tenant_id to existing tables
Run the migrations:
php artisan migrate
Step 4: Configure Environment Variables
Add to your .env file:
# Tenant Resolver TENANT_RESOLVER=Quvel\Tenant\Resolvers\DomainResolver TENANT_RESOLVER_ENABLE_CACHE=false TENANT_RESOLVER_CACHE_TTL=300 # Tenant Configuration TENANT_AUTO_MIDDLEWARE=true TENANT_DEFAULT_PIPELINE=config,database,mail,filesystem # Database per Tenant (optional) # Set to true if each tenant has its own database TENANT_SEPARATE_DATABASES=false # Admin UI (optional) TENANT_ADMIN_ENABLED=false
Step 5: Update User Model
Add the tenant relationship to your User model:
use Quvel\Tenant\Concerns\BelongsToTenant; class User extends Authenticatable { use BelongsToTenant; // Your existing code... }
Step 6: Verify Installation
Check that the package is discovered:
php artisan package:discover
You should see quvel/tenant in the list.
Test tenant functionality:
php artisan tinker >>> Quvel\Tenant\Models\Tenant::count() >>> Quvel\Tenant\Facades\TenantContext::isActive()
Optional: Publish Additional Assets
# Publish language files
php artisan vendor:publish --tag=tenant-lang
Advanced: Custom Route Files
By default, routes are autoloaded from the package. For advanced customization, you can publish and customize route files:
# Publish tenant config API routes (tenant-info endpoints) php artisan vendor:publish --tag=tenant-config-routes # Publish tenant admin routes php artisan vendor:publish --tag=tenant-admin-routes
To use your published routes instead of the package defaults:
- Publish the route file(s) you want to customize (commands above)
- Disable auto-loading in
config/tenant.php:// For tenant config API routes 'api' => [ 'enabled' => false, // Disable autoloading // ... other settings ], // For tenant admin routes 'admin' => [ 'enabled' => false, // Disable autoloading // ... other settings ],
- Customize your published route files in
routes/tenant-info.phporroutes/tenant-admin.php
Note: Most users don't need to publish routes. Use config settings to customize prefixes, middleware, and route names.
Troubleshooting
Package isn't discovered:
- Clear bootstrap cache:
rm bootstrap/cache/*.php - Run:
php artisan package:discover
Migration conflicts:
If you get "table already exists" errors, the tables may have been created by another package (like quvel/core). Mark them as migrated:
php artisan tinker DB::table('migrations')->insert([ ['migration' => '2024_01_01_000000_create_platform_settings_table', 'batch' => 2], ['migration' => '2024_01_01_000001_create_user_devices_table', 'batch' => 2], ]);
Tenant not resolving:
- Check
TENANT_RESOLVERclass is correct - Verify middleware is enabled:
TENANT_AUTO_MIDDLEWARE=true - Check domain configuration in tenants table
Configuration
Tenant Model
'model' => \Quvel\Tenant\Models\Tenant::class,
Configures which model class to use for tenant operations. This allows you to extend the base tenant model with custom functionality while keeping the package code model-agnostic.
How it works:
- Used by
tenant_model()helper function to create new tenant instances - Used by
TenantFactoryfor testing and seeding - Used by resolvers when querying tenants from the database
- Enables model customization without modifying package code
Usage example:
// Custom tenant model class CustomTenant extends \Quvel\Tenant\Models\Tenant { public function customMethod() { // Your custom logic } } // In config/tenant.php 'model' => \App\Models\CustomTenant::class,
The tenant_model() helper function returns a new instance of the configured model:
$tenant = tenant_model(); // Returns new instance of configured model class $tenant->name = 'New Tenant'; $tenant->save();
Middleware Configuration
'middleware' => [ 'auto_register' => env('TENANT_AUTO_MIDDLEWARE', true), 'internal_request' => 'tenant.is-internal', ],
auto_register: Controls automatic tenant middleware registration on ALL HTTP requests.
true(default):TenantMiddlewareis automatically prepended to the global middleware stack, running on every requestfalse: Manual middleware registration required - add'tenant'middleware to specific routes/groups
How it works:
// When auto_register = true // Middleware runs automatically on all requests // When auto_register = false, manually add middleware: Route::middleware(['tenant'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']); });
internal_request: Middleware alias for protecting internal/admin endpoints.
- Default value:
'tenant.is-internal' - Maps to
RequireInternalTenant::classmiddleware - Used by admin routes to ensure only internal tenants can access admin functionality
- Applied automatically to tenant admin UI routes when enabled
Usage in the package:
// Admin routes automatically protected with internal_request middleware Route::middleware(config('tenant.middleware.internal_request')) ->group(__DIR__.'/../routes/tenant-admin.php');
Tenant Resolver Configuration
'resolver' => [ 'class' => env('TENANT_RESOLVER', \Quvel\Tenant\Resolvers\DomainResolver::class), 'config' => [ 'cache_enabled' => env('TENANT_RESOLVER_ENABLE_CACHE', false), 'cache_ttl' => env('TENANT_RESOLVER_CACHE_TTL', 300), ], ],
class: The resolver class that determines how tenants are identified from HTTP requests.
- Default:
DomainResolver- resolves tenants by domain name (tenant.example.com) - Must implement
TenantResolverinterface - Receives config array in constructor
How it works:
// DomainResolver extracts host from request $identifier = $request->getHost(); // e.g., "tenant.example.com" $tenant = TenantModel::findByIdentifier($identifier);
config.cache_enabled: Controls whether resolved tenants are cached.
false(default): No caching - resolver called on every requesttrue: Enables caching using resolver'sgetCacheKey()method
config.cache_ttl: Cache time-to-live in seconds.
300(default): Cache resolved tenants for 5 minutes0: Cache forever (until manually cleared)- Only used when
cache_enabled = true
Caching behavior:
// When cache_enabled = true Cache::remember("tenant.{$cacheKey}", $cacheTtl, function() { return $resolver->resolve($request); });
Available Resolvers:
DomainResolver: Resolve by full domain (tenant.example.com)
Custom Resolver Example:
class SubdomainResolver implements TenantResolver { public function resolve(Request $request) { $host = $request->getHost(); $subdomain = explode('.', $host)[0]; return TenantModel::findByIdentifier($subdomain); } public function getCacheKey(Request $request): ?string { return explode('.', $request->getHost())[0]; } }
Tenant Not Found Handling
'not_found' => [ 'strategy' => env('TENANT_NOT_FOUND_STRATEGY', 'abort'), 'config' => [ // For 'redirect' strategy 'redirect_url' => env('TENANT_NOT_FOUND_REDIRECT', '/'), // For 'custom' strategy 'handler' => null, // Invokable class name ], ],
strategy: Defines what happens when no tenant is found for a request.
'abort'(default): ThrowsNotFoundHttpException(returns 404 Not Found)'redirect': Redirects to a specified URL'custom': Calls a custom handler class or callable
How it works in TenantMiddleware:
protected function handleTenantNotFound(Request $request): never { $strategy = config('tenant.not_found.strategy'); $config = config('tenant.not_found.config', []); match ($strategy) { 'redirect' => redirect($config['redirect_url'] ?? '/')->send(), 'custom' => $this->callCustomHandler($config['handler'] ?? null, $request), default => throw new NotFoundHttpException('Tenant not found'), }; }
config.redirect_url: URL for redirect strategy.
- Default:
'/'- redirects to homepage - Environment:
TENANT_NOT_FOUND_REDIRECT - Only used when
strategy = 'redirect'
config.handler: Custom handler for 'custom' strategy.
- Must be an invokable class name or callable
- Receives the
Requestobject - Example:
\App\Handlers\TenantNotFoundHandler::class
Custom Handler Example:
class TenantNotFoundHandler { public function __invoke(Request $request) { // Custom logic - log, redirect, show special page, etc. return response()->view('tenant-not-found', [], 404); } } // In config 'not_found' => [ 'strategy' => 'custom', 'config' => [ 'handler' => \App\Handlers\TenantNotFoundHandler::class, ], ],
Tenant Config API
'api' => [ 'allow_public_config' => env('TENANT_ALLOW_PUBLIC_CONFIG', false), 'allow_protected_config' => env('TENANT_ALLOW_PROTECTED_CONFIG', false), ],
Controls access to tenant configuration API endpoints. These endpoints expose tenant config for frontend applications and SSR.
allow_public_config: Enables the public config API endpoint.
false(default): Public config API disabled - throws 404true: Enables/tenant-info/publicendpoint- Returns only config marked with
ConfigVisibility::PUBLIC - No authentication required
allow_protected_config: Enables the protected config API endpoint.
false(default): Protected config API disabled - throws 404true: Enables/tenant-info/protectedendpoint- Returns config marked with
ConfigVisibility::PUBLICandConfigVisibility::PROTECTED - Protected by
internal_requestmiddleware
Available API Endpoints:
// Public config (when allow_public_config = true) GET /tenant-info/public // Returns: { data: { config: { /* public config only */ } } } // Protected config (when allow_protected_config = true) GET /tenant-info/protected // Returns: { data: { config: { /* public + protected config */ } } } // Cache endpoint (always requires internal middleware) GET /tenant-info/cache // Returns: Collection of all tenant configs for SSR
How it works:
// In TenantPublicConfig action public function __invoke($tenant): TenantConfigResource { $allowPublicConfig = config('tenant.api.allow_public_config', false); if ($allowPublicConfig !== true) { throw new NotFoundHttpException('API not enabled for this tenant'); } return new TenantConfigResource($tenant)->setVisibilityLevel('public'); }
Config Visibility Levels:
ConfigVisibility::PRIVATE: Never exposed via APIConfigVisibility::PROTECTED: Exposed in protected endpoint onlyConfigVisibility::PUBLIC: Exposed in both public and protected endpoints
Use Cases:
allow_public_config: Frontend needs app name, theme colors, public settingsallow_protected_config: SSR needs database config, mail settings, API keys
Configuration Pipes
'pipes' => [ \Quvel\Tenant\Pipes\CoreConfigPipe::class, \Quvel\Tenant\Pipes\BroadcastingConfigPipe::class, \Quvel\Tenant\Pipes\CacheConfigPipe::class, \Quvel\Tenant\Pipes\DatabaseConfigPipe::class, \Quvel\Tenant\Pipes\FilesystemConfigPipe::class, \Quvel\Tenant\Pipes\LoggingConfigPipe::class, \Quvel\Tenant\Pipes\MailConfigPipe::class, \Quvel\Tenant\Pipes\QueueConfigPipe::class, \Quvel\Tenant\Pipes\RedisConfigPipe::class, \Quvel\Tenant\Pipes\SessionConfigPipe::class, \Quvel\Tenant\Pipes\ServicesConfigPipe::class, \Quvel\Tenant\Pipes\QuvelCoreConfigPipe::class, // Add your custom pipes here ],
Configuration pipes apply tenant config to Laravel's runtime configuration. Pipes are executed in array order during tenant resolution.
How it works:
// In TenantMiddleware, after tenant is resolved: $this->configPipeline->apply($tenant, config()); // ConfigurationPipeManager loads pipes from config and executes them: foreach ($this->pipes as $pipe) { $pipe->handle($tenant, $config); }
Available Pipes:
- CoreConfigPipe: App settings (
app.name,app.url,app.timezone,frontend.url, CORS) - BroadcastingConfigPipe: Pusher, Reverb, broadcasting drivers and credentials
- CacheConfigPipe: Cache drivers, Redis configuration, prefixes
- DatabaseConfigPipe: Database connections, credentials, isolated databases
- FilesystemConfigPipe: Storage disks, S3 configuration, CDN settings
- LoggingConfigPipe: Log channels, Sentry configuration, log levels
- MailConfigPipe: SMTP settings, mail drivers, from addresses
- QueueConfigPipe: Queue drivers (database, Redis, SQS), worker configuration
- RedisConfigPipe: Redis connections with tenant prefixing
- SessionConfigPipe: Session drivers and tenant isolation
- ServicesConfigPipe: Third-party APIs (Stripe, PayPal, payment gateways)
- QuvelCoreConfigPipe: Enable tenant scoping for the Quvel Core package
Pipe Implementation:
class CoreConfigPipe extends BasePipe { public function apply(): void { $this->setMany([ 'app.name', // Direct 1:1 mapping 'app.url', 'frontend.url', ]); } }
Custom Pipe Example:
class CustomConfigPipe extends BasePipe { public function apply(): void { // Set config if tenant has the value $this->setIfExists('custom.api_key', 'services.custom.key'); // Set multiple configs at once $this->setMany([ 'custom.endpoint', // Direct mapping 'tenant_theme' => 'app.theme', // Custom mapping ]); } }
Execution Context:
- Executed on every request after tenant resolution
- Receives current tenant and Laravel config repository
- Can modify any Laravel configuration at runtime
- Order matters - later pipes can override earlier ones
Tenant Tables Configuration
'tables' => [ // Users table with proper tenant isolation 'users' => [ 'after' => 'id', 'cascade_delete' => true, 'drop_uniques' => [['email']], 'tenant_unique_constraints' => [['email']] ], 'posts' => true, // Simple registration with defaults // 'orders' => \App\Tenant\Tables\OrdersTableConfig::class, ],
Defines which application tables should have tenant_id columns added. These tables get automatically modified by TenantTableManager to support tenant isolation.
How it works:
// Process configured tables to add tenant_id columns $manager = app(\Quvel\Tenant\Managers\TenantTableManager::class); $results = $manager->processTables(); // Result: ['users' => 'processed', 'posts' => 'skipped_exists']
Configuration Options:
Simple Registration:
'table_name' => true, // Uses default settings
Advanced Configuration:
'users' => [ 'after' => 'id', // Insert tenant_id after this column 'cascade_delete' => true, // Cascade delete when tenant is deleted 'drop_uniques' => [['email']], // Drop these unique constraints 'tenant_unique_constraints' => [['email']] // Create tenant-scoped unique constraints ],
Custom Configuration Class:
'orders' => \App\Tenant\Tables\OrdersTableConfig::class, // OrdersTableConfig.php class OrdersTableConfig { public function getConfig(): TenantTableConfig { return new TenantTableConfig( after: 'id', cascadeDelete: true, dropUniques: [['order_number']], tenantUniqueConstraints: [['order_number']] ); } }
What gets added to tables:
tenant_idforeign key column referencingtenants.id- Index on
tenant_idfor query performance - Optional cascade delete constraint
- Tenant-scoped unique constraints (e.g.,
tenant_id + emailunique)
Automatic Service Table Registration: The manager automatically adds system tables when service isolation is enabled:
// When 'queue.auto_tenant_id' = true 'jobs', 'failed_jobs', 'job_batches' // When 'sessions.auto_tenant_id' = true 'sessions' // When 'cache.auto_tenant_id' = true 'cache', 'cache_locks' // When 'password_reset_tokens.auto_tenant_id' = true 'password_reset_tokens'
Processing Results:
'processed': Table successfully updated with tenant_id'skipped_exists': Table already has tenant_id column'skipped_missing': Table doesn't exist in database'error: message': Processing failed with error details
Usage Example:
// Process all configured tables $manager = app(\Quvel\Tenant\Managers\TenantTableManager::class); $results = $manager->processTables(); // Process specific tables only $results = $manager->processTables(['users', 'posts']); // Remove tenant support $results = $manager->removeTenantSupport(['old_table']);
Scoped Models Configuration
'scoped_models' => [ // Add your application models here // \App\Models\Post::class, // \App\Models\Order::class, // Laravel's built-in models // \Illuminate\Notifications\DatabaseNotification::class, // Sanctum tokens (for API authentication per tenant) // \Laravel\Sanctum\PersonalAccessToken::class, // Spatie permissions (enable if roles should be tenant-scoped) // \Spatie\Permission\Models\Role::class, // \Spatie\Permission\Models\Permission::class, ],
Models that should have tenant scoping automatically applied. The package adds global scopes and model events to enforce tenant isolation without requiring code changes.
How it works:
// In TenantServiceProvider::bootExternalModelScoping() foreach ($models as $modelClass) { // Add global scope for automatic WHERE tenant_id filtering $modelClass::addGlobalScope(new Scopes\TenantScope()); // Add model events for automatic tenant_id assignment and validation $modelClass::creating(function ($model) { if (!isset($model->tenant_id) && !tenant_bypassed()) { $model->tenant_id = tenant_id(); } }); $modelClass::updating(function ($model) { $this->validateTenantMatch($model); // Prevent cross-tenant updates }); $modelClass::deleting(function ($model) { $this->validateTenantMatch($model); // Prevent cross-tenant deletes }); }
Automatic Scoping Behavior:
Query Filtering:
// Automatically scoped to current tenant $posts = Post::all(); // WHERE tenant_id = current_tenant_id // Available query macros $posts = Post::forAllTenants()->get(); // Remove tenant filtering $posts = Post::withoutTenantScope()->get(); // Same as forAllTenants() $posts = Post::forTenant(123)->get(); // Filter for specific tenant ID
Model Creation:
// tenant_id automatically assigned $post = Post::create(['title' => 'New Post']); // Results in: INSERT ... (title, tenant_id) VALUES ('New Post', current_tenant_id)
Cross-Tenant Protection:
// Prevents updating/deleting models from different tenants $otherTenantPost = Post::forAllTenants()->where('tenant_id', 999)->first(); $otherTenantPost->update(['title' => 'Hacked']); // Throws TenantMismatchException
Isolated Database Behavior: For tenants using isolated databases, tenant_id scoping is automatically disabled since database-level isolation provides the separation.
No-Tenant Handling: When no tenant context exists:
throw_no_tenant_exception = true: ThrowsNoTenantExceptionthrow_no_tenant_exception = false: Returns empty results (WHERE 1 = 0)
Bypass Mode:
// Admin operations can bypass tenant scoping without_tenant(function () { return Post::all(); // Returns all posts across all tenants }); // Or using TenantContext TenantContext::bypass(); $allPosts = Post::all();
Events Dispatched:
TenantScopeApplied: When scope is applied to a queryTenantScopeNoTenantFound: When no tenant context existsTenantMismatchDetected: When cross-tenant operation is blocked
Scoping Behavior Configuration
'scoping' => [ // Whether to throw NoTenantException when no tenant is found // When false, returns empty results instead of throwing 'throw_no_tenant_exception' => env('TENANT_THROW_NO_TENANT_EXCEPTION', true), // Whether to automatically add tenant_id to model $fillable arrays 'auto_fillable' => env('TENANT_AUTO_FILLABLE', true), // Whether to automatically add tenant_id to model $hidden arrays 'auto_hidden' => env('TENANT_AUTO_HIDDEN', true), // Skip tenant_id scoping for tenants using isolated databases // When true, skip tenant_id scoping for tenants using isolated databases. // When false, always use tenant_id scoping for consistency. 'skip_tenant_id_in_isolated_databases' => env('TENANT_SKIP_WHEN_ISOLATED', false), ],
Configures how tenant scoping behaves across the application.
throw_no_tenant_exception: Controls behavior when no tenant context exists.
true(default): ThrowsNoTenantExceptionwhen trying to query tenant-scoped models without tenant contextfalse: Returns empty results by applyingWHERE 1 = 0condition
How it works:
// In TenantScope::apply() if (!$tenant) { if (config('tenant.scoping.throw_no_tenant_exception', true)) { throw new NoTenantException('No tenant context found for model...'); } // Return empty results $builder->whereRaw('1 = 0'); }
auto_fillable: Automatically adds tenant_id to model $fillable arrays.
true(default): Models usingTenantScopedtrait gettenant_idadded to$fillablefalse: Manual fillable management required
auto_hidden: Automatically adds tenant_id to model $hidden arrays.
true(default): Models usingTenantScopedtrait gettenant_idadded to$hidden(excludes from JSON serialization)false: Manual hidden management required
How it works:
// In TenantScoped::initializeTenantScoped() if (config('tenant.scoping.auto_fillable', true) && !in_array('tenant_id', $this->getFillable(), true)) { $this->fillable[] = 'tenant_id'; } if (config('tenant.scoping.auto_hidden', true) && !in_array('tenant_id', $this->getHidden(), true)) { $this->hidden[] = 'tenant_id'; }
skip_tenant_id_in_isolated_databases: Controls tenant_id scoping for isolated database tenants.
false(default): Always use tenant_id scoping for consistency across all isolation strategiestrue: Skip tenant_id scoping for tenants using isolated databases since database-level isolation provides separation
How it works:
// In TenantScope and model events if ($this->tenantUsesIsolatedDatabase($tenant)) { return; // Skip tenant_id logic - database isolation handles it } // Otherwise apply tenant_id scoping $builder->where('tenant_id', $tenant->id);
Configuration Trade-offs:
throw_no_tenant_exception = false:
- Pros: Prevents exceptions during development/testing
- Cons: Can hide bugs where tenant context is missing
skip_tenant_id_in_isolated_databases = true:
- Pros: Better performance, cleaner database schema for isolated tenants
- Cons: Inconsistent behavior between isolation strategies
auto_fillable/auto_hidden = false:
- Pros: Full control over model configuration
- Cons: Manual configuration required for every tenant-scoped model
Context Preservation
'preserve_context' => env('TENANT_PRESERVE_CONTEXT', true),
Enables automatic tenant context preservation across async operations using Laravel's Context feature. This ensures tenant context is maintained in queued jobs, HTTP requests, and other async operations.
How it works:
// In TenantServiceProvider::registerContextPreservation() Context::dehydrating(static function ($context): void { $tenant = TenantContextFacade::current(); if ($tenant) { $context->addHidden('tenant', $tenant); } }); Context::hydrated(function ($context): void { if ($context->hasHidden('tenant')) { $tenant = $context->getHidden('tenant'); TenantContextFacade::setCurrent($tenant); app(ConfigurationPipeManager::class)->apply( $tenant, $this->app->make(ConfigRepository::class) ); } });
When enabled (true, default):
- Tenant context is automatically serialized when Laravel dehydrates context (queued jobs, etc.)
- Tenant context is restored when Laravel hydrates context in the worker/async process
- Configuration pipes are re-applied to restore tenant-specific settings
- Works across queue drivers (database, Redis, SQS, etc.)
When disabled (false):
- No automatic context preservation
- Async operations lose tenant context
- Manual tenant context management required for jobs
Use Cases:
Queued Jobs:
// With preserve_context = true dispatch(new ProcessOrderJob($order)); // Job automatically has tenant context and config // With preserve_context = false dispatch(new ProcessOrderJob($order, tenant_id())); // Must manually pass tenant_id and restore context
Event Listeners:
// With preserve_context = true event(new OrderCreated($order)); // Event listeners have tenant context // With preserve_context = false // Listeners lose tenant context if queued
HTTP Client Requests:
// With preserve_context = true Http::post('https://api.service.com', $data); // Outbound requests can include tenant context via middleware // With preserve_context = false // Manual context management for external requests
Performance Considerations:
- Minimal overhead - tenant object is serialized/deserialized
- Context data travels with async operations
- Configuration pipes re-run on context restoration
Requirements:
- Laravel 11+ Context feature
- Works with all queue drivers that support Laravel Context
- No additional setup required
Queue Configuration
'queue' => [ // Enable tenant-aware database queue with automatic tenant_id column management 'auto_tenant_id' => env('TENANT_QUEUE_AUTO_TENANT_ID', false), ],
Enables tenant-aware queue behavior for Laravel's database queue driver. When enabled, this automatically adds tenant_id columns to queue tables and scopes queue operations per tenant.
How it works:
// In TenantServiceProvider if (config('tenant.queue.auto_tenant_id', true)) { // Override database queue connector $manager->addConnector('database', function () { return new TenantDatabaseConnector($this->app['db']); }); // Override failed job provider return new TenantDatabaseUuidFailedJobProvider($app['db'], ...); // Override batch repository return new TenantDatabaseBatchRepository(...); }
What gets modified:
Queue Tables (jobs):
// In TenantDatabaseQueue::buildDatabaseRecord() $record = [ 'queue' => $queue, 'payload' => $payload, 'tenant_id' => TenantContext::current()?->id, // Added automatically ];
Failed Jobs Table (failed_jobs):
// TenantDatabaseUuidFailedJobProvider adds tenant_id when jobs fail $failedJob['tenant_id'] = TenantContext::current()?->id;
Job Batches Table (job_batches):
// TenantDatabaseBatchRepository adds tenant_id to batches $batch['tenant_id'] = TenantContext::current()?->id;
Automatic Table Registration:
When enabled, these tables are automatically added to the tables config for TenantTableManager:
// Automatically added tables when auto_tenant_id = true 'jobs' => [ 'after' => 'id', 'cascade_delete' => true, ], 'failed_jobs' => [ 'after' => 'id', 'cascade_delete' => true, ], 'job_batches' => [ 'after' => 'id', 'cascade_delete' => true, ],
Usage Example:
// Jobs automatically get tenant_id when dispatched dispatch(new ProcessOrderJob($order)); // Failed jobs include tenant_id for isolation // Batches are tenant-scoped automatically // Process tables to add tenant_id columns $manager = app(\Quvel\Tenant\Managers\TenantTableManager::class); $results = $manager->processTables(['jobs', 'failed_jobs', 'job_batches']);
Requirements:
- Database queue driver must be configured in
config/queue.php - Queue tables must exist (run
queue:tableand migrate) - Only affects the
databasequeue driver - Other drivers (Redis, SQS) rely on context preservation
Tenant Context in Jobs:
- Jobs automatically retain tenant context via
preserve_context - Queue isolation works alongside context preservation
- Worker processes restore tenant context and apply configuration pipes
Benefits:
- Complete job isolation per tenant
- Failed job isolation and debugging
- Batch operation isolation
- Database-level queue security
Admin Interface Configuration
'admin' => [ 'enable' => env('TENANT_ADMIN_ENABLE', false), ],
Controls the optional admin interface for tenant management. When enabled, provides a web UI and API endpoints for creating and managing tenants using preset configurations.
enable: Enables/disables the admin interface.
false(default): Admin interface disabled for securitytrue: Enables admin routes, views, and API endpoints
How it works:
// In TenantServiceProvider if (config('tenant.admin.enable', false)) { // Register admin routes with internal middleware protection Route::middleware(config('tenant.middleware.internal_request')) ->group(__DIR__.'/../routes/tenant-admin.php'); // Register view namespace for Blade templates $this->loadViewsFrom(__DIR__.'/../resources/views', 'tenant'); }
Available Routes (when enabled):
// UI Routes GET /admin/tenants/ui - Tenant management interface // API Routes (protected by internal middleware) GET /admin/tenants/presets - Get available presets GET /admin/tenants/presets/{preset}/fields - Get form fields for preset GET /admin/tenants - List all tenants POST /admin/tenants - Create new tenant
Admin Interface Features:
Tenant Creation with Presets:
- Basic Preset: Shared database with minimal configuration
- Isolated Database Preset: Dedicated database with connection settings
- Dynamic form generation based on preset requirements
- Real-time field validation and error handling
Tenant Listing:
- View all existing tenants
- Display tenant names, identifiers, and creation dates
- Sortable and filterable interface
API Integration:
// Get available presets GET /admin/tenants/presets // Response: {"presets": {"basic": {...}, "isolated_database": {...}}} // Get form fields for a preset GET /admin/tenants/presets/basic/fields // Response: {"preset": "basic", "fields": [...]} // Create tenant POST /admin/tenants { "name": "Acme Corp", "identifier": "acme.example.com", "preset": "basic", "config": { "app.name": "Acme Application", "frontend.url": "https://acme.example.com" } }
Security Considerations:
Protected by Internal Middleware:
- Admin routes require
internal_requestmiddleware - Only internal/system tenants can access admin functionality
- Prevents external access to tenant management
Default Disabled:
- Admin interface is disabled by default
- Must be explicitly enabled via environment variable
- Recommended to disable in production environments
Environment Configuration:
// Enable admin interface (development/staging only) TENANT_ADMIN_ENABLE=true // Disable admin interface (production - default) TENANT_ADMIN_ENABLE=false
File Structure:
/resources/views/admin/ui.blade.php - Main admin interface
/routes/tenant-admin.php - Admin route definitions
/Http/Controllers/TenantController.php - Admin API endpoints
Use Cases:
- Development/staging tenant setup
- Internal admin panels
- Automated tenant provisioning
- Tenant configuration management
Production Recommendations:
- Keep
enable = falsein production - Use programmatic tenant creation instead
- Implement custom admin interfaces with proper authentication