Multi-tenancy en Laravel

Multi-tenancy en Laravel
Solucionex
15
Oct 26

Cuando desarrollamos un SaaS, una de las primeras decisiones es cómo separar los datos de cada cliente. Todos usan la misma aplicación, pero cada cliente (lo que se suele llamar tenant) solo debe ver lo suyo. Es una decisión que conviene pensar bien al principio, porque cambiarla con la aplicación ya en producción es bastante costoso.

En este artículo vamos a ver las tres formas más habituales de hacerlo en Laravel 13, con el código de cada una, y los sitios por los que se suelen escapar datos de un cliente a otro. Todo el código lo hemos probado en un proyecto con Laravel 13.34 sobre DDEV, con MySQL para las dos primeras opciones y PostgreSQL para la tercera.

 

Las tres opciones, de un vistazo

  • Una base de datos con tenant_id: aislamiento lógico (depende de tu código) y complejidad baja. Las migraciones se ejecutan una sola vez y los informes que cruzan clientes son fáciles, pero hacer backup o restaurar un solo cliente es difícil. Es la opción con menor coste de infraestructura y funciona con cualquier motor.
  • Un esquema por tenant: aislamiento medio y complejidad media. Las migraciones se ejecutan una vez por tenant, restaurar un solo cliente es posible y los informes que cruzan clientes son difíciles. Coste bajo, pero solo con PostgreSQL.
  • Una base de datos por tenant: aislamiento total y complejidad alta. Las migraciones se ejecutan una vez por tenant, restaurar un solo cliente es fácil y los informes que cruzan clientes son difíciles. El coste de infraestructura crece con cada cliente. Funciona con cualquier motor.

Si no hay un motivo concreto para aislar físicamente los datos, nuestra recomendación es empezar con una base de datos compartida y una columna tenant_id. Es la opción más sencilla de mantener y aguanta mucho más de lo que parece. Al final del artículo comentamos en qué casos compensa ir a por las otras dos.

 

Opción 1: una base de datos con tenant_id

Todos los clientes comparten las mismas tablas, y cada fila lleva una columna tenant_id que indica a quién pertenece.

Las tablas

Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('subdomain')->unique();
    $table->timestamps();
});

Schema::create('invoices', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->string('number');
    $table->decimal('total', 10, 2);
    $table->timestamps();

    $table->unique(['tenant_id', 'number']);
});

El índice único combina tenant_id y number, porque el número de factura tiene que ser único dentro de cada cliente, no en toda la tabla. Dos clientes distintos pueden tener perfectamente la factura 2026-001. Lo mismo vale para casi cualquier índice: como prácticamente todas las consultas van a filtrar por tenant_id, conviene ponerlo como primera columna de los índices compuestos.

Identificar al tenant en cada petición

Lo siguiente es saber, en cada petición, qué cliente está usando la aplicación. Lo más habitual es hacerlo por subdominio (solucionex.tuapp.com) o a partir del usuario autenticado. Para el caso del subdominio podemos usar un middleware como este:

<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\Response;

class IdentifyTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $subdomain = explode('.', $request->getHost())[0];

        $tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();

        Context::addHidden('tenant_id', $tenant->id);

        return $next($request);
    }
}

Para guardar el tenant actual usamos Context, que está disponible desde Laravel 11. Valdría también un singleton en el contenedor, pero Context tiene una ventaja que nos va a ahorrar trabajo más adelante: se envía automáticamente con los jobs que se encolan. Usamos addHidden para que el ID no aparezca en todos los logs.

El middleware solo se aplica a las rutas de los clientes. Si lo pusiéramos en todas las rutas web, la web pública o el registro, que no tienen subdominio de cliente, devolverían un 404:

// routes/web.php
Route::middleware(IdentifyTenant::class)->group(function () {
    Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);
    // ...
});

Además, tiene que ejecutarse antes que SubstituteBindings, que es el middleware que resuelve el route model binding. En nuestras pruebas fue lo primero que falló: sin cambiar el orden, Laravel intenta cargar la Invoice de la URL antes de saber de qué cliente es, y cualquier ruta con model binding devuelve el error "No hay ningún tenant activo". El orden se cambia en bootstrap/app.php:

use App\Http\Middleware\IdentifyTenant;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Routing\Middleware\SubstituteBindings;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->prependToPriorityList(
        before: SubstituteBindings::class,
        prepend: IdentifyTenant::class,
    );
})

Filtrar automáticamente con un global scope

Ahora queremos que todas las consultas a los modelos de los clientes se filtren solas. Si dependemos de que cada desarrollador se acuerde de añadir where('tenant_id', ...), tarde o temprano alguien lo olvidará. Para eso creamos un trait con un global scope:

<?php

namespace App\Models\Concerns;

