A framework upgrade that was mostly deleting

Laravel 11 arrived in March with a substantially slimmer application skeleton: no HTTP kernel, no console kernel, no exception handler class, no middleware file. The upgrade does not require adopting any of it, which made adopting it a choice rather than a task.

The symptom

$ ls app/Http app/Console app/Exceptions
app/Console:     Kernel.php  Commands/
app/Exceptions:  Handler.php
app/Http:        Kernel.php  Controllers/  Middleware/

$ wc -l app/Http/Kernel.php app/Exceptions/Handler.php
   94 app/Http/Kernel.php
  188 app/Exceptions/Handler.php
  282 total

# 282 lines of framework structure, of which ours is
# maybe 60.

The exception handler had grown eleven cases over five years and the kernel carried a middleware ordering that had never been re-derived. Neither file was wrong; both were structure inherited from a skeleton generated in 2019.

Why it happens

A skeleton is a starting point and nobody goes back to it. An application generated in 2019 keeps 2019’s structure forever, and the framework’s own conventions move without it — which is fine until a new person joins and every tutorial describes a different layout.

The fix

Everything in one file

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->api(prepend: [EnsureApiVersion::class]);
        $middleware->alias([
            'subscribed' => EnsureSubscribed::class,
            'signed'     => ValidateSignature::class,
        ]);
        $middleware->trustProxies(at: '*');
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->dontReport(SupplierUnavailable::class);
        $exceptions->render(fn (ApiException $e) => $e->toProblemDetails());
    })
    ->create();

Fifty lines replacing two hundred and eighty-two, and the parts that are ours are now visible rather than embedded in framework scaffolding. That is the actual benefit: a reader can see what this application does differently from a default one, which was previously a diff against a skeleton nobody had.

The middleware order, which is not visible any more

public function testTheApiMiddlewareOrderIsUnchanged(): void
{
    $groups = $this->app->make(Router::class)->getMiddlewareGroups();

    self::assertSame([
        EnsureApiVersion::class,
        ThrottleRequests::class . ':api',
        SubstituteBindings::class,
    ], $groups['api']);
}

The order was an array literal and is now the result of several calls, so the only way to know the migration preserved it is to assert it. The order matters concretely — a throttle after route binding does a database lookup for a request it is about to reject — and the test is now the documentation of what it is supposed to be.

The exception handler, unpicked

eleven cases, examined:

  4  render a specific exception as JSON
     → $exceptions->render(), one line each
  3  suppress reporting for expected failures
     → $exceptions->dontReport()
  2  add context to the report
     → $exceptions->context()
  1  a rate limit on reporting a noisy exception
     → $exceptions->throttle()
  1  a case for an exception class deleted in 2022
     → removed

the last one had been dead for two years.

The dead case is the ordinary finding from any exercise that reads a file nobody has read in five years. The throttle case is the interesting one — it had been implemented by hand with a cache key and a counter, and there is now a method for it.

What we did not adopt

  the health route at /up
    duplicates ours at /health/deep, which checks the
    database and the cache. we kept both, because they
    answer different questions — the load balancer
    uses /up and cannot be allowed to remove an
    instance when the database blips.

  per-second rate limiting
    adopted, separately, and it changed behaviour —
    see the April note.

  the new default of one queue connection
    not adopted. two connections is deliberate here.

  Reverb
    no websockets in this application.

Verifying it worked

$ php artisan about --only=environment,drivers
  Laravel Version  11.0.7
  PHP Version      8.3.2

$ vendor/bin/phpunit
  Tests: 1,414 passed

$ ./bin/route-diff --before=routes-before.json
  routes: 188, identical
  middleware: identical for all 188

$ ./bin/response-times --sample=1000
  p50   38ms      # 39ms before
  p95  180ms      # 182ms before

$ git diff --stat main | tail -1
 9 files changed, 62 insertions(+), 341 deletions(-)

A route diff comparing the resolved middleware for every route is the assertion that matters here, because the failure mode of this migration is a middleware that quietly stopped applying to one group. Response times unchanged is the confirmation that nothing structural moved.

What this costs

A structure that differs from every tutorial written before March, which is the mirror image of the problem it solves. For a year the answer to “where is the middleware registered” is different depending on which article somebody read, and the application now matches the newer half.

The fluent configuration is also less greppable than an array literal. Finding out whether a middleware applies to a route used to be a matter of reading one file; it is now a matter of reading a builder and knowing what its defaults are — which is why the test asserting the resolved order is not optional.