Diario di bordo · Fase 0 · 5/7
L'architettura a moduli del repo web
Cinque moduli, un unico punto di uscita verso il cloud e revisioni che si leggono dal percorso di file: l'architettura Laravel del repo web, generato per oltre il novanta percento da agenti IA.
- laravel
- architecture
- octane

Un sito vetrina senza database è qualche pagina e un form. La tentazione, su un perimetro così piccolo, è ammassare tutto in due o tre controller e passare oltre. Ho preso la strada opposta fin dalla prima settimana del progetto. Quando ho messo online la landing ad aprile, ho archiviato ogni responsabilità nel proprio modulo. Oggi il repo conta cinque domini: Cloud, Locale, Seo, Waitlist e ora Blog. Ecco perché questa disciplina regge, anche a questa scala.
Una cartella per dominio, non per tipo tecnico
La struttura di partenza di Laravel archivia il codice per natura: tutti i controller insieme, tutti i service insieme, tutte le classi di utilità insieme. Su aubia.dev, sono passato all'archiviazione per dominio di business. Ogni cartella sotto app/Modules/ porta un'intenzione di prodotto completa, con le sue sottocartelle Actions/, DataTransferObjects/, Http/ ed Enums/ dove sono utili.
Cloud: l'unico punto di uscita HTTP versoapi.aubia.cloud.Locale: la risoluzione della lingua prima del routing.Seo: la generazione della sitemap e dei segnali di indicizzazione.Waitlist: il proxy di iscrizione in double opt-in.Blog: il motore flat-file che serve questo articolo.
Quando un'intenzione cambia, so esattamente dove guardare. Nessun file gioca su due tavoli.
Le Action: un'intenzione, una classe
Ogni gesto di business vive nella propria classe Action. Nessun service tuttofare che accumula quindici metodi nel corso dei mesi: una classe fa una cosa, la espone attraverso un metodo di ingresso, ed è testata al di fuori dello stack HTTP. La convenzione Aubia vuole queste classi final readonly per impostazione predefinita, con un punto di ingresso nominato (execute) o invocabile.
La risoluzione della lingua ne è un buon esempio. Ripete la stessa cascata del middleware, senza toccare il segmento dell'URL, isolata per restare testabile:
public function __invoke(Request $request): string
{
$candidates = [
$request->cookie(SetLocale::COOKIE_NAME),
$this->extractAcceptLanguage($request),
config('app.locale'),
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && in_array($candidate, SetLocale::SUPPORTED_LOCALES, true)) {
return $candidate;
}
}
return SetLocale::DEFAULT_LOCALE;
}
Il controller che chiama questa Action resta banale: delega e reindirizza. Tutta la logica testabile è altrove.
I DTO readonly: un contratto tipizzato tra i livelli
Da un livello all'altro, non faccio mai passare un array associativo tipizzato in modo approssimativo. Ogni payload strutturato passa da un Data Transfer Object in puro PHP: una readonly class con proprietà promosse, con un fromArray() in entrata e un metodo di uscita esplicito. Nessuna dipendenza esterna per questo, nessun mapping magico: la tipizzazione rigorosa di PHP 8.5 basta, e il contratto si legge a colpo d'occhio.
L'iscrizione alla lista d'attesa illustra perché questo contratto conta. Il form cattura un campo honeypot temporale, startedAt, usato solo per intrappolare i bot sul versante validazione. Questo campo non deve mai partire verso il cloud. Il DTO lo trasporta internamente ma lo esclude dal payload in uscita:
public function toCloudPayload(): array
{
return [
'email' => $this->email,
'utm_source' => $this->utmSource,
'utm_medium' => $this->utmMedium,
'utm_campaign' => $this->utmCampaign,
'locale' => $this->locale,
];
}
Il confine tra ciò che il web conosce e ciò che il cloud riceve è scritto nero su bianco, in un metodo il cui nome dice esattamente cosa fa.
Un unico punto di uscita verso il cloud
Poiché la Fase 0 non memorizza nulla localmente, tutti i dati persistenti escono verso il repo cloud attraverso un proxy HTTP. Ho centralizzato queste chiamate in un'unica classe, CloudApiClient, in modo che le Action non debbano mai conoscere i dettagli del client Http nativo di Laravel: timeout, backoff, logging senza dati personali. Il client applica due politiche di retry distinte in base all'idempotenza del verbo. Un GET ripete su errore di rete e su 5xx, senza effetti collaterali. Un POST firmato ripete solo su un vero e proprio fallimento di rete, mai su una risposta ricevuta, per non rischiare una doppia iscrizione.
Questo client è final ma deliberatamente non readonly a livello di classe: sotto Octane, deve restare senza stato da una richiesta all'altra, e restare mockabile nei test. Un dettaglio di disciplina stateless che evita le fughe tra i worker.
Perché questa disciplina su un repo così piccolo
La domanda onesta è: non è troppo per cinque pagine?
Questo repo è generato per il novanta percento e oltre da agenti IA, e la revisione umana resta il vero collo di bottiglia. Un confine netto tra i moduli è una barriera in entrambe le direzioni: un agente lasciato libero su Waitlist non sconfina in Seo per sbaglio, e le mie revisioni restano brevi da leggere perché il perimetro di una modifica si intuisce dal suo percorso di file.
La Fase 1 porterà un vero sito di prodotto, e ogni modulo di oggi è una fondazione, non un'impalcatura usa e getta. E questa struttura è coerente da un repo Aubia all'altro: ritrovo gli stessi riflessi sul cockpit desktop e sul cloud. Quella regolarità, su un progetto portato avanti da solo, vale bene qualche cartella in più.
Scendere sotto la pelle del sito
Resta il più visibile: la palette, il vetro e il motion. Per ricevere l'invito alla beta 0.1 fin dalla sua apertura, la lista d'attesa è lì.
Questo cantiere si racconta qui, articolo dopo articolo. Salite a bordo: i vostri riscontri disegneranno il seguito.
Iscriviti alla lista d'attesa