There is one place the hub rule bends, and it is worth naming so that it stays the only one.
A write changes state that other services depend on. If two writes race, or one applies halfway, somebody has to reconcile the result. So writes go through the hub, where there is one place to order them, retry them, and see them fail.
A read changes nothing. If the customer application shows a visitor count that is a second old because it asked the analytics service directly, nothing downstream broke. So a read may go straight to the service that owns the data.
The benefit of saying this out loud is that a reviewer needs no architecture diagram to decide where a new call belongs. They need one question answered: does it change anything? If it does, it goes through the hub. If it doesn’t, it may go direct.
You will be tempted to make exceptions, usually for an internal tool. When you do, write the exception down next to the rule. An unwritten exception is how “writes go through the hub” stops being true without anyone having decided it.
Sharing Code: A Package, the Laravel Way
A second service needs the same request log, the same trace ID, the same error codes, the same rate limiter. You can write that code again, or write it once and depend on it.
Laravel’s answer to “write it once” is a package: a Composer dependency with a service provider. It is what every first-party Laravel package is, including the ones this book has used. (Laravel documentation: Package Development.)
// src/ApiFoundationServiceProvider.php
class ApiFoundationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/api.php',
'api',
);
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../migrations');
$config = __DIR__.'/../config/api.php';
$this->publishes([
$config => config_path('api.php'),
], 'api-config');
}
}
The application doesn’t register the provider. The package names it in its own composer.json, under extra.laravel.providers, and Laravel discovers it on install. Adding the dependency is the whole integration.
Config is merged, not copied. mergeConfigFrom() gives every application the package’s defaults. An application that needs to differ publishes the file and changes one value. Nobody has to keep a full copy in step.
It is tested as a package. Orchestra Testbench boots a minimal Laravel application around your package inside its own test suite, so the shared middleware is proven before any service installs it.
Once Chapter 7’s trace middleware moved into the package, no other service had to write it. It arrives with the dependency, the way pagination arrives with Eloquent.
The principle underneath is that what must be identical everywhere should be inherited, not remembered. “Every service should log requests the same way, and every developer knows to copy the pattern” gets skipped somewhere, eventually, by someone new, under a deadline. Somebody will forget. A package decides whether forgetting is possible.
What Belongs in It
Issuing licenses belongs to the License API. The contract, the driver, the typed object: none of it goes in the shared package. Only one service has that concern. Moving it “for consistency” would give every other service a dependency on Statamic’s idea of a license, and every change to it would pay a coordination tax for a concern only one service has.
The test fits in a sentence, and it is what keeps a shared package from becoming a junk drawer: if you deleted this code from the package and pasted it into every service, would every copy stay identical?
Request logging passes. Trace IDs pass. The error codes pass. Business logic never does, because the moment you paste it into a second service it starts drifting to fit that service’s problem.
A Library Stays a Library
A package is code that runs inside each application’s own process. It is not something you deploy, and it can’t be called over the network.
Keep it that way. The request log from Chapter 6 writes to whatever database the host application already has, after the response has gone out. The package didn’t stand up infrastructure to run it. It borrowed what was there.
The moment a “shared package” runs its own worker, holds its own database connection, or exposes an endpoint every service calls, it has become a service in every way that matters except the one that shows up on your architecture diagram and your on-call rotation. That is the worst kind of service to run: the invisible kind.
The Price of a Shared Version
A fix in the package isn’t live anywhere until each service installs it. Tagging a version doesn’t do that. Every service has to move to the new version, and every service has to be verified on it, because “the package changed” and “every service that depends on it still works” are two different facts, and only the first is automatic.
So a one-line fix costs one release and then, for each service, an update, a static analysis run, and a test run. That is the honest price of guaranteeing that six services can never disagree unnoticed about how a request is logged.
Automate the update. A scheduled job that opens a pull request on each service when the package releases is an afternoon’s work. Never automate away the verification: the pull request merges because that service’s own pipeline from Chapter 14 passed against the new version, and for no other reason.
This is why the shared surface has to stay small. Every class you add to the package is a class every service verifies on every release, forever. A shared package earns its keep by holding the five or six things that truly must be identical.
Chapter 21 Summary
- Keep one application until a real limit forces a second. Don’t split to feel sophisticated.
- Decide how services relate. A hub with leaves keeps every call path the same shape.
- Each outside vendor has one owning service, and only the owner holds its credentials.
- Writes go through the hub. Reads may go direct. Write down every exception.
- Share code as a Laravel package: a discovered service provider, merged config, tests under Testbench.
- Share only what would stay identical if pasted into every service.
- A package runs on the host’s infrastructure. If it needs its own, it is a service, and should be treated as one.
- A new version isn’t live until each service has installed it and passed its own checks.