Journal de bord · Phase 0 · 5/7
L'architecture Modules du repo web
Cinq modules, un point de sortie unique vers le cloud et des revues qui se lisent au chemin de fichier : l'architecture Laravel du repo web, généré à plus de quatre-vingt-dix pour cent par des agents IA.
- laravel
- architecture
- octane

Un site vitrine sans base de données, c'est quelques pages et un formulaire. La tentation, sur un si petit périmètre, c'est de tout empiler dans deux ou trois controllers et de passer à autre chose. J'ai pris le chemin inverse dès la première semaine du chantier. Quand j'ai posé la landing en avril, j'ai rangé chaque responsabilité dans son propre module. Aujourd'hui le repo compte cinq domaines : Cloud, Locale, Seo, Waitlist, et maintenant Blog. Voilà pourquoi cette discipline tient, même à cette échelle.
Un dossier par domaine, pas par type technique
La structure de départ de Laravel range le code par nature : tous les controllers ensemble, tous les services ensemble, toutes les classes utilitaires ensemble. Sur aubia.dev, j'ai basculé sur un rangement par domaine métier. Chaque dossier de app/Modules/ porte une intention produit complète, avec ses sous-dossiers Actions/, DataTransferObjects/, Http/ et Enums/ quand ils sont utiles.
Cloud: le seul point de sortie HTTP versapi.aubia.cloud.Locale: la résolution de langue avant le routing.Seo: la génération du sitemap et des signaux d'indexation.Waitlist: le proxy d'inscription en double opt-in.Blog: le moteur flat-file qui sert cet article.
Quand une intention change, je sais exactement où regarder. Aucun fichier ne joue sur deux tableaux.
Les Actions : une intention, une classe
Chaque geste métier vit dans sa propre classe d'Action. Pas de service fourre-tout qui accumule quinze méthodes au fil des mois : une classe fait une chose, l'expose par une méthode d'entrée, et se teste hors de la stack HTTP. La convention Aubia veut ces classes final readonly par défaut, avec un point d'entrée nommé (execute) ou invocable.
La résolution de locale en est un bon exemple. Elle rejoue la même cascade que le middleware, sans toucher au segment d'URL, isolée pour rester testable :
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;
}
Le controller qui appelle cette Action reste trivial : il délègue et redirige. Toute la logique testable est ailleurs.
Les DTO readonly : un contrat typé entre les couches
Entre une couche et la suivante, je ne fais jamais transiter un tableau associatif vaguely typé. Chaque payload structuré passe par un Data Transfer Object en pur PHP : une readonly class à propriétés promues, avec un fromArray() en entrée et une méthode de sortie explicite. Pas de dépendance externe pour ça, pas de mapping magique : le typage strict de PHP 8.5 suffit, et le contrat se lit d'un coup d'œil.
L'inscription waitlist illustre pourquoi ce contrat compte. Le formulaire capture un champ honeypot temporel, startedAt, utilisé seulement pour piéger les bots côté validation. Ce champ ne doit jamais partir vers le cloud. Le DTO le porte en interne mais l'exclut du payload sortant :
public function toCloudPayload(): array
{
return [
'email' => $this->email,
'utm_source' => $this->utmSource,
'utm_medium' => $this->utmMedium,
'utm_campaign' => $this->utmCampaign,
'locale' => $this->locale,
];
}
La frontière entre ce que le web connaît et ce que le cloud reçoit est écrite noir sur blanc, dans une méthode dont le nom dit exactement son rôle.
Un point de sortie unique vers le cloud
Puisque la Phase 0 ne stocke rien en local, toute donnée persistante part vers le repo cloud via un proxy HTTP. J'ai centralisé ces appels dans une seule classe, CloudApiClient, pour que les Actions n'aient jamais à connaître les détails du client Http natif de Laravel : timeouts, backoff, logging sans donnée personnelle. Le client applique deux politiques de retry distinctes selon l'idempotence du verbe. Un GET rejoue sur erreur réseau et sur 5xx, sans effet de bord. Un POST signé ne rejoue que sur une panne réseau franche, jamais sur une réponse reçue, pour ne pas risquer une double inscription.
Ce client est final mais volontairement pas readonly au niveau de la classe : sous Octane, il doit rester sans état d'une requête à l'autre, et rester mockable dans les tests. Un détail de discipline stateless qui évite les fuites entre workers.
Pourquoi cette discipline sur un si petit repo
La question honnête, c'est : n'est-ce pas trop pour cinq pages ?
Ce repo est généré à quatre-vingt-dix pour cent et plus par des agents IA, et la review humaine reste le vrai goulot. Une frontière de module nette est un garde-fou dans les deux sens : un agent lâché sur Waitlist ne déborde pas sur Seo par accident, et mes revues restent courtes à lire parce que le périmètre d'un changement se devine à son chemin de fichier.
La Phase 1 amènera un vrai site produit, et chaque module d'aujourd'hui est une fondation, pas un échafaudage jetable. Et cette structure est cohérente d'un repo Aubia à l'autre : je retrouve les mêmes réflexes sur le cockpit desktop et sur le cloud. Cette régularité, sur un projet mené en solo, vaut bien quelques dossiers de plus.
Descendre dans la peau du site
Reste le plus visible : la palette, le verre et le mouvement. Pour recevoir l'invitation à la beta 0.1 dès son ouverture, la liste d'attente est là.
Ce chantier se raconte ici, article après article. Embarquez dans l'aventure : vos retours dessineront la suite.
Rejoindre la liste d'attente