Zum Hauptinhalt springen

Devlog · Phase 0 · 5/7

Die fünf Laravel-Module hinter aubia.dev

aubia.dev ordnet Backend-Code nach Zuständigkeit. Actions, Queries und DTOs helfen, Änderungen nachzuvollziehen. Ordner allein kontrollieren aber keine Abhängigkeiten.

Veröffentlicht am 10. August 2026Aktualisiert am 15. September 20266 Min. Lesezeit
  • laravel
  • architecture
  • octane

Der Anmelde-Controller, die Validierung und die Daten für die Cloud befinden sich unter app/Modules/Waitlist/. Wenn ich eine Übermittlung nachvollziehe, bleibe ich bis zum ausgehenden HTTP-Aufruf in diesem Ordner.

Ich arbeite mit KI-Agenten und prüfe ihre Pull Requests. Die Gruppierung nach Zuständigkeit gibt mir einen Ausgangspunkt: Eine Formularänderung führt mich zu Waitlist, eine Sitemap-Änderung zu Seo. Um zu verstehen, worauf sich eine Änderung auswirkt, brauche ich weiterhin die Imports und den Diff.

Code nach Zuständigkeit ordnen

Laravel bietet eine Ausgangsstruktur, ohne Module vorzuschreiben. Für diese öffentliche Website verwende ich fünf Ordner unter app/Modules/:

  • Blog liest Markdown-Artikel, rendert sie als HTML und erstellt den RSS-Feed.
  • Cloud bündelt den HTTP-Transport zu api.aubia.cloud in CloudApiClient.
  • Locale wählt die Sprache und erstellt lokalisierte URLs.
  • Seo erstellt die Sitemap aus öffentlichen Seiten und veröffentlichten Artikeln.
  • Waitlist leitet Anmeldungen weiter und ruft die Anzahl bestätigter Anmeldungen ab.

Jedes Modul enthält die benötigten Unterordner. Cloud hat nur eine Klasse direkt im Modulordner. Blog enthält auch Artisan-Befehle und Adapter für die Markdown-Engine.

Vereinfachte Übersicht der fünf Module: Locale, Seo, Waitlist, Blog und Cloud. Ein Datenfluss verbindet Waitlist mit Cloud und dann mit api.aubia.cloud.

Diese Übersicht zeigt die Module und ihr HTTP-Gateway. Sie bildet nicht alle Abhängigkeiten oder Unterordner ab: Blog und Waitlist haben auch einen Ordner Queries/.

Actions und Queries

Eine Action führt eine Operation über eine Instanzmethode namens execute() aus. SubmitWaitlistEmailAction orchestriert die Anmeldung; BuildSitemapAction erstellt das XML der Sitemap. Eine Query stellt einen Lesezugriff über run() bereit: ListBlogPostsQuery gibt die veröffentlichten Artikel zurück, FindBlogPostQuery sucht einen Artikel und FetchWaitlistCountQuery liest den entfernten Zähler.

Die Unterscheidung hängt nicht vom Speicherort der Daten ab. Der Blog liest lokale Dateien, während der Zähler eine API abfragt. Beide Lesezugriffe sind Queries.

Die Artikel für die Produktion werden beim Build vorbereitet. CompileBlogAction ruft ParseBlogPostAction auf, die die Quelldateien validiert und Markdown-Rendering, Phiki-Hervorhebung und Lesezeitschätzung orchestriert. ListBlogPostsQuery liest anschließend den Katalog über ReadCompiledBlogQuery und filtert nach Veröffentlichungsdatum. Parsing mit Cache bleibt lokal und in Tests verfügbar; in Produktion führt ein fehlender, ungültiger oder veralteter Katalog zu 503, ohne auf Parsing zurückzufallen.

Diese Namen und Einstiegspunkte sind Konventionen des Repos. Laravel kann anhand der Typen konkrete Klassen instanziieren und injizieren; eine zusätzliche Actions-Bibliothek ist nicht nötig.

Eine Abhängigkeit im Konstruktor erkennen

Der Pfad / leitet mit einer 302-Antwort auf eine lokalisierte URL weiter. ResolveLocaleRedirectAction erhält DetectLocaleAction über den Konstruktor und übergibt ihr die Kandidaten in der Reihenfolge ihrer Priorität.

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;
    }
}

Der erste erkannte Kandidat wird ausgewählt: das Cookie mit der Spracheinstellung, dann die aus dem Accept-Language-Header ermittelte Sprache, dann die Konfiguration. DetectLocaleAction liefert einen Wert aus SupportedLocale, dem Enum der sechs unterstützten Sprachen. Der Zugriff auf ->value liefert die Zeichenkette für die URL.

Die Middleware SetLocale ruft dieselbe Action auf und stellt das URL-Segment an den Anfang. Die Auswahlregel ist gemeinsam, die Kandidatenliste hängt aber vom Aufrufer ab. Der HTTP-Request wird an execute() übergeben, ohne im Konstruktor gespeichert zu werden.

