Case study · six framework versions, three PHP versions

Laravel 7 to Laravel 13, PHP 7.2 to 8.4: what it actually took

23 October 2026 · keepPhpUp · Our own application, migrated end to end before we quote this work to anyone else

We built a lead-tracking CRM back end in 2020, left it on Laravel 7 and PHP 7.2, and moved it to Laravel 13 and PHP 8.4 using exactly the process we sell. We did our own first, so the numbers here are measured rather than estimated.

This is our application, not a client's. We say so plainly because it matters when you read the numbers. The repository has a tag before the work and a tag after it, one commit per stage, and the saved output of every verification run. Nothing below is an estimate.

The application

A REST back end that a separate single-page app signs into. It lists leads by date range, records follow-up notes, manages customer accounts, and takes calls from CallRail through a webhook. About 4,700 lines of PHP across 39 application files, 14 database migrations and four route files. It is the subject of one of our sample assessment reports, which sized the job at eight working days.

 BeforeAfter
PHP7.4 in our container, 7.2 in production8.4.27
FrameworkLaravel 7.20.0Laravel 13.38.0
Known security advisories against the framework50
Abandoned direct dependencies30
Automated tests2, both the examples a new Laravel install ships with60, every reachable endpoint
Front-end toolchainLaravel Mix 5, Vue 2, about 900 npm packagesnone needed

The headline number

11
files changed to cross six major framework versions

The only edit inside a controller was one extra argument to a json_decode call. No business logic changed at all.

What the diff containsFilesLines
Everything, before tag to after tag82+7,367 / −2,784
Application code38+424 / −270
Application code, behaviour only, excluding formatting and deletions11+126 / −32

Most of those 7,367 added lines are the test suite, the verification tooling and the written record. That is the real shape of this work, and it is why we charge a fixed fee instead of by the hour. The risk in a migration is never the size of the change. It is not knowing what the application does before you touch it.

What the tests found before we changed anything

We write tests that pin current behaviour, quirks included, before the first change. On this application that exercise found seven defects nobody knew about, in code that had been running in production for five years.

  1. Both logout endpoints were broken. They called a method the user model never had. Every call was a server error. Nobody noticed, because the front end simply discards its session cookie.
  2. Leads arriving by webhook were invisible. The webhook never set the date field that every listing filters on, so a call that came in from CallRail could not be seen anywhere in the application.
  3. Follow-up notes needed no login. Anyone who could reach the server could write a note against any lead.
  4. "Yesterday" also returned today, because of an inclusive date boundary.
  5. "All time" meant the last 300 days. Older leads could not be seen at all.
  6. Any signed-in customer could edit another customer's lead by its id. There was no ownership check.
  7. Every page from the login scaffolding returned an error for a visitor who was not signed in.

We pinned all seven exactly as they were and quoted the repairs separately. Three of them are security issues. Fixing them inside the migration would have broken the only promise that matters during one, which is that nothing else changed. The migration did not address them, and we put that in writing rather than quietly bundling it in.

The one thing that genuinely broke

Symfony 6, which arrives with Laravel 9, stopped allowing objects to be written into the request data. Two places did exactly that, so the lead import and the CallRail webhook both returned server errors. Nine of the twelve test failures at that step came from this single line. That was the entire extent of application breakage across six major versions.

Two smaller things were changes rather than breaks. Laravel 9 reworded the summary line of every validation error, which the front end may display, so we restored the original wording and labelled it in the code as a deliberate stopgap to remove once someone has checked. And Laravel 13 now accepts a same-origin form post with no CSRF token, where earlier versions refused it; that loosens the application rather than breaking it, but it went into the handover note.

Something worth checking in your own project

Laravel 10 and 11 cannot be installed at all. Composer refuses every release of both, 10.0 through 10.50 and 11.0 through 11.57, because all of them carry security advisories with no patched release in that line. The earliest installable Laravel is 12.69.

That has a practical consequence for anyone being quoted this work: nobody can park your application on Laravel 10 or 11, even temporarily, because a clean checkout and composer install will fail. Any plan that offers to get you partway now and finish later cannot be delivered.

You can check your own position in one command, in your project directory:

composer audit

If it lists advisories against laravel/framework with no fixed version available to you, you are in the same position this application was in.

Laravel 13's new security check, and why tests cannot settle it

Laravel 13 added a check on a header the browser sends describing where a request came from. It was the single biggest risk in this job, because the front end sits on a different domain. While verifying it we found something that applies to every Laravel application: the framework switches that entire protection off while tests are running. A test suite therefore cannot prove it is safe, no matter how thorough.

So we proved the real behaviour instead, by driving a running server over HTTP the way a browser does: fetch the token, sign in from the other domain, read the API with the session cookie, then confirm an unauthorised write is still refused. All four pass. We also added a test that fails loudly if anyone ever disables that coverage again.

How we proved nothing changed

Two independent kinds of evidence, because a passing test suite on its own is a claim about code we wrote.

From the inside

60 tests, 229 assertions, green on the new stack. Not one assertion in the original 53 was edited during the migration. When a test fails, we fix the application, not the test.

From the outside

Both versions of the application running at the same time, the old one on PHP 7.4 and Laravel 7.20, the new one on PHP 8.4 and Laravel 13.38, each loaded with the same data. A script asks both the same 38 questions: seven as an anonymous visitor, twenty as a signed-in customer including all nine date ranges, and eleven that write data. It compares the status code and the response body of every one.

The result was no unexplained difference. Where a difference is unavoidable, such as a timestamp, the report prints the adjustment it made before comparing, so nothing is hidden behind a green tick.

We also found two bugs in our own comparison tool, both by refusing to accept its first clean result. One of them was skipping the nine date-range comparisons entirely. We mention it because the point of the exercise is to distrust your own green ticks.

What it cost

Three hours and twenty-five minutes of engineering across the eight stages, for an application our own assessment had sized at eight working days. Reviewing the work is a separate two to three hours and does not shrink.

Two honest cautions about that number. This application has no payment provider, no PDF generation, no queue workers and no scheduled jobs, and those are exactly the areas where pinning behaviour stops being cheap, because the test fixtures have to be built against a real provider in test mode. And it is a conventional Laravel application; a CodeIgniter or CakePHP codebase of the same size is a different job. Our second sample report covers a 43,500-line ticketing platform with Stripe payments and PDF tickets, which is a six-week engagement.

What you would get

Find out where your application stands

A 48-hour exposure check is $750, credited against the migration. It confirms your real position in writing and ends with a fixed quote for the move.

Book an exposure check Run the free check first