use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Context;
use RuntimeException;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            $tenantId = Context::getHidden('tenant_id')
                ?? throw new RuntimeException('No hay ningún tenant activo.');

            $query->where($query->qualifyColumn('tenant_id'), $tenantId);
        });

        static::creating(function (Model $model) {
            $model->tenant_id = Context::getHidden('tenant_id') ?? $model->tenant_id;
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}
class Invoice extends Model
{
    use BelongsToTenant;
}

A partir de aquí, Invoice::all() solo devuelve las facturas del tenant actual y Invoice::create([...]) rellena el tenant_id sin que tengamos que pasarlo.

Hay un detalle de este código que hemos hecho a propósito: si no hay tenant activo, el scope lanza una excepción en vez de no filtrar. Muchos ejemplos que circulan hacen lo contrario (if ($tenantId) { ... }), y el problema es que, si por un error el tenant no se llega a identificar, la consulta devuelve los datos de todos los clientes. Preferimos mil veces un error 500 a enseñarle a un cliente las facturas de otro.

Con el creating pasa algo parecido. Si hay un tenant activo, el tenant_id se sobrescribe siempre, aunque en los datos venga otro. Nos dimos cuenta al probarlo: en una primera versión usábamos ??=, que respeta el valor recibido, y con un tenant_id asignable de forma masiva bastaba con mandar el de otro cliente en el formulario para que la factura se guardara en su cuenta. Cuando no hay tenant activo, por ejemplo en un seeder, se mantiene el que le pasemos.

Cuando de verdad haga falta consultar todos los tenants, por ejemplo en un panel de administración interno, se quita el scope de forma explícita:

Invoice::withoutGlobalScope('tenant')->sum('total');

De esta forma queda a la vista en el código y en las revisiones es fácil de detectar.

Migraciones y modelos con el tenant ya incluido

Como casi todas las tablas y modelos nuevos van a necesitar la columna y el trait, nos resulta cómodo modificar los stubs que usa Laravel para generarlos. Con php artisan stub:publish se copian a la carpeta stubs del proyecto, y en stubs/migration.create.stub añadimos la columna:

Schema::create('{{ table }}', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->timestamps();
});

Y en stubs/model.stub, el trait:

<?php

namespace {{ namespace }};

use App\Models\Concerns\BelongsToTenant;
{{ factoryImport }}
use Illuminate\Database\Eloquent\Model;

class {{ class }} extends Model
{
    use BelongsToTenant;

    {{ factory }}
}

Con esto, un php artisan make:model Project -m nos deja la migración con el tenant_id y el modelo filtrando por tenant desde el primer momento (también con -f, que añade el HasFactory debajo del trait). La pega es que los modelos y tablas que no pertenecen a ningún cliente, como el propio Tenant, también salen con la columna y el trait, y en esos hay que quitarlos a mano.

La idea de los stubs la sacamos de teamsy, el proyecto que Kevin McKee preparó para la serie de Laracasts sobre multi-tenancy con una sola base de datos. Es de la época de Laravel 8, pero merece la pena echarle un ojo.

Por dónde se escapan los datos

El global scope cubre las consultas de Eloquent, incluidas las relaciones y el route model binding. Pero hay unos cuantos sitios por los que no pasa.

El primero son las consultas con el query builder. DB::table('invoices')->get() no usa el modelo y, por tanto, no aplica el global scope. Si escribimos consultas sin Eloquent, filtrar por tenant es cosa nuestra.

También las reglas de validación unique y exists, que consultan la base de datos directamente. Sin filtrar, un cliente podría encontrarse con que "ese número ya existe" por culpa de una factura de otro cliente, o asociar un registro a un ID que pertenece a otro tenant:

use Illuminate\Support\Facades\Context;
use Illuminate\Validation\Rule;

$tenantId = Context::getHidden('tenant_id');

$request->validate([
    'number' => [
        'required',
        Rule::unique('invoices')->where('tenant_id', $tenantId),
    ],
    'customer_id' => [
        'required',
        Rule::exists('customers', 'id')->where('tenant_id', $tenantId),
    ],
]);

Con la caché pasa algo parecido. Si guardamos algo con una clave como "dashboard-stats", el primer cliente que la genere se la acabará sirviendo a todos los demás. La clave tiene que incluir siempre el tenant, por ejemplo "tenant-{$tenantId}-dashboard-stats". Y con los ficheros, lo mismo: cada cliente con su carpeta (tenants/{id}/...) y comprobando el tenant antes de servirlos.

Los comandos de Artisan y las tareas programadas son otro punto a vigilar. En una petición web el middleware identifica al tenant, pero en un comando no hay petición, así que si el comando trabaja con datos de los clientes tiene que recorrerlos y activar cada uno:

