SaaS multi-tenant en Laravel: una base de datos, un global scope y las fugas que no detecta: 02 de 08

Cómo aislar empresas dentro de una sola app Laravel: elegir un modelo de tenancy, resolver el tenant desde la URL, aplicarlo con un scope que falla cerrado y probar las fugas, incluidos los jobs y comandos que corren sin usuario.

Imagina un equipo que lanza un SaaS de facturación en tres meses. Cada consulta lleva un where('company_id', $companyId), los revisores lo buscan en cada pull request y durante medio año nada sale mal. Entonces alguien agrega un job nocturno que envía recordatorios de facturas vencidas. Lo ejecuta el scheduler, así que no hay usuario con sesión, y el helper que antes entregaba el id de la empresa devuelve null. La consulta pierde su filtro sin hacer ruido, y el job lee las facturas de todas las empresas.

Nada de esa historia es exótico. Es lo que pasa cuando el aislamiento entre tenants depende de que cada desarrollador recuerde una línea, y de un contexto de petición que los jobs y los comandos no tienen.

Este artículo trata de aislar empresas dentro de una sola app Laravel: qué modelo de tenancy elegir, cómo resolver la empresa actual, cómo imponerla para que olvidarla sea imposible y cómo probarlo. Cómo organizar la lógica de negocio (Actions, Data objects, Events) queda para los siguientes artículos de la serie.

Tres formas de darle a cada empresa su propio espacio

La primera decisión es dónde viven físicamente los datos de cada empresa. Hay tres respuestas habituales.

Modelo Qué separa a los tenants Qué cuesta
Base de datos compartida, columna company_id Tu código, opcionalmente respaldado por row-level security en la base de datos Lo más barato de operar: una migración, un pool de conexiones, un respaldo. El aislamiento es tan bueno como tu disciplina
Un schema por empresa (PostgreSQL) El schema de la base de datos Una frontera real, pero cada migración corre una vez por schema y cada petición tiene que cambiar de schema
Una base de datos por empresa El servidor de base de datos El aislamiento más fuerte y restauración por empresa, pero las migraciones, las conexiones y la factura crecen con cada cliente nuevo

Para un producto que todavía busca su mercado, yo empezaría con la base de datos compartida. Se parece al argumento de Modular Monolith or Microservices: A Framework for Deciding Without Dogma (en inglés): la opción más barata suele dar la mayor parte del beneficio, siempre que dejes abierta la salida. El precio es que el aislamiento ahora vive en tu código. El resto de este artículo trata de hacer que ese código sea difícil de esquivar.

La URL decide quién es el tenant

La siguiente pregunta: ¿cómo dice una petición a qué empresa pertenece? Un subdominio, una cabecera, la sesión, un segmento de la ruta. La opción tentadora es una columna "empresa actual" en el usuario. Cambias una vez y todas las peticiones posteriores ya lo saben. Se rompe en cuanto alguien abre dos pestañas. La columna guarda una respuesta por usuario, las pestañas piden dos, y gana la última que cambió, para ambas.

Yo prefiero la ruta: /{company-slug}/invoices. La URL es el único estado que pertenece a la petición misma. Sobrevive a los marcadores y a los enlaces compartidos, y pone al tenant en tus logs.

Un middleware resuelve el slug, comprueba la pertenencia y abre el contexto del tenant para el resto de la petición.

