Multi-tenancy in Laravel: strategie di isolamento dati per SaaS PHP
Nel 2023 ho costruito il mio primo SaaS multi-tenant in Laravel per un cliente del settore servizi professionali - una piattaforma di gestione documentale per studi legali, con la necessità di garantire che nessun documento di uno studio fosse mai visibile a un altro. Ho scelto l'approccio più semplice: un singolo database MySQL con una colonna tenant_id su ogni tabella e un global scope Eloquent per filtrare automaticamente i dati. Funzionava, era veloce da implementare, e per i primi 15 tenant andava bene. Al ventesimo tenant, un bug nel global scope di una query di export CSV ha fatto sì che un amministratore di uno studio vedesse 14 documenti di un altro studio nel suo export. Nessun dato finanziario, nessuna informazione riservata di clienti - ma la fiducia era compromessa, e il cliente ha dovuto notificare gli studi coinvolti.
Nel 2024 ho costruito il secondo SaaS per un'azienda del settore formazione - una piattaforma di e-learning con corsi, utenti e certificazioni separati per azienda. Questa volta ho scelto database separati per tenant, uno per ogni azienda cliente. L'isolamento era perfetto, ma il costo operativo è esploso: ogni nuovo tenant richiedeva la creazione di un database, l'esecuzione di tutte le migration, la configurazione del backup separato, e il monitoring individuale. Con 60 tenant attivi, gestivo 60 database con 60 set di migration - e un aggiornamento di schema che normalmente richiede 5 minuti diventava un'operazione di 2 ore con rischio di inconsistenza tra i database.
Nel 2025 ho costruito il terzo SaaS e, forte dell'esperienza dei primi due, ho adottato un approccio ibrido che bilancia isolamento e manutenibilità. In questo articolo ti racconto i trade-off reali di ciascuna strategia - non la teoria da documentazione, ma i problemi che scopri solo in produzione con dati reali e clienti che pagano.
Quali sono le strategie di multi-tenancy in Laravel e quando conviene ciascuna?
Le strategie principali sono tre, ordinate per livello di isolamento crescente e complessità operativa crescente. La scelta dipende da quattro fattori: il requisito normativo di isolamento dei dati (GDPR, NIS2, contratti enterprise), il numero previsto di tenant, il budget infrastrutturale, e la dimensione del team che dovrà mantenere il sistema.
Strategia 1 - Database condiviso con colonna tenant_id. Tutti i tenant condividono lo stesso database e le stesse tabelle. Ogni tabella ha una colonna tenant_id e un global scope Eloquent che filtra automaticamente i record in base al tenant corrente. È la strategia più semplice da implementare, la più economica da gestire (un solo database, un solo set di migration, un solo backup), e la più pericolosa: l'isolamento dipende interamente dal codice applicativo. Se una query dimentica il filtro tenant_id - un DB::table() raw, una query di reporting, un job in coda che non ha il contesto del tenant - i dati leakano tra tenant. Per una startup con 5-50 tenant, budget limitato e team piccolo, è la scelta pragmatica che permette di andare in produzione velocemente. Per un'applicazione che gestisce dati finanziari, sanitari o legali, il rischio di data leak è inaccettabile.
Strategia 2 - Schema separato per tenant (PostgreSQL). Un singolo server database ospita uno schema separato per ogni tenant. Le tabelle hanno la stessa struttura ma vivono in namespace isolati (tenant_a.users, tenant_b.users). L'isolamento è a livello di database - un bug nel codice applicativo non può attraversare i confini dello schema senza un errore esplicito di accesso. Il costo operativo è medio: un singolo server da gestire, ma le migration devono essere eseguite su ogni schema, e il numero di connessioni al database cresce con il numero di tenant. PostgreSQL supporta questo pattern nativamente e con ottime prestazioni. MySQL lo supporta meno bene - i "database" MySQL sono tecnicamente schema separati, ma la gestione delle connessioni è meno efficiente.
Strategia 3 - Database separato per tenant. Ogni tenant ha il proprio database dedicato, potenzialmente su un server separato. L'isolamento è massimo: anche un attacco SQL injection riuscito su un tenant non può accedere ai dati di un altro tenant. Il costo è il più alto: N database da creare, migrare, backuppare e monitorare. Con Tenancy for Laravel (stancl/tenancy), la creazione automatica del database per ogni nuovo tenant è gestita dal pacchetto, ma le migration e i backup restano una responsabilità operativa che scala linearmente con il numero di tenant.
Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nella progettazione di architetture multi-tenant - tre SaaS in produzione con tre strategie diverse, ciascuna con i propri successi e le proprie cicatrici.
Il global scope: la prima linea di difesa (e il primo punto di fallimento)
Nella strategia con tenant_id, il global scope Eloquent è il meccanismo che garantisce l'isolamento. Ogni model che contiene dati tenant-specific deve avere un trait che aggiunge automaticamente il filtro WHERE tenant_id = ? a ogni query:
// app/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
// Aggiunge automaticamente il filtro tenant a ogni query
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = app('tenant')->id ?? null;
if ($tenantId !== null) {
$builder->where(
$builder->getModel()->getTable() . '.tenant_id',
$tenantId
);
}
});
// Imposta automaticamente il tenant_id alla creazione
static::creating(function (Model $model) {
if (!$model->tenant_id) {
$model->tenant_id = app('tenant')->id;
}
});
}
}Il problema del global scope è che funziona solo per le query Eloquent. Non funziona per le query raw (DB::select()), per le query nel query builder senza model (DB::table('orders')->get()), per le subquery scritte a mano, e per i job in coda che non hanno il contesto HTTP della richiesta (e quindi non sanno quale sia il tenant corrente). Ogni singola di queste eccezioni è un potenziale data leak. Nel primo SaaS, il bug dell'export CSV era esattamente questo: un DB::table('documents')->select(...) che bypassava il global scope del model Document perché usava il query builder diretto invece dell'Eloquent model.
La difesa che implemento oggi è un approccio a due livelli: il global scope Eloquent come prima linea, e un middleware di validazione che verifica dopo l'esecuzione della query che tutti i record restituiti appartengano al tenant corrente. Il secondo livello è costoso in termini di performance (aggiunge un check su ogni risposta), ma lo abilito solo in staging e in un sottoinsieme di richieste in produzione (1% con sampling casuale) per catturare le violazioni prima che diventino incidenti. Quando il check trova un record di un tenant diverso in una risposta, lancia un'eccezione critica che viene loggata e notificata immediatamente - il dato non viene mai restituito all'utente.
Job in coda, cache e sessioni: dove il multi-tenancy si rompe
Il contesto del tenant in un'applicazione web è tipicamente risolto dalla richiesta HTTP: l'URL, un header, un cookie o il dominio identificano il tenant, e un middleware lo carica all'inizio della richiesta. Ma ci sono almeno quattro scenari in cui il contesto HTTP non esiste, e il multi-tenancy si rompe silenziosamente se non li gestisci esplicitamente.
Job in coda: un job dispatchiato da un controller ha il contesto del tenant al momento del dispatch. Ma quando il worker lo processa (secondi o minuti dopo), non c'è nessuna richiesta HTTP - il worker non sa quale tenant ha dispatchato il job. La soluzione è serializzare il tenant_id nel payload del job e ripristinare il contesto del tenant nel metodo handle() del job prima di eseguire qualsiasi query. Con stancl/tenancy, questo è gestito dal trait TenantAware che il pacchetto fornisce; senza il pacchetto, devi implementarlo manualmente, e dimenticarlo è un bug che genera data leak in produzione.
Cache: se usi un singolo server Redis per tutti i tenant, le chiavi di cache devono essere prefissate con il tenant_id. Se la chiave users:list è condivisa tra tenant, il primo tenant che popola la cache vede i suoi utenti, e tutti i tenant successivi vedono gli utenti del primo - fino a quando la cache non scade. La soluzione è un prefisso tenant nelle chiavi (tenant_42:users:list) o un database Redis separato per tenant (Redis supporta fino a 16 database logici, sufficienti per piccoli SaaS).
Scheduler e cronjob: un comando Artisan schedulato che elabora dati (es. generazione report notturni, invio email periodiche) deve iterare esplicitamente su tutti i tenant e impostare il contesto per ciascuno prima di eseguire la logica. Senza questa iterazione, il comando gira nel contesto di default (senza tenant) e o fallisce con un errore di missing tenant, o peggio opera su dati non filtrati.
CLI e tinker: un sviluppatore che apre php artisan tinker per debuggare un problema non ha contesto tenant. Se esegue User::all() senza impostare il tenant, vede tutti gli utenti di tutti i tenant - o nessuno, se il global scope filtra per un tenant null. La soluzione è un comando artisan tenant:impersonate {id} che imposta il contesto per la sessione CLI.
Il mio approccio ibrido: single database con guardrail a livello di infrastruttura
Per il terzo SaaS (una piattaforma di gestione contratti per PMI), ho adottato la strategia tenant_id su database condiviso ma con tre guardrail che compensano il rischio di data leak senza il costo operativo dei database separati.
Il primo guardrail è una row-level security policy a livello di database. MySQL 8 non supporta RLS nativo (PostgreSQL sì), ma ho implementato un meccanismo equivalente con una stored procedure che verifica il tenant_id su ogni INSERT e UPDATE tramite trigger:
-- Trigger di validazione tenant su INSERT
-- Impedisce l'inserimento di record con tenant_id diverso
-- dal tenant corrente nella variabile di sessione
DELIMITER //
CREATE TRIGGER check_tenant_insert_documents
BEFORE INSERT ON documents
FOR EACH ROW
BEGIN
IF @current_tenant_id IS NOT NULL
AND NEW.tenant_id != @current_tenant_id THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Tenant ID mismatch: tentativo di cross-tenant write';
END IF;
END //
DELIMITER ;Il middleware Laravel imposta @current_tenant_id come variabile di sessione MySQL all'inizio di ogni connessione. Se un bug nel codice applicativo prova a scrivere un record con un tenant_id diverso dal tenant corrente, il trigger blocca l'operazione a livello di database - una difesa in profondità che funziona anche quando il codice PHP fallisce.
Il secondo guardrail è il test automatizzato di isolamento: una suite di test che crea due tenant, inserisce dati per ciascuno, e poi verifica che nessuna query possa accedere ai dati dell'altro tenant. Questo test gira in CI ad ogni push e copre tutti i controller, tutti i job in coda, e tutti i comandi Artisan. Se qualcuno introduce una query che bypassa il global scope, il test fallisce e il merge viene bloccato. Ho descritto un approccio simile alla validazione sistematica nel mio articolo sui test automatici per codebase PHP legacy senza riscrittura, dove la regola è la stessa: il test di isolamento è la rete di sicurezza che cattura i bug prima che raggiungano la produzione.
Il terzo guardrail è la segregazione dei backup per tenant. Anche con un singolo database, il backup notturno viene esportato con filtro per tenant_id in file separati, così che in caso di ripristino parziale (un tenant chiede il ripristino dei suoi dati dopo una cancellazione accidentale) non devo toccare i dati degli altri tenant.
Quando cambiare strategia: i segnali che il single database non basta più
La documentazione di Tenancy for Laravel descrive entrambe le strategie (single e multi-database) e permette di migrare dall'una all'altra. Ma nella pratica, il cambio di strategia è un'operazione complessa che va pianificata quando i segnali sono chiari, non quando il sistema è già in crisi. I segnali che ho identificato nei miei tre progetti sono quattro.
Il primo segnale è la dimensione dei dati di un singolo tenant che supera il 20% del database totale. Quando un tenant "grande" domina il database, le sue query rallentano tutti gli altri tenant perché condividono le stesse tabelle e gli stessi indici. La soluzione temporanea è aggiungere indici composti che includano tenant_id come prima colonna; la soluzione definitiva è estrarre quel tenant in un database dedicato.
Il secondo segnale è un requisito contrattuale di isolamento da parte di un cliente enterprise. Quando un'azienda grande firma un contratto SaaS con clausola di isolamento dei dati (tipico in fintech, healthcare e PA), devi dimostrare che i suoi dati non condividono tabelle con altri clienti. Con la strategia tenant_id, questa dimostrazione è impossibile - e il contratto va perso.
Il terzo segnale è la frequenza delle migration che diventa un problema operativo. Con 200+ tenant su database condiviso, una ALTER TABLE su una tabella grande blocca tutti i tenant contemporaneamente. Con database separati, puoi migrare i tenant uno alla volta con zero-downtime rolling migration.
Il quarto segnale è la compliance GDPR per il diritto alla cancellazione. Quando un tenant chiede la cancellazione completa dei suoi dati (art. 17 GDPR), con il database condiviso devi eseguire DELETE su ogni tabella filtrando per tenant_id e verificare di non aver dimenticato nessuna tabella. Con un database dedicato, fai DROP DATABASE e la certezza di completezza è assoluta.
La scelta della strategia multi-tenant non è una decisione una tantum - è un'architettura che evolve con la crescita del prodotto. Il consiglio che do ai clienti è: parti con tenant_id su database condiviso (con i guardrail che ho descritto), e pianifica la migrazione a database separati quando uno dei quattro segnali diventa concreto. Ho descritto un approccio analogo alla migrazione incrementale nel mio articolo sul pattern Strangler Fig per estrarre microservizi da un monolite Laravel - il principio è lo stesso: non decomporre per principio, decomponi quando il problema è misurabile. Se stai progettando un SaaS multi-tenant e non sai quale strategia adottare, o se hai un SaaS in produzione che mostra i segnali di stress che ho descritto, contattami per una consulenza architetturale: in una giornata di lavoro analizziamo i requisiti di isolamento, dimensioniamo la strategia e definiamo un piano di implementazione con tempi e costi realistici.