Assembling an API skeleton from packages you did not write

The fourth service that year started the same way as the first three: four days of deciding how authentication works, what an error looks like, where validation lives and how logging is configured — none of which is the thing anybody was asked to build. A micro-framework means those decisions are yours, including the boring ones, and making them freshly each time is not a virtue.

The symptom

$ composer create-project laravel/lumen svc-pricing
$ cd svc-pricing && php -S localhost:8000 -t public

$ curl -s localhost:8000/nope | head -3
<!DOCTYPE html>
<html>
    <head><title>Not Found</title>

# an HTML error page from a service that only speaks JSON,
# on day one, before anybody has written anything.

That is the shape of the whole problem. Nothing is wrong — the framework has no way to know this is an API — and every service repeats the same four days of establishing that it is.

Why it happens

Micro means the framework declines to decide, and the decisions it declines are almost all cross-cutting: error shape, authentication, validation responses, CORS, logging format, health endpoint. Each is small. Together they are the difference between a routing library and something you can put in front of a mobile client.

The reason they get redecided is that they are invisible in a finished service. Reading an existing project tells you what was chosen and not why, so the next service either copies without understanding or starts over. A starter pack is the artefact that makes the choice explicit and reusable.

The fix

What goes in, and the rule for what does not

in    auth middleware, the error handler and its shape,
      request validation with a JSON 422, CORS done once,
      JSON logging with a correlation id, a real health
      endpoint, a Makefile and a docker-compose.yml

out   anything domain-specific, a schema, an ORM choice,
      and any opinion that cannot be removed in ten minutes

The rule that keeps it useful is the last line: everything in the skeleton has to be removable. A starter pack that cannot be partially rejected becomes a framework, and a framework maintained by one team for four services is a bad trade against the one maintained by hundreds of people.

The error shape, decided once

// app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
    $status = $this->statusFor($e);

    return response()->json([
        'error' => [
            'code'    => $this->codeFor($e),      // 'validation.failed'
            'message' => $this->messageFor($e),   // human, may change
            'fields'  => $e instanceof ValidationException
                ? $e->errors()
                : null,
        ],
        'correlation_id' => $request->attributes->get('cid'),
    ], $status);
}

The stable code separate from the human message is the decision that pays for itself repeatedly — a client matching on the text breaks when somebody fixes a typo. Returning the correlation id in the error body rather than only in a header means a support ticket can quote it from a screenshot, which is where most of them come from.

Lumen renders HTML by default for anything not caught, so the handler has to be the first thing configured. Getting this wrong produces a service that returns JSON when it works and HTML when it does not, which is the worst possible arrangement for a client trying to parse the response.

The configuration Lumen does not autoload

A config file dropped into config/ is simply not read, and config('services.gateway.url') returns null with no error. This costs an hour the first time and is deliberate rather than an oversight.

// bootstrap/app.php
$app->configure('app');
$app->configure('services');
$app->configure('queue');
$app->configure('logging');

// and the guard that makes the omission loud rather than null
function config_required(string $key)
{
    $value = config($key);

    if ($value === null) {
        throw new RuntimeException("missing config: {$key}");
    }

    return $value;
}

Loading nine config files per request is measurable overhead on a framework whose entire pitch is not doing work you did not ask for, so the design is defensible. The practical consequence is that adding a config file is a two-step operation forever, and the second step has no failure mode other than a null appearing somewhere unrelated. Wrapping the reads that matter is four lines and turns it into an exception at boot.

Facades and Eloquent, and whether to switch them on

// bootstrap/app.php — both commented out in a fresh project
// $app->withFacades();
// $app->withEloquent();

// with facades off, dependencies are explicit
final class PriceController
{
    public function __construct(
        PriceRepository $prices,
        LoggerInterface $logger
    ) { /* ... */ }
}

Leaving facades off is worth trying for at least one service, because it forces constructor injection and the resulting classes are testable without booting a framework at all. Eloquent is a different calculation: the alternative is writing queries against the connection directly, which is fine for a service with six tables and tedious beyond that.

The honest position is that switching both on turns Lumen into a slightly smaller Laravel, and at that point the question is what the micro-framework is earning. For a service that is genuinely six endpoints over a read model, quite a lot; for one that grows an admin interface and a queue and a scheduler, nothing — and that is the line worth naming rather than discovering.

When to stop being micro

signals that the service has outgrown it

  a scheduler; templates — any HTML at all
  a queue with more than one job type
  three or more people working on it at once

none fatal alone. two together is the framework
asking to be swapped.

Migrating from Lumen to Laravel is not dramatic — the container, the routing and most of the packages are shared — but it is a day nobody plans for and it always happens under deadline pressure. Naming the threshold in the starter pack’s README means the conversation happens before the deadline rather than during it.

Verifying it worked

$ composer create-project internal/api-skeleton svc-pricing
$ cd svc-pricing && make up

$ curl -s localhost:8000/health | jq .
{ "status": "ok", "checks": { "mysql": "ok", "redis": "ok" } }

$ curl -s localhost:8000/nope | jq .
{ "error": { "code": "not_found", "message": "Not Found" },
  "correlation_id": "9c1f..." }

$ curl -s -XPOST localhost:8000/prices -d '{}' | jq -r .error.code
validation.failed

# clone to first real endpoint: 18 minutes

The health endpoint checking its dependencies rather than returning a constant is the part most likely to be cut and the part that earns the most — an endpoint that returns ok unconditionally is a load balancer keeping traffic on a container whose database connection died an hour ago.

What this costs

A skeleton is a dependency, and it rots faster than the framework it wraps. Six months after the fourth service, the first one is on a version of the error handler that has since been improved twice, and nothing propagates the improvement — a starter pack is copied rather than depended upon, which is what makes it removable and also what makes it diverge.

The alternative, publishing it as a package the services require, fixes the divergence and reintroduces the coupling: now an improvement to the error shape is a breaking change across four services with their own release cycles. Neither answer is clean. Copying and accepting the drift is the one I would defend for a small number of services, on the grounds that the divergence is visible and the coupling is not.