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.
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.
| Before | After | |
|---|---|---|
| PHP | 7.4 in our container, 7.2 in production | 8.4.27 |
| Framework | Laravel 7.20.0 | Laravel 13.38.0 |
| Known security advisories against the framework | 5 | 0 |
| Abandoned direct dependencies | 3 | 0 |
| Automated tests | 2, both the examples a new Laravel install ships with | 60, every reachable endpoint |
| Front-end toolchain | Laravel Mix 5, Vue 2, about 900 npm packages | none needed |
The only edit inside a controller was one extra argument to a json_decode call. No business logic changed at all.
| What the diff contains | Files | Lines |
|---|---|---|
| Everything, before tag to after tag | 82 | +7,367 / −2,784 |
| Application code | 38 | +424 / −270 |
| Application code, behaviour only, excluding formatting and deletions | 11 | +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.
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.
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.
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.
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 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.
Two independent kinds of evidence, because a passing test suite on its own is a claim about code we wrote.
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.
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.
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.
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