Laravel Eloquent: Complex Multi-Tenant Polymorphic Relations with Pivot Constraints Breaking in Queue Jobs

5 days ago 8
ARTICLE AD BOX

Laravel 11.35.1 | PHP 8.4.2 | PostgreSQL 16.5

I'm implementing a multi-tenant SaaS with polymorphic relations across 3 database connections (tenant1, tenant2, shared). The issue occurs only in queue jobs where pivot constraints fail due to tenant context loss.

Database Schema

SQL

-- shared database tenants id (uuid), name, database_name -- tenant1 database users id (uuid), tenant_id (uuid), name, email workspaces id (uuid), tenant_id (uuid), name -- tenantX databases (dynamic) workspace_members (pivot) id (uuid), workspace_id (uuid), member_id (uuid), member_type -- member_type: 'App\Models\User', 'App\Models\Team', 'App\Models\ExternalGuest'

Models

PHP

// app/Models/Tenant.php (shared DB) class Tenant extends Model { protected $connection = 'shared'; public function database() { return $this->belongsToMany(Workspace::class, 'workspace_members', 'tenant_id', 'workspace_id')->withPivot('member_id', 'member_type'); } } // app/Models/Workspace.php (tenant DB) class Workspace extends Model { protected $connection = null; // Dynamic public function members() { return $this->morphToMany( get_class(), // Dynamic: User|Team|ExternalGuest 'member', 'workspace_members', 'workspace_id', 'member_id' )->withPivot('tenant_id'); // Tenant isolation } public function membersInCurrentTenant() { return $this->members()->wherePivot('tenant_id', tenant()->id); } } // Dynamic model resolution class DynamicModelResolver { public static function resolve(string $type): Model { $tenantId = tenant()->id; // Switch DB connection config(['database.connections.tenant.database' => "tenant_{$tenantId}"]); DB::purge('tenant'); DB::reconnect('tenant'); return match($type) { 'App\Models\User' => User::on('tenant'), 'App\Models\Team' => Team::on('tenant'), 'App\Models\ExternalGuest' => ExternalGuest::on('tenant') }; } }

The Problem

Works in HTTP requests

PHP

// In controller - WORKS $workspace = Workspace::find('uuid'); $members = $workspace->membersInCurrentTenant()->get(); // Returns only members where pivot.tenant_id = current_tenant

Fails in Queue Jobs

PHP

// In queue job - BROKEN class ProcessWorkspaceActivity extends Job { public function handle() { $workspace = Workspace::find('uuid'); $members = $workspace->membersInCurrentTenant()->get(); // Returns ALL members across ALL tenants! // pivot.tenant_id constraint is ignored } }

Debug Output

HTTP Request:

text

SQL: select * from workspace_members where workspace_id = ? and tenant_id = 'tenant-uuid-here'

Queue Job:

text

SQL: select * from workspace_members where workspace_id = ? -- NO tenant_id constraint!

What I've Tried

Tenant Middleware in Jobs

PHP

class TenantAwareJob extends Job { public $tenantId; public function handle() { tenant($this->tenantId); // Sets global tenant // Still broken } } Global Scopes

PHP

// WorkspaceMemberObserver class WorkspaceMemberObserver { public function retrieved(WorkspaceMember $model) { if (!tenant()) return; $model->where('tenant_id', tenant()->id); } } Connection Switching

PHP

DB::connection('tenant')->table('workspace_members') ->where('workspace_id', $id) ->where('tenant_id', tenant()->id); // Works but bypasses Eloquent

Minimal Reproducible Example

PHP

// DatabaseSeeder DB::connection('tenant1')->table('workspace_members')->insert([ ['workspace_id' => 'ws1', 'member_id' => 'user1', 'member_type' => 'App\Models\User', 'tenant_id' => 'tenant1'], ['workspace_id' => 'ws1', 'member_id' => 'user2', 'member_type' => 'App\Models\User', 'tenant_id' => 'tenant2'], // Cross-tenant! ]); // Test job php artisan queue:work --once

Expected: 1 member (tenant1 only) Actual: 2 members (both tenants)

Questions

Why does the pivot constraint disappear in queue jobs?

How to preserve tenant context in Eloquent relations during queue processing?

Best practice for multi-tenant polymorphic relations with dynamic DB connections?

Current Workaround (ugly)

PHP

// Forces tenant constraint manually $members = $workspace->members() ->whereHas('pivot', fn($q) => $q->where('tenant_id', tenant()->id)) ->get();

But this breaks:

with('members') eager loading

members()->count()

Any relation chaining


Has anyone solved multi-tenant polymorphic relations in Laravel queues? 🤯

Read Entire Article