our-edu / multi-tenant
Shared multi-tenant infrastructure for OurEdu Laravel services (tenant context, global scope, traits, middleware).
Installs: 215
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
pkg:composer/our-edu/multi-tenant
Requires
- php: >=8.1
- illuminate/database: ^9.0|^10.0|^11.0
- illuminate/http: ^9.0|^10.0|^11.0
- illuminate/support: ^9.0|^10.0|^11.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- mockery/mockery: ^1.5
- orchestra/testbench: ^8.0|^9.0
- phpunit/phpunit: ^10.0
This package is auto-updated.
Last update: 2026-02-04 11:02:05 UTC
README
A Laravel package for building multi-tenant applications. This package provides tenant context management, automatic query scoping, and model traits for seamless multi-tenancy support.
Features
- Tenant Context - Centralized tenant state management across requests, jobs, and commands
- Automatic Query Scoping - All queries automatically filtered by tenant
- Model Trait - Simple
HasTenanttrait for tenant-aware models - Built-in Resolvers - Session and Header resolvers included
- Flexible Resolution - Implement your own tenant resolution strategy
- Middleware Support - HTTP middleware for tenant resolution with excluded routes
- Exception Handling - Throws exception when tenant cannot be resolved (translatable messages)
- Auto-assignment - Automatically sets tenant ID on model creation/update
- Zero Configuration - Works out of the box with sensible defaults
- Customizable - Override tenant column names per model
- Queue Support - Maintain tenant context in queued jobs
- Command Support - Run commands for specific tenants
- Laravel Octane Compatible - Uses scoped bindings for request isolation
Requirements
- PHP 8.1 or higher
- Laravel 9.x, 10.x, or 11.x
Installation
Install the package via Composer:
composer require our-edu/multi-tenant
The package will auto-register its service provider and automatically publish the configuration file.
Quick Start
1. Configure (Optional)
The package uses ChainTenantResolver by default, which tries resolvers in order:
HeaderTenantResolver- Getstenant_idfrom request header for specific routesUserSessionTenantResolver- Getstenant_idfromgetSession()helper
Configure the session helper in config/multi-tenant.php:
'session' => [ 'helper' => 'getSession', // Your helper function name 'tenant_column' => 'tenant_id', // Column on session object ],
2. Add Trait to Models
Option A: Use HasTenant Trait Manually
Add the HasTenant trait to models that should be tenant-scoped:
use Illuminate\Database\Eloquent\Model; use Ouredu\MultiTenant\Traits\HasTenant; class Project extends Model { use HasTenant; }
Option B: Use Artisan Command (Recommended)
Configure your tables and run the command to automatically add the trait:
// config/multi-tenant.php 'tables' => [ 'projects' => \App\Models\Project::class, 'invoices' => \App\Models\Invoice::class, 'orders' => \App\Models\Order::class, ],
# Add HasTenant trait to all configured table models php artisan tenant:add-trait # Preview changes without modifying files php artisan tenant:add-trait --dry-run # Add trait to specific tables only php artisan tenant:add-trait --table=projects --table=invoices
That's it! All queries on configured models will now be automatically scoped to the current tenant.
Configuration
The configuration file is automatically published to config/multi-tenant.php:
return [ // Your tenant model class (used by DomainTenantResolver) 'tenant_model' => App\Models\Tenant::class, // Default tenant column name 'tenant_column' => 'tenant_id', // Session configuration (for UserSessionTenantResolver) 'session' => [ 'helper' => 'getSession', // Helper function name 'tenant_column' => 'tenant_id', ], // Header configuration (for HeaderTenantResolver) 'header' => [ 'name' => 'X-Tenant-ID', // Header name containing tenant ID 'routes' => [ // Routes where header resolution is allowed // 'api.external.*', // 'api/v1/external/*', ], ], // Excluded routes (bypass tenant resolution in middleware) 'excluded_routes' => [ // 'health', // 'login', // 'password/*', ], // Domain configuration (for DomainTenantResolver) 'domain' => [ 'column' => 'domain', ], // Tables mapped to models (for migration, trait command, and query listener) 'tables' => [ // 'users' => \App\Models\User::class, // 'orders' => \App\Models\Order::class, ], // Query listener (logs queries without tenant_id filter) 'query_listener' => [ 'enabled' => true, 'log_channel' => null, // null = default channel ], ];
Database Migration
Add tenant_id column to your configured tables:
# Add tenant_id to all configured tables php artisan tenant:migrate # Add tenant_id to specific tables php artisan tenant:migrate --table=users --table=orders # Remove tenant_id from tables (rollback) php artisan tenant:migrate --rollback
Query Listener
The package includes a database query listener that logs errors when queries are executed on tenant tables without a tenant_id filter.
Configuration
'tables' => [ 'users' => \App\Models\User::class, 'orders' => \App\Models\Order::class, ], 'query_listener' => [ 'enabled' => env('MULTI_TENANT_QUERY_LISTENER_ENABLED', true), 'log_channel' => env('MULTI_TENANT_QUERY_LISTENER_CHANNEL'), 'primary_keys' => ['id', 'uuid'], // Primary key columns to skip ],
Smart Detection
The query listener is smart about detecting safe queries:
- Primary Key Operations: UPDATE/DELETE by
idoruuidare considered safe (model was already loaded with tenant scope) - Excluded Models: Models with
$withoutTenantScope = trueare skipped - Configurable Primary Keys: Add custom primary key columns to
primary_keysconfig
Log Output
When a query without tenant filter is detected:
{
"message": "Query executed without tenant_id filter",
"context": {
"table": "orders",
"sql": "SELECT * FROM orders WHERE status = ?",
"bindings": ["pending"],
"tenant_id": 1,
"file": "/app/Http/Controllers/OrderController.php",
"line": 45
}
}
Usage
Tenant Context
Access the current tenant ID anywhere in your application:
use Ouredu\MultiTenant\Tenancy\TenantContext; $context = app(TenantContext::class); // Get current tenant ID $tenantId = $context->getTenantId(); // Check if tenant exists if ($context->hasTenant()) { // ... } // Manually set tenant ID (for testing, jobs, commands) $context->setTenantId($tenantId); // Run code in tenant context $context->runForTenant($tenantId, function () { // All queries scoped to this tenant });
Model Trait
use Ouredu\MultiTenant\Traits\HasTenant; class Invoice extends Model { use HasTenant; // Optional: custom tenant column public function getTenantColumn(): string { return 'organization_id'; } }
The trait provides:
- Automatic global scope for tenant filtering
- Automatic tenant ID assignment on create/update
tenant()relationship methodscopeForTenant($query, $tenantId)scope
Middleware
Register and use the tenant middleware:
// In bootstrap/app.php or Kernel.php ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'tenant' => \Ouredu\MultiTenant\Middleware\TenantMiddleware::class, ]); }) // In routes Route::middleware('tenant')->group(function () { Route::resource('projects', ProjectController::class); });
Excluded Routes
Configure routes that should bypass tenant resolution:
// config/multi-tenant.php 'excluded_routes' => [ 'health', // Exact match 'api/health', // Exact path 'password/*', // Wildcard pattern 'auth.*', // Route name pattern ],
Exception Handling
When no resolver can determine the tenant ID, a TenantNotResolvedException is thrown. This ensures all non-excluded routes have a valid tenant context.
use Ouredu\MultiTenant\Exceptions\TenantNotResolvedException; // Handle in your exception handler public function render($request, Throwable $e) { if ($e instanceof TenantNotResolvedException) { return response()->json(['error' => 'Tenant not found'], 404); } return parent::render($request, $e); }
Header Tenant Resolver
For API routes where the tenant ID is passed as a header (e.g., external integrations, webhooks):
// config/multi-tenant.php 'header' => [ 'name' => 'X-Tenant-ID', // Header name 'routes' => [ 'api.external.*', // Route name pattern 'api/v1/webhook/*', // URI pattern ], ],
Then send requests with the header:
curl -H "X-Tenant-ID: 123" https://api.example.com/api/v1/webhook/process
Queued Jobs
For jobs that need tenant context, set the tenant ID in the job:
class ProcessInvoice implements ShouldQueue { public ?int $tenantId = null; public function __construct(public Invoice $invoice) { $this->tenantId = app(TenantContext::class)->getTenantId(); } public function handle(): void { // Restore tenant context if ($this->tenantId) { app(TenantContext::class)->setTenantId($this->tenantId); } // Process invoice... } }
Artisan Commands
Run commands for specific tenants:
class GenerateReports extends Command { protected $signature = 'reports:generate {--tenant= : Tenant ID}'; public function handle(): int { $tenantId = $this->option('tenant'); if ($tenantId) { app(TenantContext::class)->setTenantId((int) $tenantId); } // Generate reports... return self::SUCCESS; } }
API Reference
TenantContext
| Method | Description |
|---|---|
getTenantId(): ?int |
Get the current tenant ID |
hasTenant(): bool |
Check if a tenant is set |
setTenantId(?int $tenantId): void |
Manually set the tenant ID |
clear(): void |
Clear the tenant context |
runForTenant(int $tenantId, callable $callback): mixed |
Run callback with specific tenant |
HasTenant Trait
| Method | Description |
|---|---|
tenant(): BelongsTo |
Relationship to tenant model |
scopeForTenant($query, int $id): Builder |
Scope to specific tenant |
getTenantColumn(): string |
Get tenant column name (override) |
Translations
The package supports translatable exception messages. Language files are automatically published when the package is installed.
Supported languages: English (en), Arabic (ar)
To manually re-publish or update the language files:
php artisan vendor:publish --tag=multi-tenant-lang --force
Published files location: lang/vendor/multi-tenant/
// lang/vendor/multi-tenant/en/exceptions.php return [ 'tenant_not_resolved' => 'Unable to resolve tenant. No resolver returned a valid tenant ID.', ]; // lang/vendor/multi-tenant/ar/exceptions.php return [ 'tenant_not_resolved' => 'غير قادر على تحديد المستأجر. لم يُرجع أي محلل معرف مستأجر صالح.', ];
Adding More Languages
Create additional language files in lang/vendor/multi-tenant/{locale}/exceptions.php:
// lang/vendor/multi-tenant/fr/exceptions.php return [ 'tenant_not_resolved' => 'Impossible de résoudre le locataire. Aucun résolveur n\'a retourné un ID de locataire valide.', ];
Testing
# Run tests composer test # Run with coverage composer test:coverage
Contributing
Please see CONTRIBUTING.md for details.
Changelog
Please see CHANGELOG.md for version history.
License
The MIT License (MIT). Please see LICENSE for more information.