Dragging a Laravel 5.7 App to Laravel 12
by G. Forrest
I wrote a blog in 2018 on Laravel 5.7, PHP 7.1 and MySQL, hosted on Heroku. When Heroku retired its free tier in 2022 I didn't migrate the database in time and lost every post — which is part of why I let it sit.
I'd upgraded it to Laravel 9 at some point along the way. This year I decided to take it the rest of the distance.
First step was the easy part - updating my composer.json with laravel/framework: ^12.0,
PHP 8.2, the modern skeleton with bootstrap/app.php and no Kernel.php. I installes Fortify to replace my custom auth. By any measure the framework
upgrade was finished. However, the app couldn't render a single page.
That gap is what this post is about. Upgrading the Laravel version is mostly a composer.json
edit. Upgrading the code you wrote against Laravel is the actual work, and almost
none of it announces itself.
Nothing failed loudly.
The exception messages I encountered at first were a bit misleading and nothing failed loudly. That's the thread running through all of it. Seven years of breaking changes had accumulated, and not one of them produced a clear error at the point of the mistake. Here's what I mean.
Laravel 8 moved models from App\ to App\Models\. Model references were strings, so they resolved at the wrong time. My relationships looked like this:
public function comments()
{
return $this->hasMany('App\Comment');
}
A string class name isn't resolved when the file loads, or when the app boots, or when the model is instantiated. It's resolved the moment something touches the relationship. So the app started fine, the routes registered fine, and the first page render died with Class "App\Comment" not found from somewhere deep in Eloquent.
User.php was half-converted — role() and photo() used ::class, posts() still used 'App\Post'. There was no way to tell by reading it, because both forms are valid PHP.
The fix is mechanical, and using ::class means a typo becomes a compile-time error instead:
public function comments()
{
return $this->hasMany(Comment::class);
}
While I was in there I found Category had no posts() relationship at all, despite a controller calling Category::with('posts'). That had presumably been broken since 2019.
Things the framework deleted
app/Http/Controllers/Auth/LoginController.php used Illuminate\Foundation\Auth\AuthenticatesUsers, which moved out of the framework into laravel/ui in Laravel 8. It also called $this->middleware('guest') in the constructor — removed in Laravel 11.
Because Fortify was already installed and registering /login and /register, those five controllers were unreachable dead code that would have fataled if anything had routed to them. Deleting the folder was the entire fix.
Similar smaller ones:
str_limit()becameStr::limit()in Laravel 6. Mine was commented out, so it never threw — it just meant my post excerpts rendered full articles.- String controller actions (
'action' => 'AdminPostsController@store') were removed in Laravel 8. Route::resourcestill registers all seven actions, so emptyshow()methods I'd never implemented rendered blank pages instead of 404s.
An abandoned package with 137 call sites 😓
Every form in the app used laravelcollective/html:
{!! Form::open(['method'=>'POST', 'action'=>'AdminPostsController@store', 'files' => true]) !!}
{!! Form::text('title', null, ['class'=>'form-control']) !!}
{!! Form::close() !!}
The package is abandoned and doesn't support Laravel 12. There are maintained community forks, and I looked at them — but every Form::open also used the string controller action removed in Laravel 8, so I had to touch all fifteen views regardless.
Given that, converting to plain Blade was barely more work than patching:
<form method="POST" action="{{ route('posts.store') }}" enctype="multipart/form-data">
@csrf
<input type="text" name="title" class="form-control" value="{{ old('title') }}">
</form>
And it removed a dependency that had already broken once and would break again.
The routes file had been gutted
routes/web.php had four routes. AdminPostsController, AdminUsersController, AdminCategoriesController, AdminMediaController and the rest all existed, fully implemented, and none of them were reachable.
I reconstructed the file by grepping the Blade templates for route() calls — the views are the specification for what routes must exist:
grep -rn "route('" resources/views | grep -o "route('[a-z.-]*'" | sort -u
That produced the list, and php artisan route:list --except-vendor verified it.
And one PHP fatal hiding underneath
PostCommentsController declared store() twice — once with (Request $request) and once with (Request $request, Post $post). Cannot redeclare is a compile-time error, so that controller could never load at all, no matter what else I fixed.
Two bugs that predated the upgrade
These weren't migration issues. They were always wrong, and modernising the surrounding code is what made them visible.
Uploads were writing to the wrong key. Every image upload did:
$file->store($name, 's3');
store()'s first argument is a directory, not a filename. It generates a random filename inside that directory. So uploading my-photo.jpg created an object at my-photo.jpg/8kJd93mFqL2.jpg — a folder named after the file — while the database recorded my-photo.jpg. The two never matched.
That explained a workaround I'd written years earlier and forgotten: the home page controller was calling ListObjects to enumerate the entire S3 bucket on every page load, then fuzzy-matching filenames with substr($file, 8) and str_replace('#', '%23') to find each post's image.
$file->storeAs('', $name, 's3');
storeAs writes to an exact key. Once the database value and the S3 key agree, the URL is a string concatenation and the whole matching apparatus disappears — along with a network round-trip per page view.
Migrations had inconsistent key types. posts.id was increments() (int) while users.id was id() (bigint), users.photo_id was a string holding a foreign key, and there was a migration targeting a table called category — singular, never existed — with an empty body, which had been silently passing for six years.
What I'd do differently
Read the upgrade guides for every version you're skipping, not just the target. The breaking changes that hurt were spread across 6, 8, 9 and 11. Jumping 5.7 → 12 means you inherit all of them at once with no signal about which is which.
Grep for removed APIs before running anything. AuthenticatesUsers, $this->middleware(, str_limit, Form::, 'Controller@method' — five greps would have produced the whole work list in two minutes, instead of discovering them one runtime error at a time.
Write the tests first. I added feature tests at the end, and every one of them encodes a bug I'd already fixed. Written first, they'd have been a to-do list.
Assume every silent success is a lie. The recurring theme: validated() returning an empty array because a FormRequest's rules were commented out. storeAs returning false because Laravel's S3 disk ships with 'throw' => false. MigrateAsync succeeding with no migrations present. A missing @csrf producing "419 Page Expired," which sounds like a session timeout and isn't. Every one of these looked like it worked.
Was it worth it?
For me, absolutely. The app does more than it did in 2018 — Markdown with syntax highlighting instead of TinyMCE text editor, S3 uploads that resolve without enumerating a bucket, real foreign key constraints, moderation that distinguishes admins from subscribers, and a test suite with CI.
But the honest answer is that a rewrite would have taken less time. What I got instead was a map of every bad decision I made in 2018, and enough distance to see why each one was bad.
The most rewarding part wasn't fixing it. It was recognising, line by line, exactly why past-me had done it that way — and knowing better. Rereading code you wrote seven years ago is an unusually direct way to measure what you've learned since.
The code is on GitHub if you want to see what any of this looks like in context.
About
I'm Gavin, a full-stack developer working mainly in
C# / ASP.NET Core / Azure and PHP/Laravel,
with SQL, Postgres, Docker and AWS underneath.
I write about the problems that don't have a clean answer online - things I've run into and my debugging paths that actually worked. I hope you find them useful. Thanks for visiting.