Here's a question worth asking before you add anything to a shared package: does this behavior belong to every service, or does it belong to this service?
License creation belongs to api-license. LicenseManager, LicenseFacade, LicenseDTO, the Statamic driver underneath, none of it lives in api-infrastructure, and it shouldn't. A license is not a concern every API in this fleet has. Only one does. If LicenseManager migrated into the shared package "for consistency," every other service would compile a dependency on Statamic's licensing model into its build for no reason, and the day api-license needed to change how a license is issued, that change would have to go through the same coordination tax as a change to auth, for a concern only one consumer actually has.
The test is simple, and it's worth writing down because it's the one question that keeps a shared package from becoming a junk drawer: if you removed this code from the shared package and copy-pasted it into every consumer instead, would every consumer's copy end up identical? Auth passes that test. Rate limiting passes it. A retry-with-backoff HTTP client passes it. Business logic specific to one domain never does, because the moment you copy-paste it into a second consumer, it starts drifting to fit that consumer's actual problem, not the shared one. A shared package holds the code that's supposed to be identical everywhere. Anything that's supposed to differ by service doesn't belong there, no matter how "infrastructure-ish" it looks.
The package that must not become a service
Why call this a "shared package" instead of a "shared service"? Because the moment it starts acting like a service, holding its own state, listening on its own port, owning its own queue, it stops being something every app quietly inherits and starts being an undocumented dependency that nobody put on the architecture diagram.
Look at how request logging actually works. LogApiRequestsMiddleware ships with the package and runs inside whichever app's process handled the request:
// vendor/webplo/api-infrastructure/src/Middleware/LogApiRequestsMiddleware.php
public function handle(Request $request, Closure $next): Response
{
$startTime = microtime(true);
$response = $next($request);
defer(function () use ($request, $response, $startTime) {
$bearer = $request->attributes->get('bearer');
$sanitizedRequest = SensitiveDataSanitizer::sanitize($request->all());
$sanitizedResponse = SensitiveDataSanitizer::sanitize(ResponseParser::parse($response));
SendToLogsJob::dispatch(
user_id: $bearer?->id ?? 0,
method: $request->method(),
endpoint: $request->url(),
request: $sanitizedRequest,
response: $sanitizedResponse,
status: $response->getStatusCode(),
execution_time: microtime(true) - $startTime,
bearer_token: $bearer?->token,
// ...remaining named arguments (payload sizes, gzip sizes)
);
});
return $response;
}
Notice what this middleware does not do. It doesn't open a socket to a logging server. It doesn't buffer anything in its own memory. It sanitizes the request and response, SensitiveDataSanitizer scrubbing whatever shouldn't leave the process, then defers a job onto whichever queue connection the host app already has configured, using Laravel's own defer() so the response goes back to the client before any of this work runs. The job itself resolves its queue name from configuration instead of hardcoding one, so it always lands on whatever queue the host app has set aside for it:
// vendor/webplo/api-infrastructure/src/Jobs/SendToLogsJob.php
public function __construct(
public readonly int $user_id,
public readonly string $method,
public readonly string $endpoint,
// ...remaining constructor promoted properties
) {
$this->onQueue(config('webplo.queue', 'default'));
}
public function handle(): void
{
LogFacade::create(/* ...same parameters, forwarded */);
}
SendToLogsJob runs on the host app's own queue worker, the same worker process that runs every other job in that app. The package didn't stand up infrastructure to run it. It borrowed infrastructure that was already there. LogFacade::create() then hands off to a Manager-and-driver pair, the same Facade-over-Manager-over-driver shape this book used for LicenseManager in Chapter 2, which is the one place an actual HTTP call finally leaves the process, using the same SendsRequests trait every other outbound integration in this fleet uses.
That's lesson two: a shared library should stay a library. Its code executes on borrowed infrastructure, the host's process, the host's queue connection, the host's configured cache store, never infrastructure the package stood up for itself. The moment a "shared package" starts running its own worker, holding its own database connection, or exposing its own endpoint that every service quietly calls, it has become a service in every way that matters except the one that shows up on your architecture diagram or your on-call rotation. That's the worst kind of service to run: the invisible kind, the one nobody remembers depends on it being up, until it isn't.