Aller au contenu principal

Journal de bord · Phase 0 · 5/7

Les cinq modules Laravel d'aubia.dev

Le backend d'aubia.dev regroupe le code par responsabilité. Actions, Queries et DTO aident à suivre une modification, avec des dépendances que les dossiers ne suffisent pas à contrôler.

Publié le 10 août 2026Mis à jour le 15 septembre 20266 min de lecture
  • laravel
  • architecture
  • octane

Le controller d'inscription, sa validation et les données envoyées au cloud se trouvent tous sous app/Modules/Waitlist/. Pour suivre une soumission, je reste dans ce dossier jusqu'à l'appel HTTP sortant.

Je travaille avec des agents IA et je relis leurs pull requests. Regrouper les fichiers par responsabilité me donne un point de départ : une modification du formulaire conduit vers Waitlist, une modification du sitemap vers Seo. Les imports et le diff restent nécessaires pour savoir ce que le changement affecte.

Regrouper le code par responsabilité

Laravel propose une structure de départ, sans imposer un découpage en modules. Sur cette vitrine, j'utilise cinq dossiers sous app/Modules/ :

  • Blog lit les articles Markdown, les rend en HTML et construit le flux RSS.
  • Cloud regroupe le transport HTTP vers api.aubia.cloud dans CloudApiClient.
  • Locale choisit la langue et construit les URL localisées.
  • Seo construit le sitemap à partir des pages publiques et des articles publiés.
  • Waitlist transmet les demandes d'inscription et récupère le nombre d'inscriptions confirmées.

Chaque module contient les sous-dossiers dont il a besoin. Cloud n'a qu'une classe à sa racine. Blog comprend aussi des commandes Artisan et des adaptateurs pour le moteur Markdown.

Carte simplifiée des cinq modules : Locale, Seo, Waitlist, Blog et Cloud. Un flux relie Waitlist à Cloud, puis à api.aubia.cloud.

Cette carte situe les modules et leur passerelle HTTP. Elle ne représente pas toutes leurs dépendances ni tous leurs sous-dossiers : Blog et Waitlist contiennent aussi un dossier Queries/.

Actions et Queries

Une Action exécute une opération par une méthode d'instance execute(). SubmitWaitlistEmailAction orchestre l'inscription ; BuildSitemapAction construit le XML du sitemap. Une Query expose une lecture par run() : ListBlogPostsQuery retourne les articles publiés, FindBlogPostQuery recherche un article et FetchWaitlistCountQuery lit le compteur distant.

La distinction ne dépend donc pas de l'endroit où les données sont stockées. Le blog lit des fichiers locaux, le compteur interroge une API. Les deux lectures sont des Queries.

La préparation des articles de production se fait au build. CompileBlogAction appelle ParseBlogPostAction, qui valide les sources et orchestre le rendu Markdown, la coloration Phiki et l'estimation du temps de lecture. ListBlogPostsQuery lit ensuite le catalogue via ReadCompiledBlogQuery et filtre les dates de publication. Le parsing avec cache reste disponible en local et dans les tests ; en production, un catalogue absent, invalide ou périmé donne une réponse 503, sans parsing de secours.

Ces noms et ces points d'entrée sont des conventions du repo. Laravel sait construire et injecter les classes concrètes à partir de leurs types ; aucune bibliothèque d'Actions supplémentaire n'est nécessaire.

Une dépendance visible dans le constructeur

La racine / redirige en 302 vers une URL localisée. ResolveLocaleRedirectAction reçoit DetectLocaleAction dans son constructeur et lui transmet les candidats dans l'ordre de priorité.

namespace App\Modules\Locale\Actions;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;

final readonly class ResolveLocaleRedirectAction
{
    public function __construct(private DetectLocaleAction $detectLocaleAction) {}

    public function execute(Request $request): string
    {
        return $this->detectLocaleAction->execute([
            $request->cookie(DetectLocaleAction::COOKIE_NAME),
            $this->detectLocaleAction->extractAcceptLanguage($request),
            Config::string('app.locale'),
        ])->value;
    }
}

Le premier candidat reconnu l'emporte : le cookie de préférence, puis la langue extraite de l'en-tête Accept-Language, puis la configuration. DetectLocaleAction retourne un SupportedLocale, l'enum des six langues servies. L'accès à ->value donne la chaîne utilisée dans l'URL.

