Skip to main content

Devlog · Phase 0 · 5/7

The Five Laravel Modules Behind aubia.dev

aubia.dev groups backend code by responsibility. Actions, Queries and DTOs help trace a change, but directories alone cannot control dependencies.

Published on August 10, 2026Updated on September 15, 20266 min read
  • laravel
  • architecture
  • octane

The signup controller, its validation and the data sent to the cloud are all under app/Modules/Waitlist/. To trace a submission, I stay in that directory until the outgoing HTTP call.

I work with AI agents and review their pull requests. Grouping files by responsibility gives me a starting point: a form change leads me to Waitlist, a sitemap change to Seo. I still need the imports and the diff to understand what the change affects.

Grouping Code by Responsibility

Laravel provides a starting structure without requiring modules. On this public website, I use five directories under app/Modules/:

  • Blog reads Markdown articles, renders them as HTML and builds the RSS feed.
  • Cloud groups HTTP transport to api.aubia.cloud in CloudApiClient.
  • Locale selects the language and builds localized URLs.
  • Seo builds the sitemap from public pages and published articles.
  • Waitlist forwards signup requests and fetches the number of confirmed signups.

Each module has the subdirectories it needs. Cloud has just one class at its root. Blog also includes Artisan commands and adapters for the Markdown engine.

Simplified map of the five modules: Locale, Seo, Waitlist, Blog and Cloud. A flow connects Waitlist to Cloud, then to api.aubia.cloud.

This map shows the modules and their HTTP gateway. It doesn't show every dependency or subdirectory: Blog and Waitlist also have a Queries/ directory.

Actions and Queries

An Action performs an operation through an instance method named execute(). SubmitWaitlistEmailAction orchestrates signup; BuildSitemapAction builds the sitemap XML. A Query exposes a read through run(): ListBlogPostsQuery returns published articles, FindBlogPostQuery looks up an article and FetchWaitlistCountQuery reads the remote counter.

The distinction doesn't depend on where the data is stored. The blog reads local files, while the counter calls an API. Both reads are Queries.

Production articles are prepared at build time. CompileBlogAction calls ParseBlogPostAction, which validates sources and orchestrates Markdown rendering, Phiki highlighting and reading time estimation. ListBlogPostsQuery then reads the catalogue through ReadCompiledBlogQuery and filters publication dates. Parsing with caching remains available locally and in tests; in production, a missing, invalid or stale catalogue returns 503 without fallback parsing.

These names and entry points are repo conventions. Laravel can construct and inject concrete classes from their types; no additional Actions library is needed.

A Dependency Visible in the Constructor

The root / redirects to a localized URL with a 302 response. ResolveLocaleRedirectAction receives DetectLocaleAction through its constructor and passes it the candidates in priority order.

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

The first recognized candidate wins: the preference cookie, then the language extracted from the Accept-Language header, then the configuration. DetectLocaleAction returns a SupportedLocale, the enum of the six supported languages. Accessing ->value gives the string used in the URL.

The SetLocale middleware calls the same Action with the URL segment added at the front. The selection rule is shared, but the candidate list depends on the caller. The HTTP request is passed to execute(), without being stored in the constructor.

Data After Validation

The signup form goes through a FormRequest, Laravel's validation class. It also checks the local anti-bot protections, then selects the fields needed by WaitlistSubmissionData.

This DTO, or data transfer object, contains the email address, the three UTM attribution values and the locale. The anti-bot control fields aren't among its properties, so this object doesn't pass them to the signup Action.

The output method lists the payload intended for the 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,
    ];
}

The locale remains an enum in the PHP code and becomes a string on output. Adding a property to the DTO doesn't automatically add it to the payload: this method has to change.

The blog uses the same principle for different destinations. BlogPostData provides a summary without HTML for the index, the full article for its page and an array of serializable values for both the compiled catalogue and the local cache. The DTO carries those results; an Action handles Markdown rendering.

The Cloud Module's HTTP Gateway

Application calls to aubia.cloud go through CloudApiClient. Two classes use it: the submission Action and the counter Query, both in Waitlist. The public website has no local database for signups.

The Action prepares the JSON body and its signature. The HTTP client sends the supplied bytes and handles timeouts, retries and failure logs. Those logs record the error type without storing the email address or the body sent.

A GET can be retried after a connection error or a 5xx response, but not after a 4xx response. The signed POST is retried only after a connection error. That error doesn't prove the remote service processed nothing: the response may be missing even though the request was received.

CloudApiClient is a final class with no instance properties. Its tests use Http::fake() to simulate responses and network errors while running the real application client.

What readonly Doesn't Guarantee Under Octane

Actions, Queries and DTOs are declared final readonly. final prevents inheritance. readonly prevents properties from being reassigned after initialization, but doesn't make an object stored in them immutable.

ListBlogPostsQuery stores its constructor dependencies, but not article collections. Read results remain in its methods' local variables. The compiled catalogue on disk is separate from the Redis cache used by local parsing mode.

Under Octane, Laravel stays loaded between requests. The readonly keyword doesn't determine an instance's lifetime. The repo doesn't register these classes as singletons; registering them as shared services would require reviewing their state and dependencies. The Octane documentation describes the risks of retaining an HTTP request in a service from one request to the next.

Dependencies Still Need Review

The sitemap uses ListBlogPostsQuery to find the published articles. A change in Blog can therefore change the output of Seo, even if the pull request changes no files in Seo.

Architecture tests check suffixes, final readonly declarations and the public methods execute() or run(). They don't prohibit imports between modules. An agent can introduce a misplaced dependency in a correctly named directory.

This structure adds classes and requires switching between files to trace an operation. On a public website this size, that's a real cost. I keep this organization so I can find validation, orchestration and output data in consistent places. During review, I still have to trace their dependencies and check the behavior with tests.

This build is chronicled here, article after article. What comes next depends on what you have to say about it.

Join the waitlist