tomatic query scoping, and framework-native interception. Below are production-ready implementations for Laravel and Node.js.
Laravel: Global Scopes and Middleware
Laravel's middleware pipeline and Eloquent global scopes provide a robust mechanism for enforcing tenant context at the request lifecycle boundary.
namespace App\Http\Middleware;
use App\Services\TenantContext;
use Closure;
use Illuminate\Http\Request;
class ResolveTenant
{
public function __construct(private TenantContext $tenantContext) {}
public function handle(Request $request, Closure $next)
{
$tenantId = $this->resolveTenantId($request);
if (!$tenantId) {
return response()->json(['error' => 'Tenant could not be resolved.'], 403);
}
$this->tenantContext->set($tenantId);
$response = $next($request);
$this->tenantContext->clear();
return $response;
}
private function resolveTenantId(Request $request): ?string
{
if ($request->hasHeader('X-Tenant-ID')) {
return $this->validateTenantAccess(
$request->user(),
$request->header('X-Tenant-ID')
);
}
$host = $request->getHost();
$subdomain = explode('.', $host)[0] ?? null;
if ($subdomain) {
return $this->resolveTenantBySubdomain($subdomain);
}
return $request->user()?->default_tenant_id;
}
private function validateTenantAccess($user, string $tenantId): ?string
{
if (!$user) return null;
$hasAccess = $user->tenants()->where('tenant_id', $tenantId)->exists();
return $hasAccess ? $tenantId : null;
}
private function resolveTenantBySubdomain(string $subdomain): ?string
{
return cache()->remember(
"tenant:subdomain:{$subdomain}",
now()->addHours(1),
fn () => \App\Models\Tenant::where('subdomain', $subdomain)->value('id')
);
}
}
Enter fullscreen mode Exit fullscreen mode
namespace App\Services;
class TenantContext
{
private ?string $tenantId = null;
public function set(string $tenantId): void
{
$this->tenantId = $tenantId;
}
public function get(): string
{
if ($this->tenantId === null) {
throw new \RuntimeException('Tenant context not set. This is a critical isolation failure.');
}
return $this->tenantId;
}
public function clear(): void
{
$this->tenantId = null;
}
public function isSet(): bool
{
return $this->tenantId !== null;
}
}
Enter fullscreen mode Exit fullscreen mode
Automatic Query Scoping with Eloquent
The most dangerous aspect of row-level tenancy is forgetting to add WHERE tenant_id = ? to a query. Eloquent global scopes eliminate this risk:
namespace App\Models\Traits;
use App\Models\Scopes\TenantScope;
use App\Services\TenantContext;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
if (!$model->tenant_id) {
$model->tenant_id = app(TenantContext::class)->get();
}
});
}
}
Enter fullscreen mode Exit fullscreen mode
namespace App\Models\Scopes;
use App\Services\TenantContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantContext = app(TenantContext::class);
if ($tenantContext->isSet()) {
$builder->where($model->getTable() . '.tenant_id', $tenantContext->get());
}
}
}
Enter fullscreen mode Exit fullscreen mode
Now every model that uses the BelongsToTenant trait automatically filters by tenant:
class Invoice extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'number', 'amount', 'status'];
}
// These queries automatically include WHERE tenant_id = ?
$invoices = Invoice::where('status', 'pending')->get();
$invoice = Invoice::findOrFail($id);
$count = Invoice::count();
Enter fullscreen mode Exit fullscreen mode
Node.js: Middleware and Query Builder Integration
Node.js requires explicit async context management to prevent context bleeding across concurrent requests. AsyncLocalStorage provides a reliable execution context boundary.
import { Request, Response, NextFunction } from 'express';
import { AsyncLocalStorage } from 'async_hooks';
interface TenantContextData {
tenantId: string;
}
export const tenantStorage = new AsyncLocalStorage<TenantContextData>();
export function getTenantId(): string {
const context = tenantStorage.getStore();
if (!context?.tenantId) {
throw new Error('Tenant context not set. This is a critical isolation failure.');
}
return context.tenantId;
}
export function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const tenantId = resolveTenantId(req);
if (!tenantId) {
res.status(403).j
Pitfall Guide
- Async Context Leakage: Failing to clear or isolate tenant context in async/threaded environments causes request bleeding. Always wrap request handling in execution context boundaries (e.g.,
AsyncLocalStorage.run() in Node.js, middleware finally blocks in Laravel) and explicitly clear context after response dispatch.
- Bypassing Global Scopes with Raw Queries: Using
DB::raw(), knex.raw(), or ORM bypass methods without manual tenant filtering creates immediate data leakage. Implement query interceptors or wrapper functions that inject tenant constraints into raw SQL, and enforce code review policies blocking direct DB access in tenant-scoped modules.
- Incomplete Tenant Resolution Validation: Relying solely on subdomains or headers without cross-validating user-tenant associations allows IDOR-style attacks. Always verify
user.tenants.contains(tenant_id) before accepting a resolved tenant ID, and reject requests where the association fails.
- Cache Poisoning in Tenant Lookups: Caching tenant resolution results without proper key isolation or TTL management can return stale or cross-tenant data. Use namespaced cache keys (
tenant:subdomain:{hash}), enforce strict TTLs, and implement cache invalidation hooks on tenant/subdomain updates.
- Missing Scopes on New Models: Forgetting to apply the
BelongsToTenant trait or equivalent scoping mechanism to newly created entities bypasses isolation entirely. Enforce this via linting rules, base model inheritance, or automated schema validation in CI/CD pipelines.
- Inadequate Integration Testing Strategy: Unit tests rarely simulate cross-tenant request sequences or connection pooling behavior. Implement dedicated tenant isolation test suites that run concurrent requests across multiple tenants, assert query plan filtering, and verify that global scopes are applied before query execution.
Deliverables
- Multi-Tenant Architecture Blueprint: A comprehensive reference architecture detailing request lifecycle flow, context propagation boundaries, database schema conventions, and framework-specific interception points for Laravel and Node.js.
- Data Isolation Security Checklist: A 24-point validation matrix covering tenant resolution, context lifecycle management, query scoping enforcement, cache isolation, raw query auditing, and CI/CD pipeline gates.
- Configuration Templates: Production-ready middleware, context service classes, global scope implementations, and test harness scaffolds for both Laravel (PHP) and Node.js (TypeScript/Express), ready for direct integration into existing codebases.