class ResolveCompany { public function __construct(private TenantContext $tenant) {} public function handle(Request $request, Closure $next): Response { $user = $request->user() ?? throw new AuthenticationException; // Los bindings aún no se han ejecutado, así que esto sigue siendo el slug crudo. $company = Company::where('slug', $request->route('company'))->first(); abort_if( $company === null || ! $user->companies()->whereKey($company->id)->exists(), 404, ); return $this->tenant->runAs($company->id, fn () => $next($request)); } } 

Devuelvo 404 a quien no es miembro para que la URL no confirme que una empresa existe. Un 403 es más amable con un usuario legítimo que se equivocó al escribir, así que elige uno a conciencia.

Hay una trampa, y es de orden. El route model binding ocurre en SubstituteBindings, y cuando se resuelve {invoice}, esa consulta pasa por los global scopes del modelo. Si el tenant todavía no está definido, el binding corre sin scope. En la lista de prioridad de middleware por defecto de Laravel, ese middleware también está antes de la autenticación, y por eso el código de arriba lee el usuario por su cuenta. Mueve el middleware del tenant antes de los bindings en bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void { $middleware->prependToPriorityList( before: \Illuminate\Routing\Middleware\SubstituteBindings::class, prepend: \App\Http\Middleware\ResolveCompany::class, ); }) 

Y las rutas:

Route::middleware(['auth', ResolveCompany::class]) ->prefix('{company}') ->group(function () { Route::resource('invoices', InvoiceController::class); }); 

El middleware llama a algo llamado TenantContext. Esa es la siguiente pieza, y la que necesitará el job nocturno.

Un solo lugar que sabe cuál es la empresa actual

La solución a la historia del principio empieza con una regla: nadie lee el id de la empresa desde auth()->user(). Todo se lo pregunta a un objeto pequeño, y quien quiera ejecutar código como una empresa tiene que decirlo en voz alta.

final class TenantContext { private ?int $companyId = null; public function id(): ?int { return $this->companyId; } public function runAs(int $companyId, Closure $callback): mixed { $previous = $this->companyId; $this->companyId = $companyId; try { return $callback(); } finally { $this->companyId = $previous; } } } 

Regístralo con scoped, no con singleton:

$this->app->scoped(TenantContext::class); 

Según la documentación del container, las instancias scoped se descartan cuando un worker de Octane toma una petición nueva o un worker de colas toma un job nuevo. Un singleton normal arrastraría al tenant anterior a la siguiente unidad de trabajo, que es la misma fuga con otro disfraz. El bloque finally es una segunda línea de defensa para el caso en que el código llame a runAs y lance una excepción.

Un scope que falla cerrado

Ahora la parte que todos escriben primero. Un global scope filtra todas las consultas de un modelo, y un trait puede engancharlo y rellenar company_id al crear. La diferencia con la versión habitual está en qué pasa cuando no hay empresa: lanza una excepción en lugar de omitir el filtro (fail closed).

class TenantScope implements Scope { public function apply(Builder $builder, Model $model): void { $companyId = app(TenantContext::class)->id() ?? throw new MissingTenantContext($model::class); $builder->where($model->qualifyColumn('company_id'), $companyId); } } trait BelongsToCompany { public static function bootBelongsToCompany(): void { static::addGlobalScope(new TenantScope); static::creating(function (Model $model): void { $model->company_id ??= app(TenantContext::class)->id() ?? throw new MissingTenantContext($model::class); }); } } 

Acóplalo a una migración donde la columna no pueda ser nula y la empresa vaya primero en los índices, ya que casi todas las consultas filtran por ella:

$table->foreignId('company_id')->constrained(); $table->string('number'); $table->unique(['company_id', 'number']); 

El costo de fallar cerrado es real. Las pantallas de administración, los seeders, los reportes entre todas las empresas y cualquier otra cosa que legítimamente necesite datos sin scope tienen que decirlo con Invoice::withoutGlobalScope(TenantScope::class). Yo lo cuento como una ventaja. Cada consulta sin scope es ahora una línea que puedes buscar con grep en la revisión de código, y la lista debería mantenerse corta.

Un scope también tiene puntos ciegos, y conviene conocerlos antes de confiar en él:

