Multitenancy en REST API con Laravel: enfoques arquitectónicos, aislamiento de datos y rendimiento
La multitenancy (multi-tenancy) es un patrón arquitectónico en el que una sola instancia de la aplicación sirve a múltiples clientes independientes (tenants), garantizando al mismo tiempo el aislamiento completo de sus datos. Para productos SaaS en Laravel, este es uno de los principales desafíos arquitectónicos: un sistema mal diseñado puede provocar filtraciones de datos, degradación del rendimiento y pesadillas en el escalado.
Por qué la multitenancy es compleja
La complejidad se manifiesta en varios planos simultáneamente. En primer lugar, es necesario garantizar un estricto aislamiento de datos: ningún tenant debe acceder a los datos de otro bajo ninguna circunstancia. En segundo lugar, hay que escalar el sistema horizontalmente sin un aumento lineal de los costes de infraestructura. En tercer lugar, mantener un alto rendimiento del REST API a medida que crece el número de tenants. Por último, simplificar el despliegue, el onboarding de nuevos clientes y la gestión de los esquemas de datos.
Tres enfoques arquitectónicos
1. Una base de datos con columna tenant_id
La opción más sencilla: todos los tenants almacenan datos en tablas compartidas, y cada registro contiene un tenant_id. El enfoque es fácil de implementar, pero requiere máxima disciplina en el código: un único filtro WHERE omitido puede provocar una filtración.
- Ventajas: overhead mínimo, despliegue sencillo, esquema de migraciones unificado.
- Desventajas: riesgo de filtración ante errores del desarrollador, dificultad para hacer sharding por tenant, índices compartidos.
2. Esquemas de PostgreSQL por tenant (Schema-per-tenant)
PostgreSQL soporta esquemas (schemas) — espacios de nombres lógicos dentro de una misma base de datos. Cada tenant obtiene su propio esquema (tenant_alice, tenant_bob), pero comparten el mismo servidor de BD. Laravel puede cambiar dinámicamente el search_path.
- Ventajas: buen aislamiento, el servidor compartido reduce costes, posibilidad de migraciones específicas por tenant.
- Desventajas: gestión de migraciones más compleja, limitación de PostgreSQL en el número de esquemas con miles de tenants.
3. Base de datos separada por tenant (Database-per-tenant)
Cada tenant obtiene su propia base de datos. Máximo aislamiento, pero también el mayor overhead. Adecuado para el segmento enterprise, donde el cliente exige garantías de aislamiento.
- Ventajas: aislamiento total, posibilidad de migrar la BD del tenant, copias de seguridad independientes.
- Desventajas: costes elevados con muchos tenants, despliegue complejo, miles de conexiones a la BD.
Implementación en Laravel
Middleware para identificar el tenant
El primer paso es identificar al tenant en cada solicitud y guardar el contexto. Creamos el middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
use App\Services\TenantManager;
class ResolveTenant
{
public function __construct(private TenantManager $manager) {}
public function handle(Request $request, Closure $next)
{
// Identificación mediante subdominio
$host = $request->getHost();
$subdomain = explode('.', $host)[0];
$tenant = Tenant::where('slug', $subdomain)->firstOrFail();
$this->manager->setTenant($tenant);
// Para el enfoque con BD separadas — conectamos la correspondiente
config(['database.connections.tenant.database' => $tenant->database_name]);
DB::purge('tenant');
DB::reconnect('tenant');
return $next($request);
}
}
TenantManager — servicio de gestión de contexto
<?php
namespace App\Services;
use App\Models\Tenant;
class TenantManager
{
private ?Tenant $current = null;
public function setTenant(Tenant $tenant): void
{
$this->current = $tenant;
}
public function getTenant(): ?Tenant
{
return $this->current;
}
public function getId(): ?int
{
return $this->current?->id;
}
}
Scope global para el aislamiento de datos
Con el enfoque de tenant_id, el scope global es una herramienta de protección obligatoria. Añade automáticamente la condición de filtrado a cada consulta Eloquent:
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use App\Services\TenantManager;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(TenantManager::class)->getId();
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
}
}
Incorporamos el scope en un trait que añadimos a todos los modelos tenant-aware:
<?php
namespace App\Traits;
use App\Scopes\TenantScope;
use App\Services\TenantManager;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
$model->tenant_id = app(TenantManager::class)->getId();
});
}
}
Cambio de esquemas en PostgreSQL
Para el enfoque schema-per-tenant, cambiamos dinámicamente el search_path tras establecer la conexión:
// En el middleware tras identificar el tenant
$schemaName = 'tenant_' . $tenant->slug;
DB::statement("SET search_path TO {$schemaName}, public");
Las migraciones para un nuevo tenant se ejecutan de forma programática:
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
Aislamiento de datos: prevención de filtraciones
El scope global protege las lecturas, pero también es necesario proteger las actualizaciones y eliminaciones. Añada políticas (Policies) y asegúrese de que el scope global esté activo al usar findOrFail(). Nunca utilice withoutGlobalScope(TenantScope::class) en código de producción sin una auditoría explícita.
Protección adicional — verificación a nivel de controlador:
public function update(Request $request, int $id): JsonResponse
{
// El scope ya está aplicado, 404 si el registro pertenece a otro tenant
$resource = Resource::findOrFail($id);
$resource->update($request->validated());
return response()->json($resource);
}
Caché con Redis en entornos multitenant
Al usar Redis, es fundamental aislar las claves de caché por tenant. Use prefijos con el tenant_id o el slug del tenant:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class TenantCache
{
public function __construct(private TenantManager $manager) {}
public function key(string $key): string
{
return 'tenant:' . $this->manager->getId() . ':' . $key;
}
public function remember(string $key, int $ttl, callable $callback): mixed
{
return Cache::remember($this->key($key), $ttl, $callback);
}
public function forget(string $key): void
{
Cache::forget($this->key($key));
}
public function flush(): void
{
// Invalidación de todas las claves del tenant mediante patrón
$pattern = 'tenant:' . $this->manager->getId() . ':*';
$keys = Redis::keys($pattern);
if (!empty($keys)) {
Redis::del($keys);
}
}
}
Una alternativa es usar bases de datos Redis separadas (database index) por tenant cuando su número es reducido. En SaaS masivo, es preferible utilizar prefijos e invalidación explícita.
Diseño de REST API para multitenancy
La identificación del tenant en un REST API puede realizarse de tres maneras:
- Subdominio:
alice.myapp.com/api/v1/users— intuitivo y conveniente para clientes en navegador. - Cabecera HTTP:
X-Tenant-ID: alice— adecuado para APIs B2B donde el cliente es una aplicación servidor. - Claim en JWT: campo
tenant_iddentro del token — funciona bien con OAuth2/Passport/Sanctum.
Ejemplo de extracción del tenant desde JWT con Laravel Sanctum:
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user || !$user->tenant_id) {
return response()->json(['error' => 'Tenant not found'], 403);
}
$tenant = Tenant::findOrFail($user->tenant_id);
app(TenantManager::class)->setTenant($tenant);
return $next($request);
}
Pruebas de escenarios multitenant
Las pruebas son una parte crítica. Es necesario verificar no solo el funcionamiento correcto dentro de un tenant, sino también la ausencia de filtraciones entre tenants:
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Order;
use App\Services\TenantManager;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
public function test_tenant_cannot_access_other_tenant_data(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$orderA = Order::factory()->create(['tenant_id' => $tenantA->id]);
// Establecemos el contexto del tenant B
app(TenantManager::class)->setTenant($tenantB);
// Una solicitud del tenant B debe devolver 404
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
$this->actingAs($userB)
->getJson("/api/v1/orders/{$orderA->id}")
->assertStatus(404);
}
}
Pruebe también: la creación de registros (verifique que el tenant_id se asigna automáticamente), la invalidación de caché al cambiar el contexto y el aislamiento de colas (los jobs deben llevar el tenant_id y restaurar el contexto al ejecutarse).
Despliegue en Docker: configuración por tenant
Al desplegar en Docker con el enfoque database-per-tenant, use variables de entorno y configuración dinámica. El docker-compose.yml base contiene un único servicio de aplicación, y las configuraciones de los tenants se almacenan en una BD central o en un almacén de configuración (Vault, AWS Secrets Manager).
# docker-compose.yml (fragmento)
services:
app:
build: .
environment:
- APP_ENV=production
- DB_HOST=postgres
- DB_DATABASE=saas_central # BD central para el registro de tenants
- REDIS_HOST=redis
depends_on:
- postgres
- redis
postgres:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
Al incorporar un nuevo tenant, ejecute el comando Artisan desde CI/CD o mediante un endpoint de administrador:
php artisan tenant:create --name="Alice Corp" --slug=alice --db=tenant_alice
Rendimiento y escalado
Algunas recomendaciones prácticas sobre el rendimiento en Laravel SaaS 2026:
- Índices: con el enfoque de base de datos compartida, cree siempre índices compuestos
(tenant_id, id)y(tenant_id, created_at)en todas las tablas grandes. - Connection pooling: use PgBouncer delante de PostgreSQL, especialmente con database-per-tenant, para reducir el overhead de establecimiento de conexiones.
- Colas: en los jobs, guarde el
tenant_idy restaure el contexto en el métodohandle(). Use colas separadas por tenant bajo alta carga. - Read replicas: dirija las consultas de lectura a réplicas mediante las conexiones de lectura/escritura de la base de datos en Laravel.
- Caché de configuración del tenant: almacene en caché la configuración del tenant en Redis con un TTL de 60-300 segundos para evitar consultas a la BD central en cada solicitud HTTP.
// Cacheamos la configuración del tenant
public function resolveTenant(string $slug): Tenant
{
return Cache::remember(
"tenant_config:{$slug}",
300,
fn() => Tenant::where('slug', $slug)->firstOrFail()
);
}
Conclusión y recomendaciones para elegir la estrategia
La elección de la arquitectura de multitenancy es siempre un equilibrio entre aislamiento, coste y complejidad de desarrollo:
- Base de datos compartida (tenant_id): elíjala para startups y productos con cientos o miles de tenants del segmento SMB. Use obligatoriamente scopes globales y cubra el aislamiento con casos de prueba.
- Schema-per-tenant (PostgreSQL): óptimo para productos con decenas o cientos de tenants que necesitan mejor aislamiento sin un presupuesto de infraestructura enterprise.
- Database-per-tenant: SaaS enterprise con requisitos de compliance (GDPR, HIPAA), disposición a pagar por la infraestructura y un número reducido de grandes clientes.
Independientemente del enfoque elegido: pruebe el aislamiento de datos como prioridad número uno, use Redis con namespace de tenant para la caché, y prevea la posibilidad de migrar entre estrategias — las necesidades de un producto SaaS evolucionan junto con su crecimiento.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →