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.