The Composer half of the upgrade took eleven minutes. Deciding what to do with app/Http/Kernel.php took the rest of March, and I am still not certain we decided correctly.
The app is our internal operations tool: dispatch, support and finance are in it every working day, and it has been Laravel since 2020. It went 9 to 10 last year and we moved it to 11 in the last week of March, about two weeks after the release on 12 March. The upgrade guide puts the estimate at fifteen minutes. That number is honest about the framework and says nothing about the actual work, because the headline change in Laravel 11 is a decision the release deliberately refuses to make for you.
What the release actually forces
PHP 8.2 is the floor. We had been on 8.2 since last year so that cost us nothing, and the upgrade guide lists the rest: laravel/framework to ^11.0, nunomaduro/collision to ^8.1, Telescope to ^5.0, and curl 7.34 or newer for the HTTP client. The one that caught us was migrations. Telescope, Sanctum, Passport, Cashier and Spark no longer load migrations from their own package directories, so you publish them into your app:
composer require laravel/framework:^11.0 laravel/telescope:^5.0 nunomaduro/collision:^8.1
composer remove doctrine/dbal
php artisan vendor:publish --tag=telescope-migrations
We only run Telescope on staging, so this was a ten minute detour. If you have Passport in production and you deploy with migrate --force, read that section twice before you ship.
The two files that are not there any more
A new Laravel 11 app has no app/Http/Kernel.php and no app/Console/Kernel.php. Middleware, exception handling, routing and providers are configured in bootstrap/app.php, which is now a builder rather than a wiring file:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
The rest of the skeleton shrank to match. The 11.x application repo ships one service provider instead of five, with the list living in bootstrap/providers.php; routes/ holds only web.php and console.php, with api.php and channels.php created on demand by install:api and install:broadcasting; config/ is down to ten files and you pull the others back with php artisan config:publish. The nine default middleware moved into the framework and are configured through methods on the Middleware object: validateCsrfTokens(except: [...]), trustProxies(), alias(), priority(). Scheduled tasks are declared with the Schedule facade in routes/console.php. The base controller is an abstract class with nothing in it.
Now the part that took three weeks. Upgrading does not move you to any of this. The upgrade guide is blunt about it: they do not recommend that a Laravel 10 app migrate its structure, and 11 was tuned to keep supporting the old one. There is even a seam in the builder, withKernels(), which binds the framework’s own HTTP and console kernels into the container for apps living between the two shapes.
Our Http/Kernel.php is 140 lines. Fourteen route middleware aliases, a global request-ID middleware, and a $middlewarePriority array that exists because our branch-scoping middleware has to run after StartSession and before Authenticate, a fact discovered in 2022 by a support engineer who could see the wrong branch’s shipments. All of that is expressible in bootstrap/app.php. I spent a Saturday proving it on a branch. Then I threw the branch away.
The reasons, in order of how much they mattered: the diff was around 600 lines across nine files and nobody on the team could review it by eye; the runtime benefit is zero; and git blame on those middleware lines is the only documentation of why the priority array looks like that. What we gained would have been aesthetic. What we risked was a middleware ordering bug in an app that finance uses to close the month.
I do like the new shape. I started a client project on a fresh 11 install in April and it is better there: one provider, two route files, and no config directory full of files nobody has read. That is exactly what the release notes claim, that this is for new applications.
600 a minute is not 20 a second
One of our courier partners posts scan events to /webhooks/partners/{code}/scans. Their sender is a cron worker, so when their side has a bad hour, we get the backlog in one burst. The limiter was Limit::perMinute(600), which is exactly what we asked for and not at all what we wanted: 600 requests arrived inside about three seconds on 2 April, queued 600 jobs, and the dispatch board’s p95 went from 180ms to 2.4s for four minutes while MySQL fought with itself.
Laravel 11 added per-second granularity to every limiter. Limit::perSecond($maxAttempts, $decaySeconds = 1), same builder methods as before:
use IlluminateCacheRateLimitingLimit;
use IlluminateHttpRequest;
use IlluminateSupportFacadesRateLimiter;
RateLimiter::for('partner-scans', function (Request $request) {
return Limit::perSecond(20)->by($request->route('code'));
});
Twenty a second is a higher ceiling than 600 a minute in the long run and a much lower one in the second that matters. Their sender now gets a 429 with headers and backs off, and we stopped absorbing other people’s cron schedules.
The same change has a quiet edge. Limit‘s constructor, GlobalLimit, and the ThrottlesExceptions queue middleware all take seconds now where they used to take minutes, and the decayMinutes property is decaySeconds. We had this on a job that talks to a partner tracking API:
-new ThrottlesExceptions(10, 5)
+new ThrottlesExceptions(10, 5 * 60)
Before the upgrade that meant five minutes. After it, five seconds. No error, no failing test, just a job that would have hammered a partner who was already down. It was caught in code review because someone had read the upgrade guide properly, which is not a process I would like to depend on again.
The one that threw
Laravel 11 drops doctrine/dbal, and with it every getDoctrine* method on the connection. We had a small command that dumps a schema document for the finance team’s data dictionary, written in 2021, and on the first staging deploy it did this:
Error: Call to undefined method IlluminateDatabaseMySqlConnection::getDoctrineSchemaManager()
at app/Console/Commands/ExportSchemaDoc.php:38
The replacement is better than what it replaced, which is a rare thing in an upgrade:
// before
$columns = DB::connection()->getDoctrineSchemaManager()->listTableColumns('shipments');
// after
$columns = Schema::getColumns('shipments');
$indexes = Schema::getIndexes('shipments');
$keys = Schema::getForeignKeys('shipments');
The uglier one was silent. The float column type was rewritten: it is float($column, $precision = 53) now, not float($column, $total, $places). PHP does not complain when you pass a third argument to a two-argument method, so a new migration written the old way produced float(8) on MySQL, a four-byte single-precision column, instead of float(8,2). We found it when a weight total across 1,204 rows came out 0.6kg off the old table. We have stopped using float for anything a human reads:
-$table->float('weight_kg', 8, 2);
+$table->decimal('weight_kg', total: 8, places: 2);
Read the “Modifying Columns” section of the upgrade guide too. change() no longer keeps modifiers you leave out, so an old change() migration re-run on a fresh database will quietly drop your unsigned, default and comment.
/up, and what it is actually doing
New apps get a health endpoint from the health: '/up' argument. Since we kept the old skeleton we do not have that argument, so I read what the framework does and wrote the same thing by hand: the built-in route is registered inside the web middleware group, dispatches a DiagnosingHealth event, and returns a small Blade view.
use IlluminateFoundationEventsDiagnosingHealth;
Route::get('/up', function () {
event(new DiagnosingHealth);
return response('ok');
});
Two things to know before you point a load balancer at it. It runs the web group, so it starts a session and touches your session store; it is a stack check, not a ping. And PreventRequestsDuringMaintenance is global middleware, so php artisan down makes /up return 503. We learned that on 8 May when every target in the group went unhealthy for ninety seconds. Our deploy script now skips down for migration-free releases.
SQLite by default, and a new hire’s first week
New installs now default to SQLite, with the database driver for session, cache and queue. Run migrations with no database file and you get a Prompts confirmation instead of a stack trace: The SQLite database configured for this application does not exist, then Would you like to create it?. For a fresh app that is a real improvement. You go from laravel new to a working app with no MySQL, no Redis, no Docker.
The dev who joined us on 3 June spent four days on his onboarding exercise this way and it went fine, right up to his first branch on the ops app, where nothing was configured the way he had learned. MySQL, Redis queues, a session store shared between containers. He asked why his local queue worker had a jobs table. Fair question. The default is good for the first hour and teaches you nothing about the stack you are paid to work on, so new people now get our .env.example on day one. He also came close to running migrate:fresh against staging, which 11.9 would have blocked; the release added a guard that refuses destructive commands in production.
The smaller things we actually use
Encryption key rotation is the one I am most grateful for. APP_PREVIOUS_KEYS takes a comma-separated list; the current APP_KEY encrypts, and decryption falls back through the old keys until one works. We rotated our key on 21 May without logging everyone out, which was a first. 11.8 extended the same fallback to signed URL verification.
protected function casts(): arrayon models, instead of the$castsproperty. Worth checking you have no relationship namedcastsbefore you upgrade, because the base model defines that method now.withFakeQueueInteractions()on a job, thenassertReleased(delay: 30),assertFailed(),assertDeleted(). It replaced about 40 lines of hand-rolled fake in our webhook retry test.make:class,make:enum,make:interface,make:trait.Str::trim,Str::ltrimandStr::rtrimarrived in 11.2 and strip zero-width and BOM characters, not just ASCII whitespace. We use them on courier reference codes pasted out of Excel.
The once() helper deserves a warning. It memoizes for the duration of the request, and the cache key is built from the calling file, the class and function, the line number, and the closure’s captured variables, with the calling object tracked separately. So a closure that captures nothing is cached once per line per request, whatever you passed into the enclosing function. Capture what you key on.
On the first-party packages: Prompts is a separate package the framework depends on (laravel/prompts ^0.1.18 in the framework’s own composer.json), while Reverb, Pennant, Folio and Volt are things you install yourself. Reverb is the interesting one and we are not using it. It is a real WebSocket server with Redis pub/sub for horizontal scaling, and as I write this its newest tag is still a 1.0.0 beta. We poll for dispatch updates every ten seconds, which is ugly but understood.
So the ops app runs 11.9.2 with a 140-line kernel file the framework no longer has any opinion about, and the two structures will sit side by side here for a while: the customer platform on Symfony 7 since February, the ops app on a Laravel skeleton that new Laravel developers will not recognise. Bug fixes for 11.x stop on 3 September 2025. My guess is we move the kernels when the next major gives us a reason to open those files anyway, and if I am wrong about that, the priority array outlives all of us.
Sources
- Laravel 11 release notes: release date of 12 March 2024, the PHP 8.2 floor, the support table (bug fixes to 3 September 2025, security to 12 March 2026), the new structure, per-second rate limiting, health routing,
APP_PREVIOUS_KEYS, queue interaction testing,casts(),once(), the newmake:commands. - Upgrade guide, 10.x to 11.0: dependency versions, the explicit recommendation not to migrate application structure, package migration publishing, Doctrine DBAL removal, floating-point and
change()behaviour, and the seconds-not-minutes changes toLimitandThrottlesExceptions. - laravel/laravel at v11.0.2: the skeleton as shipped, including the ten remaining config files, the two route files,
bootstrap/providers.phpand the SQLite defaults in.env.example. - ApplicationBuilder.php:
withRouting‘s signature,withKernels(), and the health route registration that shows it runs in thewebgroup and dispatchesDiagnosingHealth. - Middleware.php: the configuration methods that replace the HTTP kernel, and the default global stack containing
PreventRequestsDuringMaintenance. - Limit.php:
perSecond, and the constructor now taking$decaySeconds. - laravel/framework CHANGELOG for 11.x:
Strtrim methods in 11.2.0, previous-keys support for signed URLs in 11.8.0, the destructive command guard in 11.9.0, and the 11.9.2 release date of 30 May 2024. - Onceable.php: exactly what
once()uses as a cache key. - MigrateCommand.php: the SQLite creation prompt and its wording.
- laravel/reverb tags: Reverb is a separate package and its newest tag is still a 1.0.0 beta.