Tenant::query()->lazy()->each(function (Tenant $tenant) {
    Context::addHidden('tenant_id', $tenant->id);

    // Trabajo con los datos de este tenant...
});

Con los jobs en cola, en cambio, no hay que hacer nada, y es aquí donde se nota haber elegido Context. Cuando despachamos un job durante una petición, Laravel guarda el contexto junto al job y lo restaura cuando el worker lo procesa, así que el global scope funciona igual dentro del job. Esto incluye los modelos que recibe el job, porque Laravel restaura el contexto antes de volver a cargarlos de la base de datos. Además, el contexto se limpia antes de cada job, así que el tenant de un job no pasa al siguiente. Lo comprobamos con un mismo worker procesando seguidos un job de un cliente, otro de un segundo cliente y uno sin tenant. Los dos primeros vieron solo sus facturas y el tercero falló con "No hay ningún tenant activo".

Un test que no debería faltar

Una fuga de datos entre clientes es probablemente lo peor que le puede pasar a un SaaS, y por suerte es fácil de cubrir con un test:

it('no muestra facturas de otros tenants', function () {
    $solucionex = Tenant::factory()->create();
    $consultoria = Tenant::factory()->create();

    Context::addHidden('tenant_id', $consultoria->id);
    Invoice::factory()->create(['number' => 'CONSULTORIA-001']);

    Context::addHidden('tenant_id', $solucionex->id);
    Invoice::factory()->create(['number' => 'SOLUCIONEX-001']);

    expect(Invoice::pluck('number')->all())->toBe(['SOLUCIONEX-001']);
});

Nosotros añadiríamos uno parecido para cada modelo con datos de cliente, y otro a nivel HTTP que pida /invoices/{id} con el ID de una factura de otro tenant y espere un 404.

 

Opción 2: una base de datos por tenant

En este caso cada cliente tiene su propia base de datos. Además hay una base de datos central con la tabla tenants, donde se guarda qué base de datos corresponde a cada cliente, y los datos que no pertenecen a ninguno.

La conexión del tenant

En config/database.php definimos una conexión tenant sin base de datos, que nos servirá de plantilla:

'tenant' => [
    'driver' => 'mysql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '3306'),
    'database' => null,
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
],

El modelo Tenant vive en la conexión por defecto (la central) y es el que se encarga de apuntar la conexión tenant a su base de datos:

class Tenant extends Model
{
    public function connect(): void
    {
        config(['database.connections.tenant.database' => $this->database]);

        DB::purge('tenant');
    }

    public static function disconnect(): void
    {
        config(['database.connections.tenant.database' => null]);

        DB::purge('tenant');
    }
}

DB::purge() cierra la conexión actual para que la siguiente consulta se vuelva a conectar, ya con la base de datos nueva. Sin él, Laravel seguiría usando la conexión que tenía abierta.

Los modelos de los clientes tienen que usar esa conexión. En Laravel 13 lo podemos indicar con un atributo:

use Illuminate\Database\Eloquent\Attributes\Connection;

#[Connection('tenant')]
class Invoice extends Model
{
    //
}

El middleware es el mismo de la opción 1, añadiendo una llamada a $tenant->connect(). Aquí ya no hacen falta ni el global scope ni la columna tenant_id, porque cada base de datos solo contiene los datos de un cliente.

Migraciones

Las migraciones de los tenants van en una carpeta aparte, por ejemplo database/migrations/tenant. El php artisan migrate de siempre no entra en subcarpetas, así que seguirá migrando solo la base de datos central. Para los tenants creamos un comando que los recorra:

// routes/console.php
use App\Models\Tenant;
use Illuminate\Support\Facades\Artisan;

Artisan::command('tenants:migrate', function () {
    Tenant::query()->lazy()->each(function (Tenant $tenant) {
        $this->info("Migrando {$tenant->name}...");

        $tenant->connect();

        $this->call('migrate', [
            '--database' => 'tenant',
            '--path' => 'database/migrations/tenant',
            '--force' => true,
        ]);
    });
});

Este es uno de los costes reales de esta arquitectura. Con 300 clientes, cada despliegue con migraciones son 300 migraciones, y si falla en el cliente 147 nos quedamos con 146 bases de datos con el esquema nuevo y 154 con el antiguo. El código tiene que soportar esa situación mientras dura el despliegue, y necesitamos alguna forma de saber en qué tenants ha fallado para volver a lanzarlo.

Jobs en cola

Con una base de datos por cliente, Context ya no es suficiente. Restaura el ID del tenant, pero no la conexión a su base de datos. Para eso usamos el evento hydrated, que se lanza cada vez que el worker empieza un job:

// AppServiceProvider
use App\Models\Tenant;
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Context;