  • DB::table('invoices') y el SQL crudo nunca ven los scopes de Eloquent.
  • Las reglas de validación unique y exists consultan la tabla directamente, así que ignoran el scope. Agrega la condición tú mismo: Rule::unique('invoices', 'number')->where('company_id', $companyId).
  • Cualquier cosa que cruce un modelo que no use el trait es tan segura como ese otro modelo.

El job nocturno, otra vez

Volvamos al job de recordatorios. Con las reglas nuevas no puede correr como estaba escrito, porque nada define la empresa. Así que el job recibe la empresa de forma explícita y un middleware de job abre el contexto a su alrededor. El middleware de jobs, descrito en la documentación de colas, envuelve al job igual que el middleware de rutas envuelve una petición.

class RunsAsCompany { public function handle(object $job, Closure $next): void { app(TenantContext::class)->runAs($job->companyId, fn () => $next($job)); } } class SendOverdueReminders implements ShouldQueue { use Queueable; public function __construct(public int $companyId) {} public function middleware(): array { return [new RunsAsCompany]; } public function handle(): void { // Invoice::query() ahora está filtrada a $this->companyId } } 

El scheduler reparte un job por empresa:

Schedule::call(function () { Company::query()->each( fn (Company $company) => SendOverdueReminders::dispatch($company->id) ); })->dailyAt('02:00'); 

Tiene un costo: N jobs en lugar de uno. A cambio, una empresa lenta o que falla no bloquea a las demás, los reintentos se quedan dentro de un solo tenant y el tenant es visible en el payload en lugar de esconderse en estado ambiental. Company en sí no usa el trait, porque es el tenant.

Empresas, usuarios y roles

En un producto multiempresa, una persona suele pertenecer a varias empresas. Eso decide dónde vive el rol. Pertenece a la membresía, una tabla pivote company_user con una columna role, y no al usuario. La misma persona puede ser admin en una empresa y viewer en otra, y el middleware ya sabe de qué empresa trata la petición.

La tentación es darle un rango a los roles y comparar números. Funciona durante un tiempo. Los permisos como cadenas simples por rol (invoice:create, invoice:void) envejecen mejor, porque el día que alguien pida un rol que pueda anular facturas pero no editarlas, agregas una fila en lugar de reordenar una jerarquía. Comprueba los permisos con las policies de Laravel, y pasa el rol de la membresía, no una marca a nivel de usuario.

Demuéstralo con un test que intenta filtrar

Un scope que no has atacado es un scope que esperas que funcione. Tres tests cubren la historia hasta aquí, escritos con Pest:

it('nunca devuelve facturas de otra empresa', function () { $acme = Company::factory()->create(); $globex = Company::factory()->create(); $tenant = app(TenantContext::class); $tenant->runAs($globex->id, fn () => Invoice::factory()->create()); expect($tenant->runAs($acme->id, fn () => Invoice::count()))->toBe(0); }); it('se niega a consultar sin tenant', function () { Invoice::count(); })->throws(MissingTenantContext::class); it('devuelve 404 para una empresa a la que el usuario no pertenece', function () { $other = Company::factory()->create(); $this->actingAs(User::factory()->create()) ->get("/{$other->slug}/invoices") ->assertNotFound(); }); 

El segundo test es el que habría atrapado la historia del principio. Cuando estos tests existan, agrega una guarda que liste todas las tablas con una columna company_id y falle si su modelo no usa el trait. Las tablas nuevas son donde nacen las fugas.

Dónde deja de funcionar el schema compartido

Este diseño tiene límites, y es mejor verlos pronto. Un solo tenant grande puede dominar los índices y el pool de conexiones, así que los vecinos ruidosos son una posibilidad real. Restaurar una empresa desde un respaldo significa extraer sus filas de un respaldo de todos. Y algunos clientes, sobre todo los enterprise o regulados, pedirán su propia base de datos por muy bueno que sea tu scope.

El diseño también te deja una salida. Como todas las consultas pasan por un solo contexto y un solo trait, mover a un cliente grande a su propia base de datos más adelante es un refactor mucho más pequeño que en un código lleno de cláusulas where escritas a mano. Ese es el argumento para hacerlo así aunque nunca necesites la salida.

Conclusión

El job nocturno que envió correos a los clientes equivocados no necesitaba un filtro más ingenioso. Necesitaba un sistema donde correr sin empresa sea un error, y donde ese error aparezca en un test mucho antes de que lo vea un cliente. Resuelve el tenant desde la URL, guárdalo en un solo lugar, haz que el scope falle cerrado e intenta filtrar a propósito.

Si quieres una acción para hoy, busca con grep en tu código auth()->user()->company_id y withoutGlobalScopes, y escribe el test "se niega a consultar sin tenant". En el siguiente artículo dejamos atrás el aislamiento y vemos dónde debe vivir la lógica de negocio para que los controladores se mantengan delgados.