PHPackages                             esolutions/tenancy - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. esolutions/tenancy

ActiveLibrary

esolutions/tenancy
==================

Single-database multi-tenancy for Laravel: fail-closed row-level isolation, domain resolution and session scoping

v0.4.0(yesterday)04↓50%proprietaryPHPPHP ^8.2

Since Aug 16Pushed yesterdayCompare

[ Source](https://github.com/eriquegasparcarlos/esolutions-tenancy)[ Packagist](https://packagist.org/packages/esolutions/tenancy)[ RSS](/packages/esolutions-tenancy/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (3)Versions (5)Used By (0)

esolutions/tenancy
==================

[](#esolutionstenancy)

Multi-tenancy **single-database** para Laravel: aislamiento row-level *fail-closed*, resolución por dominio y scoping de sesión.

Todos los tenants comparten las mismas tablas y el dato se separa por `tenant_id`. **No hay una base por tenant, no hay migraciones ×N y dar de alta una empresa es insertar una fila.**

Por qué existe
--------------

[](#por-qué-existe)

Los paquetes de tenancy más difundidos están diseñados para **multi-database**. Su modo single-DB suele traer un scope **fail-open**:

```
if (! tenancy()->initialized) {
    return;   // devuelve filas de TODOS los tenants
}
```

En multi-DB eso significa "estoy en el contexto central" y es correcto. En **single-DB es el escenario de fuga**: un job en cola, un comando o un listener sin contexto lee todas las empresas, en silencio y sin error.

Acá el scope es **fail-closed**: sin tenant en contexto, la query **lanza excepción**.

Además, el motivo por el que se elige single-DB: con una base por tenant, el número de tablas del motor crece con el número de clientes (`empresas × tablas`). MySQL dimensiona `table_definition_cache` / `open_files_limit` por **tablas abiertas**, no por filas — al superarlo aparecen errores `1615 Prepared statement needs to be re-prepared`. Con single-DB el número de tablas es **fijo**.

Requisitos
----------

[](#requisitos)

- PHP &gt;= 8.2
- Laravel 11, 12 o 13

Instalación
-----------

[](#instalación)

```
composer require esolutions/tenancy
php artisan vendor:publish --tag=tenancy-migrations
php artisan vendor:publish --tag=tenancy-config   # opcional
php artisan migrate
```

Instalar desde el repositorio (sin Packagist)```
{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/eriquegasparcarlos/esolutions-tenancy" }
    ]
}
```

Configuración
-------------

[](#configuración)

El paquete aporta el **mecanismo**; la aplicación aporta la **política**. La app declara sus modelos concretos:

```
// config/tenancy.php
'tenant_model'    => \App\Models\System\Tenant::class,
'domain_model'    => \App\Models\System\Domain::class,
'central_domains' => ['miapp.com', 'admin.miapp.com'],
```

```
// app/Models/System/Tenant.php
class Tenant extends \Esolutions\Tenancy\Models\Tenant
{
    // política propia de la app
    public function modules(): HasMany { return $this->hasMany(TenantModule::class); }
    public function hasModule(string $v): bool { return $this->modules()->where('value', $v)->exists(); }
}
```

Si preferís no extender los modelos base, alcanza con implementar `Esolutions\Tenancy\Contracts\Tenant` (`getTenantKey()` e `isTenantActive()`).

### Opciones de `config/tenancy.php`

[](#opciones-de-configtenancyphp)

ClaveDefaultPara qué`tenant_model` / `domain_model``App\Models\System\*`modelos concretos de la app`column``tenant_id`columna discriminadora en las tablas de negocio`slug_column``slug`columna del tenant usada al resolver por subdominio`resolution_cache_ttl``300`segundos de cache de `host → tenant` (`0` = sin cache)`base_domain``localhost`dominio base de los subdominios (`acme.`)`central_domains``localhost,127.0.0.1`dominios del panel del proveedor`middleware_group``tenant`nombre del grupo de middleware que registra el paqueteUso
---

[](#uso)

### 1. Modelos de negocio

[](#1-modelos-de-negocio)

```
use Esolutions\Tenancy\Concerns\BelongsToTenant;

class Invoice extends Model
{
    use BelongsToTenant;   // filtra al leer, setea tenant_id al crear
}
```

Los modelos **globales** (catálogos) y el **control-plane** (`Tenant`, `Domain`) NO lo usan.

### 2. Rutas

[](#2-rutas)

```
Route::middleware(['web', 'tenant'])->group(base_path('routes/tenant.php'));
```

El grupo `tenant` se registra solo y aplica, **en este orden**:

MiddlewareQué hace`PreventAccessFromCentralDomain`las rutas de empresa no responden en el dominio del proveedor`ResolveTenant`host → tenant → contexto (busca dominio exacto, luego subdominio)`ScopeSessionToTenant`ata la sesión al tenant> **`ScopeSessionToTenant` no es opcional.** Las cookies se comparten entre subdominios de un mismo dominio padre: sin esto, una sesión abierta en `acme.miapp.com` seguiría siendo válida en `globex.miapp.com` y el usuario quedaría autenticado en otra empresa.

El paquete además declara la **prioridad** del trío en el kernel HTTP: corre después de `StartSession` y **antes de `auth` y de `SubstituteBindings`**. Esto garantiza que el contexto de tenant ya está fijado cuando el route-model-binding y el guard de autenticación tocan la base — sin esa prioridad, ambos usarían el contexto del request anterior (tests, Octane) y podrían resolver filas de otro tenant. No hay que configurar nada: basta `Route::middleware(['web', 'tenant'])`.

### 3. Cron / tareas programadas

[](#3-cron--tareas-programadas)

En single-DB una tarea corre **una vez** para todas las empresas, así que hay que iterar:

```
php artisan tenants:run inventory:recalc
php artisan tenants:run "invoices:remind --days=3"
php artisan tenants:run report:build --tenants=1,5,9
```

```
// routes/console.php
Schedule::command('tenants:run invoices:remind')->dailyAt('08:00');

// o con un closure
Schedule::call(fn () => Tenancy::each(fn ($t) => Invoice::sendReminders()))->hourly();
```

Un tenant que falla **no aborta** la corrida de los demás (salvo `--stop-on-error`).

### 4. Fuera de HTTP (jobs, comandos, seeders)

[](#4-fuera-de-http-jobs-comandos-seeders)

```
use Esolutions\Tenancy\TenantContext;

app(TenantContext::class)->runAs($tenant, function () {
    Invoice::create([...]);   // tenant_id automático
});

// Cross-tenant deliberado (panel central, migrador, mantenimiento):
app(TenantContext::class)->runAsSystem(fn () => Invoice::count());
Invoice::allTenants()->get();
```

Sin contexto y sin modo system, cualquier query **lanza excepción** en vez de devolver datos de todas las empresas.

> **Gotcha resuelto:** `MiJob::dispatch()` devuelve un `PendingDispatch` que encola recién al destruirse. Con `fn () => MiJob::dispatch()` (arrow function que lo *retorna*), el objeto moría fuera del contexto y el job se encolaba **sin tenant**. `runAs()` fuerza su encolado dentro del contexto correcto, así que ambas formas funcionan.

### 5. Testing

[](#5-testing)

`Storage::fake()` **inyecta un disco directamente en el manager y saltea el aislamiento por tenant**, así que no sirve para probarlo. Para tests de storage, apuntá el disco real a un directorio temporal:

```
config()->set('filesystems.disks.local.root', storage_path('framework/testing/disks/'.uniqid()));
```

Superficies compartidas de Laravel — auditoría completa
-------------------------------------------------------

[](#superficies-compartidas-de-laravel--auditoría-completa)

En single-DB no alcanza con filtrar la base de datos. Laravel tiene varias superficies **globales**donde una empresa puede ver (o pisar) datos de otra **sin pasar por SQL**, así que el scope no las puede detener. Esto es lo que cubre el paquete y lo que queda a cargo de la app:

SuperficieRiesgo si no se aíslaEstadoConsultas Eloquentleer/editar datos de otra empresa✅ `TenantScope` (fail-closed)Route model bindingIDOR por URL✅ vía scope → 404**Cache** (todos los drivers)leer valores cacheados de otra empresa✅ claves prefijadas**Cache tags** (redis/memcached)idem✅ nombres de tag prefijados`Cache::flush()`borrar el cache de TODAS las empresas✅ bloqueado dentro de un tenant**Rate limiter**contador de throttle compartido✅ automático (usa cache)**Sesiones**quedar autenticado en otra empresa✅ `ScopeSessionToTenant`**Colas** (redis/database/sqs)job procesado con el tenant equivocado✅ payload + restauraciónListeners encolados, Notifications, Mail en colaidem✅ son jobs: mismo mecanismo**Eventos síncronos** y observers—✅ corren en el contexto actual**Scheduler / cron**la tarea corre sin tenant✅ `tenants:run` + `Tenancy::each()`**Storage / archivos**pisar y leer archivos ajenos✅ discos con prefijo por tenant**Broadcasting**evento enviado al canal de otra empresa✅ `Tenancy::channel()` *(manual)***Validación** `unique` / `exists`falso duplicado + revela datos ajenos✅ `Tenancy::unique()` *(manual)*Queries crudas `DB::table()`fuga total⚠️ **manual**: `where('tenant_id', …)``Redis::` usado directoclaves globales (no pasa por cache)⚠️ **manual**: prefijar la claveOctane / workers de larga vidael contexto persiste entre requests⚠️ ver abajo### Storage — convención de carpetas

[](#storage--convención-de-carpetas)

Los discos listados en `tenancy.filesystem.scoped_disks` se montan bajo un prefijo por tenant, así que el código de la app **no cambia**:

```
Storage::disk('public')->put('logo.png', $file);
// acme  → storage/app/public/tenants/acme/logo.png
// globex→ storage/app/public/tenants/globex/logo.png
```

El patrón se configura con `path_prefix` y admite `{slug}` (subdominio — carpetas legibles, el default) o `{id}` (clave primaria, inmutable). Con `{slug}`, renombrar el subdominio de una empresa obliga a mover su carpeta.

### Octane y procesos de larga vida

[](#octane-y-procesos-de-larga-vida)

`TenantContext` es un singleton: en Octane/Swoole persiste entre requests. El middleware `ResolveTenant` lo sobreescribe en cada request, pero si una ruta **no** pasa por el grupo `tenant`podría heredar el contexto de la request anterior. Si usás Octane, limpiá el contexto entre requests:

```
Octane::tick('flush-tenant', fn () => app(TenantContext::class)->forget());
// o en un listener de RequestTerminated
```

En los workers de cola esto ya está resuelto: el bootstrapper limpia el contexto entre jobs.

Suite de tests
--------------

[](#suite-de-tests)

El paquete es la **frontera de aislamiento** de los sistemas que lo usan, así que trae su propia suite (orchestra/testbench) para que un refactor futuro no rompa el aislamiento en silencio:

```
composer install
mysql -e "CREATE DATABASE esolutions_tenancy_test"
vendor/bin/phpunit          # 37 tests
```

ArchivoQué garantiza`IsolationTest`A no ve/edita/borra lo de B · IDOR → null · `tenant_id` automático · fail-closed sin contexto · `runAsSystem`/`allTenants` · UNIQUE compuesto · `runAs` anidado restaura`CacheIsolationTest`claves por tenant · `forget` no cruza · `flush()` bloqueado en tenant y permitido fuera · `remember` aislado`QueueTenancyTest`el job conserva su tenant · no hereda el del worker · **el worker no contamina entre jobs** · dispatch con arrow function · job de tenant borrado no corre`HttpTenancyTest`resolución por dominio y subdominio · 404 si no existe · 403 si inactivo · dominio central bloqueado · **sesión cruzada descartada**`SurfacesTest`storage aislado y nombrado por subdominio · `Tenancy::unique()` por tenant · canales de broadcasting · `Tenancy::each()` (contexto, tolerancia a fallos, ignora inactivos)Los tests corren contra **MySQL** (no sqlite) porque el aislamiento de storage y colas necesita el comportamiento real del motor.

Reglas de esquema (responsabilidad de la app)
---------------------------------------------

[](#reglas-de-esquema-responsabilidad-de-la-app)

1. Toda tabla de negocio: `tenant_id` con FK a `tenants` + `cascadeOnDelete`.
2. **Todo UNIQUE es compuesto con `tenant_id`.** Ej.: `unique(['tenant_id','series','number'])`. Un UNIQUE global impediría que dos empresas usen la misma serie.
3. Índices de acceso encabezados por `tenant_id`.
4. **Queries crudas** (`DB::table`, `DB::raw`) NO pasan por el scope → agregar `where('tenant_id', ...)` a mano. Es la fuga más probable; auditar con grep.
5. La tabla `tenants` debe vivir en la **misma base** que las de negocio: MySQL no soporta FK entre esquemas, así que un control-plane en otra base impediría la FK `tenant_id → tenants.id`.
6. Tests de aislamiento por módulo (tenant A no ve a B).

Regla de oro
------------

[](#regla-de-oro)

> El `tenant_id` **siempre** sale del dominio o del login, **nunca** de un parámetro del request.

Efecto lateral: protege contra IDOR gratis — si cambian el ID en la URL por uno de otra empresa, `findOrFail` no lo encuentra → 404, no fuga.

Qué NO hace este paquete
------------------------

[](#qué-no-hace-este-paquete)

- No crea bases de datos ni corre migraciones por tenant (no hace falta: es single-DB).
- No gestiona planes, facturación del SaaS ni módulos contratados — eso es política de la app.
- No provee panel de administración.

Licencia
--------

[](#licencia)

Proprietary — © Carlos Erique Gaspar.

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance100

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

1d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/57302658?v=4)[eriquegasparcarlos](/maintainers/eriquegasparcarlos)[@eriquegasparcarlos](https://github.com/eriquegasparcarlos)

---

Top Contributors

[![eriquegasparcarlos](https://avatars.githubusercontent.com/u/57302658?v=4)](https://github.com/eriquegasparcarlos "eriquegasparcarlos (4 commits)")

### Embed Badge

![Health badge](/badges/esolutions-tenancy/health.svg)

```
[![Health](https://phpackages.com/badges/esolutions-tenancy/health.svg)](https://phpackages.com/packages/esolutions-tenancy)
```

###  Alternatives

[leantime/leantime

Open source project management system for non-project managers. Simple like Trello, powerful like Jira. Built with neurodiversity in mind.

11.3k4.0k](/packages/leantime-leantime)[statamic-rad-pack/runway

Eloquently manage your database models in Statamic.

137236.2k8](/packages/statamic-rad-pack-runway)[duncanmcclean/statamic-cargo

Comprehensive e-commerce addon for Statamic. Build bespoke e-commerce sites without the complexity.

3622.8k](/packages/duncanmcclean-statamic-cargo)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[ecotone/laravel

Ecotone for Laravel — CQRS, Event Sourcing, Sagas, Durable Workflows, and Outbox on top of Laravel Queue, via PHP attributes.

21327.3k4](/packages/ecotone-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
