
In un post precedente ho raccontato perché abbiamo fatto il pivot da servizi a prodotto a marzo 2024: due anni di partnership editoriale ci avevano dato il prodotto già pronto in produzione, bisognava estrarlo come piattaforma. Quel post si fermava al perché. Questo è il come: l’architettura tecnica che ha permesso di trasformare due anni di codice custom in AI Multisite, oggi piattaforma SaaS che gestisce 45M di pageview/mese su 40+ testate. Vedi anche gli altri post di questa rubrica: Production.
Niente di nuovo nei singoli pezzi: ognuno di questi pattern è documentato da chi li ha inventati. Quello che racconto qui è la combinazione concreta che funziona per noi, con i trade-off scelti consapevolmente per un team di 5 persone.
Vincoli di partenza
Prima di parlare di pattern, i vincoli che hanno guidato ogni scelta:
- team da 2 dev e mezzo all’inizio dell’estrazione, oggi 5 in totale (non tutti full-time sul prodotto)
- partner storico in produzione che doveva continuare a girare senza downtime durante l’estrazione
- nuovi tenant che dovevano poter essere onboardati in giorni, non settimane
- editori che lavorano con WordPress da 10+ anni e non avrebbero mai cambiato CMS lato redazione
- budget infrastruttura sostenibile da un’azienda autofinanziata, senza VC che mettano cassa per l’over-provisioning
Questi vincoli, in ordine, hanno deciso quasi tutto.
Topology: i layer in ordine di traffico
[ Editor / Redattore ]
│
▼ (browser)
┌────────────────────────────────────┐
│ Cloudflare CDN + WAF + Turnstile │
└──────────────────┬─────────────────┘
▼
┌────────────────────────────────────────────────────────────┐
│ Vue 3 + Vuetify SPA (cromiltec-ui) │
│ Pinia, CASL ABAC, TipTap editor, ApexCharts, Mapbox │
└──────────────────┬─────────────────────────────────────────┘
▼ (REST + Bearer OAuth2)
┌────────────────────────────────────────────────────────────┐
│ Laravel 12 Backend API (backend-laravel) │
│ Action-DTO-Policy architecture │
│ Passport OAuth2 · Spatie Permission · Spatie Data │
│ Tenancy via Eloquent Global Scope su tenant_id │
└─┬──────────────────┬──────────────────┬────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────────────────┐
│ Horizon │ │ Typesense │ │ Prism PHP (LlmProvider) │
│ Queue │ │ Search v28 │ │ OpenAI · Anthropic · xAI │
│ Workers │ │ │ │ Google · DeepSeek · ... │
└────┬─────┘ └──────────────┘ └──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Microservices │
│ - matomo_microservice (ClickHouse + Redis 7 LRU) │
│ - stilometria service (modello 10 parametri) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Data Tier │
│ - MariaDB 11 (primary + 2 replica) via MaxScale R/W split │
│ - ClickHouse (analytics, materialized views) │
│ - Redis (cache, session, queue, pub/sub) │
│ - OpenSearch 3.5 (3 nodi LXC + dashboards) per search log │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Edge Editorial: WordPress Network │
│ Sync bidirezionale via REST API + Application Passwords │
│ Job di full-sync, single-sync, re-sync, image upload async │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Infra: Proxmox cluster (PVE 9.x) + Docker + Ansible │
│ Caddy SSL termination, nginx reverse proxy │
│ Sentry per error tracking, Prometheus + Grafana │
└─────────────────────────────────────────────────────────────┘
Vado layer per layer.
Layer 1: il CMS non si tocca
La prima decisione di prodotto è stata anche la più importante. Le redazioni con cui lavoriamo usano WordPress da anni, hanno workflow consolidati, plugin custom, integrazioni di prima parte. Cambiare CMS lato editoriale non era negoziabile. AI Multisite quindi non è un CMS: è un hub editoriale che siede in mezzo, e WordPress resta il front-of-house.
Conseguenza tecnica: tutto il sync avviene via REST API standard di WordPress (/wp-json/wp/v2/...), con Application Passwords come meccanismo di auth (più stabile dei JWT con plugin terzi). Non abbiamo plugin proprietari sul WP del cliente: solo configurazione standard più, opzionalmente, un piccolo helper plugin per esporre metadati che il core non espone (alcuni custom field SEO, statistiche di lettura).
Il vantaggio è enorme: zero lock-in lato cliente, niente debiti tecnici da plugin custom da manutenere. Lo svantaggio: dipendiamo dalle scelte di breaking change del core WordPress su REST. Lo accettiamo.
Layer 2: shared-schema multi-tenant in Laravel
La scelta più discussa internamente. Avevamo tre opzioni, e ognuna ha sostenitori legittimi:
- database-per-tenant: isolamento massimo, costo operativo enorme (N database = N backup, N migrations, N capacity plans)
- schema-per-tenant: isolamento medio, complicazioni nelle migrazioni cross-schema
- shared-schema con
tenant_idcome discriminante: isolamento logico, costo operativo minimo, attenzione massima alle query
Abbiamo scelto shared-schema. Per implementarlo:
// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if ($tenantId = TenantContext::current()?->id) {
$builder->where(
$builder->getModel()->getTable() . '.tenant_id',
$tenantId
);
}
});
static::creating(function (Model $model) {
if (! $model->tenant_id && $tenantId = TenantContext::current()?->id) {
$model->tenant_id = $tenantId;
}
});
}
}
Ogni model tenant-aware (Article, Website, AiPrompt, Author, Draft, eccetera) usa il trait. Il TenantContext viene popolato da un middleware HTTP che legge il claim dal token OAuth2 e dispone il tenant per la request. Stesso TenantContext viene serializzato dentro i payload dei job Horizon, così il global scope vale anche dentro le code asincrone.
Il rischio principale di questo pattern è la query che dimentica il tenant_id. Mitigazioni:
- PHPStan a level 5 con regole custom che identificano
whereRawoDB::selectsenzatenant_id - audit periodico delle migration per assicurarsi che ogni nuova tabella tenant-aware abbia l’index composto
(tenant_id, ...)come prima chiave - test Pest che, in suite tenant-isolation, montano due tenant nella stessa transazione e verificano che A non veda i dati di B
Niente di magico. Disciplina di codice. Il pay-off è che un tenant nuovo si crea con un INSERT in tenants e qualche riga di seed: zero provisioning di database o schema.
Layer 3: Action-DTO-Policy architecture
I controller Laravel sono thin: ricevono la request, validano via DTO con spatie/laravel-data, chiamano una Action che incapsula la logica, autorizzano via Policy, formattano la risposta via Resource. Il pattern non è invenzione nostra, è ben documentato dalla community Laravel; quello che è nostro è la disciplina di non fare eccezioni.
// app/Actions/NewsroomAI/Article/AiGeneration.php
final class AiGeneration
{
public function __construct(
private readonly LlmProvider $llm,
private readonly StylometryService $stylometry,
private readonly PromptOrchestrator $orchestrator,
) {}
public function execute(AiGenerationData $data): Article
{
$prompt = $this->orchestrator->build(
tone: $data->website->tone,
cultural: $data->website->locale,
kind: $data->kind,
stylometry: $this->stylometry->profile($data->author),
);
$generation = $this->llm->generate(
prompt: $prompt,
modelHint: $data->modelHint ?? $data->website->defaultModel,
temperature: 0.5,
maxTokens: 16_000,
);
return Article::query()->create([
'website_id' => $data->website->id,
'author_id' => $data->author->id,
'title' => $generation->title,
'body' => $generation->body,
'meta' => $generation->meta,
'status' => ArticleStatus::Draft,
// tenant_id viene popolato dal global scope
]);
}
}
Il vantaggio della disciplina: ogni Action è testabile in isolamento con mock dei tre dipendenti (LLM, stilometria, prompt orchestrator). Il pay-off in test coverage è enorme: oggi siamo intorno al 70% sulle Action, che è il livello dove la business logic vive davvero.
Layer 4: orchestrazione multi-LLM via adapter
Il LlmProvider è un’interfaccia, le implementazioni sono molte. Prism PHP ci dà il wrapper di base; sopra abbiamo costruito retry logic (3 tentativi, 100ms backoff iniziale, exponential), timeout 600 secondi, normalizzazione output JSON, gestione di modelli reasoning (o-series, DeepSeek-R1) che non accettano temperature esplicita.
La parte non banale è il routing: quale provider per quale richiesta? La logica oggi:
- per-sito default model (configurato nel pivot
accounts_websites) - override per tipo di task: traduzione → DeepL, generazione lunga → Claude, embedding → OpenAI, classificazione → modello locale via Ollama
- override per costo: se il tenant ha esaurito la quota mensile sul tier premium, fallback automatico su modello più economico
- A/B per ricerca: il 5% delle richieste viene inviato a un modello challenger per misurare la qualità relativa
Ogni chiamata viene loggata con tenant_id, provider, model, input_tokens, output_tokens, cost_eur, latency_ms. Questo log alimenta sia il pricing al tenant sia il dashboard interno di osservabilità.
Layer 5: stilometria come microservizio
La stilometria computazionale a 10 parametri (descritta nel paper Una Metodologia Ibrida) è un servizio FastAPI separato dal monolite Laravel. Perché?
- gira in Python perché le librerie NLP italiane buone sono Python
- ha un ciclo di rilascio diverso dal monolite: il modello evolve indipendentemente dal backend, e non vogliamo che un’iterazione del modello richieda un deploy del backend
- usa un pool di processi separato: la generazione di un profilo stilometrico è CPU-bound, e non vogliamo che saturi i worker PHP-FPM
Comunicazione: REST sincrono per le query veloci (estrazione profilo da articolo singolo), webhook firmato per le elaborazioni asincrone. Niente RabbitMQ qui: Redis come broker semplice basta. Se in futuro avremo più di 5 microservizi parleremo di message queue strutturate.
Layer 6: data tier ad alta disponibilità
MariaDB 11 in cluster, replica circolare fra due nodi (nomi-codice interni james e jason) con MaxScale come router R/W splitting davanti. Le SELECT non transazionali vanno sulle replica, le scritture e le SELECT FOR UPDATE sul primary, failover automatico in caso di down del primary. Il setup è descritto nei nostri runbook interni.
Sopra MariaDB:
- ClickHouse per analytics, con materialized view che riassumono i dati Matomo in finestre temporali pre-calcolate. È lo standard per dashboard analytics su volumi alti, con materialized view che danno query 10-100x più veloci della stessa query su Matomo nativo
- Redis per cache, session, queue (Horizon) e pub/sub realtime
- OpenSearch 3.5 su cluster a 3 nodi LXC (Debian 13) per il search avanzato sulle log e per query full-text che Typesense non copre
Typesense v28 è il search di prima istanza (autocomplete editor, ricerca articoli in interfaccia). OpenSearch è per il search analytics e log.
Layer 7: realtime e queue
Le operazioni realtime (notifica al redattore quando un articolo finisce di generare via AI, sync stato fra utenti dello stesso tenant) usano Redis pub/sub con Laravel Echo sul frontend. Niente Socket.io o Pusher: pub/sub Redis è sufficiente per i nostri volumi e mantiene zero dipendenze esterne.
Le queue lunghe vivono dentro Horizon: full-sync con WordPress (può essere lungo per testate con 10K+ articoli), generazione AI (può prendere fino a 30 secondi per articolo lungo), upload immagini, calcolo embedding per indicizzazione. Worker dedicati per queue, autoscaling lato Proxmox per i picchi.
Layer 8: infrastruttura Proxmox
Tutto gira su un cluster Proxmox interno: container LXC per i servizi stateless (web, queue, microservizi), VM per i database, dataset ZFS dedicati per tenant pesanti. Caddy come SSL termination al livello esterno, nginx come reverse proxy interno verso le applicazioni.
CI/CD via GitHub Actions con runner self-hosted dentro il cluster. Deploy zero-downtime via blue/green su web + drain del worker pool. Ansible per il provisioning iniziale di nuovi nodi e per gli aggiornamenti coordinati.
Sentry per gli errori application-level, Prometheus + Grafana per le metriche di sistema, alerting su un canale interno di team su cui sappiamo di avere reazione in tempo reale. CrowdSec come layer di IPS distribuito sull’edge.
Cosa rifarei diverso
Tre scelte su cui ho dei dubbi a posteriori, che condivido per onestà.
Il monolite Laravel è ancora un monolite. L’architettura Action-DTO-Policy aiuta ad avere boundaries logiche dentro il monolite, ma se dovessi rifare l’estrazione partendo da zero spaccherei prima alcune funzionalità in servizi separati: in particolare la pipeline di generazione AI, che ha un ciclo di vita molto diverso dal resto. Oggi se aggiorno la libreria Prism devo rideployare tutto il backend, e questo costa.
Il search a due velocità (Typesense + OpenSearch) è un po’ troppo. Funziona, ma è un layer in più da manutenere. Se ricominciassi forse sceglierei solo OpenSearch e accetterei una latenza un po’ più alta sulle ricerche editor in cambio di un servizio in meno.
Il sync bidirezionale con WordPress è la parte meno robusta. Funziona bene il 99% delle volte, ma il restante 1% (race condition fra modifica via WP e modifica via AI Multisite, post che cambiano slug, autori che vengono uniti) richiede manutenzione continua e logiche di reconciliation che non sono mai pulite. Se WordPress avesse hooks transazionali nativi sarei meno preoccupato, ma non li ha.
Quello che mi sono portato a casa
Prima. L’architettura non si progetta su un foglio bianco, si estrae da una codebase che ha già visto produzione. Le scelte che oggi sembrano solide sono solide perché derivano da due anni di osservazione del workflow reale. Una piattaforma progettata in astratto, senza un partner che la usi davvero, sarebbe stata diversa e probabilmente peggiore.
Seconda. Shared-schema multi-tenant è la scelta giusta per un team piccolo. Se un giorno saremo 50 e avremo budget operativo enterprise, potremo passare a database-per-tenant per i clienti che lo richiedono. Oggi no.
Terza. Il prodotto non è il monolite Laravel, è la composizione di 6-7 servizi che parlano fra loro. Il monolite resta perché è il pezzo dove vive la business logic editoriale, ma stilometria, analytics, search, sync WP sono pezzi staccati con cicli di vita propri. Saperlo dall’inizio aiuta a non costruire una palla di lana.
L’architettura che vedete oggi è il risultato di un anno e mezzo di iterazione sopra l’estrazione di marzo 2024. Continua a evolvere: nei prossimi trimestri uscirà anche dal monolite la pipeline di generazione AI, probabilmente come microservizio FastAPI separato. Ma questa è materia di un altro post.
