Back to Blog
Lesson 29 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 28, 20263 min read

Multi-Tenant Security: Eloquent Isolation and Tenant-Aware Scopes

Master multi-tenant security by implementing robust row-level isolation in Laravel. Learn to build tenant-aware Eloquent scopes that prevent cross-tenant leaks.

LaravelSecurityMulti-tenancyEloquentArchitecturephpbackend

Previously in this course, we explored JWT and Stateless Security to manage identity. Now, we must ensure that once a user is authenticated, they cannot access data belonging to another customer. In a shared-database architecture, "security by convention" is insufficient; you need a system that enforces isolation at the database query level.

The Principle of Implicit Isolation

In a multi-tenant application using a shared database, every table typically contains a tenant_id column. The primary risk is a developer forgetting to add ->where('tenant_id', $currentTenantId) to a query, which exposes sensitive information to unauthorized users.

We solve this by moving from explicit filtering to Global Scopes. By binding the tenant context to the application lifecycle, we force Eloquent to append the necessary constraints to every SELECT, UPDATE, and DELETE operation automatically.

Implementing a Tenant-Aware Scope

To implement this, we first need a contract or a trait that defines which models are "tenant-sensitive."

PHP
namespace App\Traits;

use App\Models\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant()
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function ($model) {
            if (is_null($model->tenant_id)) {
                $model->tenant_id = auth()->user()?->tenant_id;
            }
        });
    }
}

The TenantScope class then performs the heavy lifting by modifying the query builder instance:

PHP
namespace App\Models\Scopes;

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)
    {
        #6A9955">// Fallback to a service or request-bound tenant provider
        $tenantId = app('current_tenant_id');
        
        if ($tenantId) {
            $builder->where($model->getTable() . '.tenant_id', $tenantId);
        }
    }
}

Advancing the Project: Tenant Context Provider

In our ongoing project, we need a reliable way to resolve the current_tenant_id. Relying directly on auth() inside a scope can be fragile (e.g., during background jobs). Instead, we use a dedicated TenantManager service that is set during the request cycle via middleware.

  1. Create the Manager: Stores the ID in a singleton container.
  2. Middleware: Extracts the tenant identifier (from a header or sub-domain) and calls TenantManager::setTenant($id).
  3. Integration: The TenantScope now pulls from app(TenantManager::class)->getTenantId().

This architecture ensures that even if you forget to filter in your controller, the query will fail or return empty rather than leaking data. As discussed in Preventing BOLA Vulnerabilities in Multi-Tenant API Architectures, this is your primary defense against Broken Object Level Authorization (BOLA).

Hands-on Exercise

  1. Create a TenantScope class that filters by tenant_id.
  2. Apply the BelongsToTenant trait to your Project and Task models.
  3. Write a test case that attempts to fetch a Task by ID where the tenant_id does not match the currently set tenant. Assert that the result is null or a ModelNotFoundException.
  4. Challenge: Implement a withoutTenant() method in your scope to allow administrative access for cross-tenant reporting when explicitly required.

Common Pitfalls

  • Background Jobs: If you don't explicitly pass the tenant_id into a queued job, the TenantManager will be empty, and your jobs will fail to find any records. Always serialize the tenant_id into your job constructor.
  • Querying Across Tenants: Sometimes you need to aggregate data across tenants (e.g., global analytics). Using Model::withoutGlobalScope(TenantScope::class)->get() is the standard way to bypass the filter, but ensure this is wrapped in strict authorization logic.
  • Performance: Ensure your tenant_id column is included in every composite index. As we covered in Advanced Indexing Strategies, filtering on an unindexed column will lead to full table scans as your tenant count grows.

Recap

Security in multi-tenancy is not an "add-on"—it must be baked into the Eloquent layer. By using Global Scopes, we eliminate the possibility of human error in controller-level queries. While this provides strong isolation, remember that it is only one layer of defense; you should still audit your API endpoints for BOLA risks as outlined in our security roadmap.

For environments requiring even stricter isolation, consider exploring Postgres RLS for Multi-Tenant SaaS, which moves the enforcement from the application layer down to the database engine itself.

Up next: We will discuss how to prevent Server-Side Request Forgery (SSRF) when your application needs to fetch resources from external URLs on behalf of different tenants.

Similar Posts