Skip to main content
Laravel, shipping fast.

Chapter 15

A Fleet, Not a Monolith

Julian Beaujardin

Everything in this book so far describes one API. One bootstrap/app.php. One routes/api.php. One LicenseController talking to one Statamic driver behind one Facade. That's the correct way to build a service, and if you stopped here you'd already have something better than what most teams ship. But the Webplo License API doesn't run alone. It runs next to a billing surface, a server-provisioning surface, a repo and commit surface, an AI content surface, a DNS and CDN surface, and a customer-facing app that has to make all of it look like one product to a merchant who has never heard of any of those names and never should.

This chapter covers what changes when one well-built API becomes a dozen of them: the shape those dozen services take relative to each other, why exactly one of them is allowed to hold each outside vendor's credentials, why the code baked into something the fleet generates isn't the same thing as the fleet itself, and why a read and a write are allowed to travel completely different roads to reach the same data. A dozen good services arranged badly is still a bad system.

What Changes at a Dozen Services

Every pattern this book has taught so far, FormRequests, DTOs, Resources, Response Wrappers, Facades over Managers over Drivers, works the same whether you have one service or twelve. That's the point of a pattern: it doesn't care how many times you apply it. What doesn't survive the jump from one to a dozen is any assumption you were making about being alone.

At one service, "call the vendor" means one HTTP client, one config file, one retry strategy. At a dozen services, "call the vendor" is a decision with a topology attached to it: which service makes that call, who else is allowed to make it too, and what happens to everyone downstream when that vendor has a bad day. You can get every individual service architecturally right and still end up with a fleet that behaves unpredictably, because nobody decided how the services relate to each other. Consistency inside a service was Chapter 1's argument. This chapter is the same argument, one layer up: consistency between services.

The Star Topology

Here's the question a fleet forces you to answer early: when api-domain needs something that api-license knows, does it call api-license directly, or does it ask the service that's already talking to both of them?

This fleet answers it once, for everyone: through the hub. api-server is the single orchestrator that every multi-step, cross-service action runs through. api-license, api-git, api-domain, and api-ai are leaves. A leaf talks to its one external provider and it talks back to api-server. It does not talk to another leaf, ever, for any reason.

Do the arithmetic on why that rule earns its keep. Let five services call each other freely, and you're managing up to 5 × 4 = 20 possible directed call paths, twenty places a contract can drift, twenty places a timeout can cascade. Route every one of those calls through a single hub instead, and it collapses to 5 × 2 = 10, five leaves in, five leaves out, and every one of those edges looks identical: a leaf talking to the hub, using the same client shape, the same auth header, the same retry logic. That's not a minor tidiness win. It's the difference between a system a new engineer can draw on a whiteboard from memory and one they have to go read the source to understand.

A single write, say merchants asking for a new license, moves through the fleet in one direction:

merchants
api-server (the hub)
api-license
Statamic

One request creates a license. Another provisions a server. A third registers a domain. Each of those is its own version of the same shape: merchants asks the hub, the hub asks exactly one leaf, the leaf asks exactly one vendor. None of those requests ever needs api-license to know api-domain exists, because it doesn't need to. The hub already knows about both.

The cost of this shape is concentration, and the book shouldn't pretend otherwise. Every request that touches more than one capability now has to pass through api-server, which means api-server being down isn't a partial outage, it's the whole fleet being unreachable for anything that isn't a leaf serving a request entirely on its own. This fleet leans into that fact instead of hiding from it: api-server runs a circuit breaker per vendor, not per leaf, because the hub is the one place that actually needs to know when a dependency is unhealthy.

// app/Services/DependencyHealth.php
final class DependencyHealth
{
    /** Failures within the window that trip the breaker open. */
    public static int $failureThreshold;

    /** How long a recorded failure counts toward the threshold (seconds). */
    public static int $windowSeconds;

    public function recordFailure(Dependency $dependency): void
    {
        // ...enough failures inside the window trips this dependency down
    }

    public function isHealthy(Dependency $dependency): bool
    {
        // ...
    }
}

DependencyHealth lives in the hub, not in any leaf, because the hub is the only service positioned to see a dependency failing across every flow that touches it. Capability::CreateSite, from Chapter 13's Dependency enum, is only reported healthy when every Dependency it lists is healthy. The hub concentrates blast radius on purpose, then spends real code making sure it notices when that blast radius is on fire.

What the star topology rules out is a leaf reaching sideways for a shortcut. Here's what that would look like if a leaf decided it was faster to skip the hub:

// Bad: a leaf reaching sideways into another leaf instead of through the hub
final class DomainProvisionJob implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $siteId,
    ) {}

    public function handle(): void
    {
        $license = LicenseAPI::boot(config('services.license.key'))
            ->getLicenses();

        // now api-domain holds its own copy of a license-service token, its
        // own idea of that response shape, and a call path nobody drew on
        // the topology diagram
    }
}
// Good: api-domain never learns api-license exists
final class DomainProvisionJob implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $siteId,
        public array $licenseContext,
    ) {}

    public function handle(): void
    {
        // the hub already fetched this before dispatching the job;
        // api-domain just uses it
    }
}

The difference isn't a style preference. The bad version means api-domain now needs its own api-license credential, its own idea of that response shape, and it breaks in a way nobody watching api-server sees coming, because the failure happened on an edge that isn't supposed to exist. The good version means the hub, which already talked to api-license to get here, just hands the leaf what it needs. One fewer credential in the world. One fewer place a contract can drift out from under you.