Daten nach der Validierung

Das Anmeldeformular durchläuft einen FormRequest, die Validierungsklasse von Laravel. Er prüft auch die lokalen Schutzmaßnahmen gegen Bots und wählt anschließend die Felder aus, die WaitlistSubmissionData benötigt.

Dieses DTO, also Datentransferobjekt, enthält die E-Mail-Adresse, die drei UTM-Attributionswerte und die Locale. Die Kontrollfelder zum Schutz vor Bots gehören nicht zu seinen Properties. Das Objekt übergibt sie daher nicht an die Anmelde-Action.

Die Ausgabemethode führt die für die Cloud bestimmten Payload-Felder auf:

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

Die Locale bleibt im PHP-Code ein Enum und wird bei der Ausgabe zur Zeichenkette. Eine zusätzliche Property im DTO wird nicht automatisch Teil des Payloads: Dafür muss diese Methode geändert werden.

Der Blog verwendet dasselbe Prinzip für unterschiedliche Ziele. BlogPostData liefert eine Zusammenfassung ohne HTML für die Übersicht, den vollständigen Artikel für seine Seite und ein Array serialisierbarer Werte für den kompilierten Katalog und den lokalen Cache. Das DTO transportiert diese Ergebnisse; das Markdown-Rendering übernimmt eine Action.

Das HTTP-Gateway des Moduls Cloud

Anwendungsaufrufe an aubia.cloud laufen über CloudApiClient. Zwei Klassen verwenden ihn: die Action für die Übermittlung und die Query für den Zähler, beide in Waitlist. Die öffentliche Website hat keine lokale Datenbank für Anmeldungen.

Die Action bereitet den JSON-Body und seine Signatur vor. Der HTTP-Client sendet die übergebenen Bytes und kümmert sich um Timeouts, Wiederholungsversuche und Fehlerprotokolle. Diese Protokolle erfassen den Fehlertyp, ohne die E-Mail-Adresse oder den gesendeten Body zu speichern.

Ein GET kann nach einem Verbindungsfehler oder einer 5xx-Antwort wiederholt werden, aber nicht nach einer 4xx-Antwort. Der signierte POST wird nur nach einem Verbindungsfehler wiederholt. Dieser Fehler beweist nicht, dass der entfernte Dienst nichts verarbeitet hat: Die Antwort kann ausbleiben, obwohl die Anfrage eingegangen ist.

CloudApiClient ist eine final-Klasse ohne Instanz-Properties. Ihre Tests verwenden Http::fake(), um Antworten und Netzwerkfehler zu simulieren, während der echte Anwendungsclient ausgeführt wird.

Was readonly unter Octane nicht garantiert

Actions, Queries und DTOs sind als final readonly deklariert. final verhindert Vererbung. readonly verhindert, dass Properties nach ihrer Initialisierung neu zugewiesen werden, macht ein darin gespeichertes Objekt aber nicht unveränderlich.

ListBlogPostsQuery speichert ihre Konstruktor-Abhängigkeiten, aber keine Artikel-Collections. Die Leseergebnisse bleiben in den lokalen Variablen ihrer Methoden. Der kompilierte Katalog auf dem Datenträger ist vom Redis-Cache des lokalen Parsing-Modus getrennt.

Unter Octane bleibt Laravel zwischen Requests geladen. Das Schlüsselwort readonly bestimmt nicht die Lebensdauer einer Instanz. Das Repo registriert diese Klassen nicht als Singletons; eine Registrierung als gemeinsam genutzte Dienste würde eine Prüfung ihres Zustands und ihrer Abhängigkeiten erfordern. Die Octane-Dokumentation beschreibt die Risiken, wenn ein Dienst einen HTTP-Request bis zum nächsten Request speichert.

Abhängigkeiten brauchen weiterhin ein Review

Die Sitemap verwendet ListBlogPostsQuery, um die veröffentlichten Artikel zu ermitteln. Eine Änderung in Blog kann daher die Ausgabe von Seo verändern, auch wenn der Pull Request keine Datei in Seo ändert.

Architekturtests prüfen die Suffixe, die Deklarationen als final readonly und die öffentlichen Methoden execute() oder run(). Sie verbieten keine Imports zwischen Modulen. Ein Agent kann in einem korrekt benannten Ordner eine unpassende Abhängigkeit einführen.

Diese Struktur bringt zusätzliche Klassen mit sich und verlangt beim Nachvollziehen einer Operation Wechsel zwischen Dateien. Für eine öffentliche Website dieser Größe ist das ein tatsächlicher Aufwand. Ich behalte diese Organisation bei, um Validierung, Orchestrierung und Ausgabedaten an festen Stellen zu finden. Im Review muss ich ihre Abhängigkeiten weiterhin verfolgen und das Verhalten mit Tests prüfen.

Dieses Projekt wird hier erzählt, Artikel für Artikel. Was als Nächstes kommt, hängt davon ab, was Sie dazu sagen.

Auf die Warteliste