Laravel 5.0 arrived in February and the upgrade guide is short, which is misleading. Almost nothing about the framework’s behaviour changed; almost everything about where code lives did. Treating it as a version bump produces a week of confusion, so it is better approached as a move — and moves go better with an order.
The symptom
The first attempt was a fresh 5.0 skeleton with the old app/ directory copied in. It did not boot, and the error was not informative:
$ php artisan list
PHP Fatal error: Class 'AppHttpKernel' not found in
/var/www/shop/bootstrap/app.php on line 28The old application had no App namespace, no Http directory and no HTTP kernel, because in 4.2 none of those existed. Every one of the twenty errors after that one had the same root cause.
Why it happens
4.2 owned the structure: app/models, app/controllers, app/start/global.php, all autoloaded by a classmap over app/, none of it namespaced. 5.0 hands that back and expects PSR-4 under a single application namespace, with the framework’s own entry points as classes you own rather than files it includes.
Configuration moved as well. 4.2 selected a config directory by environment name; 5.0 reads a .env file and expects env() calls to appear in config files and nowhere else. The second half of that sentence is the part that catches people, and it is covered below.
The fix
Namespace the old application first, on 4.2
The single most useful decision was to do the namespacing before touching the framework version. 4.2 is perfectly happy with PSR-4 and an App namespace, so this step is testable against a working application.
{
"autoload": {
"psr-4": { "App\": "app/" },
"classmap": [ "app/commands", "app/database/seeds" ]
}
}
Models move to app/Models with a namespace AppModels; line, controllers to app/Http/Controllers, and every reference in the route file gets its full name. The application still runs on 4.2 at the end of this, which means it can be shipped, and the risky part is now much smaller.
Then swap the skeleton
With the application namespaced, the 5.0 skeleton has somewhere to put it. Take the new bootstrap/, public/index.php, config/ and the two kernels, and bring the old code across into the structure they expect.
app/
├── Console/Kernel.php # replaces app/start/artisan.php
├── Exceptions/Handler.php # replaces App::error() in global.php
├── Http/
│ ├── Kernel.php # global middleware, replaces filters.php
│ ├── Controllers/
│ ├── Middleware/
│ └── Requests/ # new: validation as a class
├── Models/
└── Providers/
Filters become middleware, and that is the conversion with the most code in it. A 4.2 filter was a closure registered by name; 5.0 middleware is a class with a handle() method that receives the next handler, which means it can act after the response as well as before it.
// 4.2
Route::filter('auth.shop', function () {
if (! Auth::check()) return Redirect::guest('login');
});
// 5.0
final class AuthenticateShop
{
public function handle($request, Closure $next)
{
if (! Auth::check()) {
return redirect()->guest('login');
}
return $next($request);
}
}
Configuration and the env() trap
Moving settings into .env is straightforward. The trap is calling env() from application code rather than from a config file, which works in development and returns null in production the moment anyone runs the config cache.
// config/services.php — the only correct place for env()
return [
'gateway' => [
'key' => env('GATEWAY_KEY'),
'secret' => env('GATEWAY_SECRET'),
],
];
// anywhere else in the application
config('services.gateway.key');
Caveat
php artisan config:cache compiles every config file into one array and the .env file is then never read again. Any env() call outside config/ returns null from that point on — and because the cache is usually only run in production, this fails exclusively where it hurts most.
Form requests are worth adopting immediately
Most of the controllers had fifteen lines of validation at the top followed by the actual work. 5.0 moves that into a class that validates before the controller method is entered.
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('place-orders');
}
public function rules(): array
{
return [
'sku' => 'required|exists:products,sku',
'quantity' => 'required|integer|min:1|max:99',
];
}
}
public function store(StoreOrderRequest $request)
{
// reached only if validation and authorisation both passed
}
The controller shrinks to the operation, and the rules become a thing that can be read on its own. authorize() returning false produces a 403 before validation runs at all, which puts the two checks in the right order for free.
Where the old start files went
The one remaining question after the structural move is where the loose bootstrap code should live — the view composers, the custom validators, the two macros that were in app/start/global.php because there was nowhere else.
final class AppServiceProvider extends ServiceProvider
{
public function register()
{
// bindings only. nothing here may resolve another service:
// the container is still being built.
$this->app->bind(Catalogue::class, DatabaseCatalogue::class);
}
public function boot()
{
// everything else — every provider has registered by now
Validator::extend('sku', SkuValidator::class . '@validate');
View::composer('partials.cart', CartComposer::class);
}
}
The register/boot split is the part that catches people: resolving anything out of the container during register() works right up until another provider has not been registered yet, and then fails in an order-dependent way that is miserable to debug. Bindings in register, everything else in boot.
Verifying it worked
The route list is the cheapest structural diff available: if a route disappeared or changed its action during the move, it shows up here rather than in production.
$ php artisan route:list > routes-after.txt
$ diff routes-before.txt routes-after.txt
$ vendor/bin/phpunit
OK (147 tests, 412 assertions)One route did change: a controller had been referenced by an unnamespaced string in 4.2 and had silently resolved to the wrong class after the move. The tests did not catch it because nothing tested that endpoint. The diff did.
What this costs
Every tutorial, answer and blog post written before February now describes a framework the application no longer resembles, and the two versions look similar enough that the wrong answer is easy to follow a long way. Expect to check the version on everything for the next year.
The upgrade is also all-or-nothing per application — there is no incremental path once the skeleton is swapped. Doing the namespacing separately, on 4.2, is what makes the irreversible step small enough to review.
Tip
5.1 arrived in June and is the LTS release, with three years of security fixes rather than six months. If this migration has not started yet, go from 4.2 to 5.1 directly — the structural work is identical and the support window is very different.