Le middleware SetLocale appelle la même Action en ajoutant le segment d'URL en tête. La règle de sélection reste commune, mais la liste des candidats dépend de l'appelant. La requête HTTP arrive à execute(), sans être conservée dans le constructeur.

Les données après la validation

Le formulaire d'inscription passe par une FormRequest, la classe de validation Laravel. Elle vérifie aussi les protections anti-bot locales, puis sélectionne les champs nécessaires à WaitlistSubmissionData.

Ce DTO, ou objet de transfert de données, contient l'adresse email, les trois valeurs d'attribution UTM et la locale. Les champs de contrôle anti-bot ne font pas partie de ses propriétés. Ils ne sont donc pas transmis à l'Action d'inscription par cet objet.

La méthode de sortie énumère le payload destiné au cloud :

public function toCloudPayload(): array
{
    return [
        'email' => $this->email,
        'utm_source' => $this->utmSource,
        'utm_medium' => $this->utmMedium,
        'utm_campaign' => $this->utmCampaign,
        'locale' => $this->locale->value,
    ];
}

La locale reste un enum dans le code PHP et devient une chaîne à la sortie. Ajouter une propriété au DTO ne l'ajoute pas automatiquement au payload : cette méthode doit être modifiée.

Le blog utilise le même principe pour des destinataires différents. BlogPostData fournit un résumé sans le HTML à l'index, l'article complet à sa page et un tableau de valeurs sérialisables au catalogue compilé comme au cache local. Le DTO transporte ces résultats ; le rendu Markdown appartient à une Action.

La passerelle HTTP du module Cloud

Les appels applicatifs vers aubia.cloud passent par CloudApiClient. Deux classes l'utilisent : l'Action de soumission et la Query du compteur, toutes deux dans Waitlist. La vitrine ne possède pas de base de données locale pour les inscriptions.

L'Action prépare le corps JSON et sa signature. Le client HTTP envoie les octets fournis et gère les délais d'attente, les nouvelles tentatives et les journaux d'échec. Ces journaux indiquent le type d'erreur, sans enregistrer l'adresse email ni le corps envoyé.

Un GET peut être retenté après une erreur de connexion ou une réponse 5xx, pas après une réponse 4xx. Le POST signé n'est retenté qu'après une erreur de connexion. Cette erreur ne prouve pas que le service distant n'a rien traité : la réponse peut manquer après réception de la demande.

CloudApiClient est une classe final, sans propriété d'instance. Ses tests utilisent Http::fake() pour simuler les réponses et les erreurs réseau, en exécutant le vrai client applicatif.

Ce que readonly ne garantit pas sous Octane

Les Actions, les Queries et les DTO sont déclarés final readonly. final interdit l'héritage. readonly empêche de réassigner les propriétés après leur initialisation, mais ne rend pas immuable un objet qu'elles contiennent.

ListBlogPostsQuery conserve ses dépendances dans le constructeur, mais pas les collections d'articles. Les résultats de lecture restent dans les variables locales de ses méthodes. Le catalogue compilé sur disque est distinct du cache Redis utilisé par le mode de parsing local.

Sous Octane, Laravel reste chargé entre les requêtes. Le mot-clé readonly ne décide pas de la durée de vie d'une instance. Le repo ne déclare pas ces classes comme singletons ; les enregistrer comme services partagés demanderait de revoir leur état et leurs dépendances. La documentation d'Octane décrit les risques d'une requête HTTP conservée dans un service d'une requête à la suivante.

Les dépendances à relire

Le sitemap utilise ListBlogPostsQuery pour connaître les articles publiés. Une modification dans Blog peut donc modifier la sortie de Seo, même si aucun fichier de Seo ne change dans la pull request.

Les tests d'architecture vérifient les suffixes, les déclarations final readonly et les méthodes publiques execute() ou run(). Ils n'interdisent pas les imports entre modules. Un agent peut introduire une dépendance mal placée dans un dossier correctement nommé.

Ce découpage ajoute des classes et oblige à passer d'un fichier à l'autre pour suivre une opération. Sur une vitrine de cette taille, c'est un coût réel. Je conserve cette organisation pour retrouver la validation, l'orchestration et les données de sortie aux mêmes endroits. Pendant la revue, je dois encore suivre leurs dépendances et vérifier le comportement avec les tests.

Ce chantier se raconte ici, article après article. Ce qui vient ensuite dépend de ce que vous en direz.

Rejoindre la liste d'attente