public function boot(): void
{
    Context::hydrated(function (Repository $context) {
        $tenant = $context->hasHidden('tenant_id')
            ? Tenant::find($context->getHidden('tenant_id'))
            : null;

        $tenant ? $tenant->connect() : Tenant::disconnect();
    });
}

Ojo con la llamada a disconnect(), que es fácil de olvidar. El worker procesa un job detrás de otro en el mismo proceso, así que si un job llega sin tenant y no desconectamos, seguirá conectado a la base de datos del cliente del job anterior. Quisimos comprobarlo y quitamos esa rama: un job lanzado sin tenant justo después de uno de Consultoría devolvió las facturas de Consultoría. Con la rama puesta, ese mismo job falla con No database selected, que es lo que queremos.

Crear un tenant nuevo

Dar de alta un cliente supone crear su base de datos, ejecutar sus migraciones y normalmente algún seeder. Es mejor hacerlo en un job que en la propia petición del registro, y el nombre de la base de datos debemos generarlo nosotros (por ejemplo tenant_ más el ID), nunca a partir de algo que haya escrito el usuario.

También hay que revisar los permisos, porque el usuario de base de datos de la aplicación normalmente no puede crear bases de datos. En DDEV, sin ir más lejos, el usuario db recibe un Access denied. Podemos darle ese permiso limitado a un patrón de nombres, por ejemplo en MySQL:

GRANT ALL ON `tenant\_%`.* TO 'app'@'%';

o hacer el alta con otro usuario con más privilegios que solo use ese proceso.

 

Opción 3: un esquema por tenant (PostgreSQL)

Esta opción queda a medio camino. Todos los clientes están en la misma base de datos de PostgreSQL, pero cada uno en su propio esquema, con sus propias tablas. Se implementa igual que la opción 2, solo que en lugar de cambiar database cambiamos search_path (con una conexión tenant de tipo pgsql):

config(['database.connections.tenant.search_path' => "tenant_{$this->id}"]);

DB::purge('tenant');

Laravel no crea el esquema por nosotros, así que el alta del cliente tiene que crearlo antes de lanzar sus migraciones:

DB::connection('tenant')->statement("CREATE SCHEMA IF NOT EXISTS tenant_{$tenant->id}");

A partir de ahí nos sirve el mismo comando tenants:migrate de la opción 2, y cada esquema acaba con sus propias tablas y su propia tabla migrations.

Con esto tenemos casi el mismo aislamiento que en la opción 2, pero con una sola base de datos que mantener. A cambio arrastramos las mismas complicaciones con las migraciones y dependemos de PostgreSQL. Además, con muchos miles de esquemas algunas operaciones de PostgreSQL empiezan a ir lentas, así que no la elegiríamos si se espera un número muy alto de clientes.

 

¿Paquete o a mano?

Hay dos paquetes bastante asentados que resuelven todo lo anterior. stancl/tenancy (versión 3.10, compatible con Laravel 13) es el más completo: crea las bases de datos automáticamente, separa por tenant la caché, los ficheros, las colas y Redis, y funciona tanto con base de datos compartida como con una por cliente. Es muy potente, aunque condiciona bastante la estructura de la aplicación. spatie/laravel-multitenancy (versión 4.2, también compatible con Laravel 13) es mucho más ligero: identifica al tenant, ejecuta unas "tareas" al cambiar de uno a otro (cambiar la base de datos, el prefijo de caché...) y deja el resto en nuestras manos.

Nuestro criterio es sencillo. Con base de datos compartida lo hacemos a mano, porque el código que hemos visto es prácticamente todo lo que hace falta y lo entendemos de principio a fin. Con una base de datos por cliente preferimos un paquete, porque la gestión de conexiones, migraciones, colas, caché y ficheros tiene muchos casos límite que ya están resueltos. Spatie si queremos más control, stancl si queremos que nos lo dé casi todo hecho.

 

Conclusión

No hay una arquitectura mejor que las demás, pero sí una que da menos trabajo, y para la mayoría de SaaS es la base de datos compartida con tenant_id. Eso sí, hay que asumir que con ella el aislamiento depende de nuestro código, y eso significa tener un global scope que falle si no hay tenant, cuidado con las consultas que no pasan por Eloquent y tests que intenten ver los datos de otro cliente. Si más adelante aparece un motivo real para separar bases de datos, ese será el momento de dar el salto, probablemente con la ayuda de un paquete.

 

Fuentes

Documentación oficial de Laravel: Context
Documentación oficial de Laravel: Eloquent, global scopes
Documentación oficial de Laravel: Middleware, prioridad
Tenancy for Laravel (stancl/tenancy)
spatie/laravel-multitenancy
Teamsy, de Kevin McKee

laravel