Laerx: Laravel 10 to 13 on PHP 8.4, three versions in three steps
Laerx is an open-source course and exam system for driving schools, MIT licensed, still on Laravel 10. I upgraded it the same way I upgrade client apps: smoke tests first, one major version per step, existing tests passing, and upgrade notes at the end.
Laravel 10.48.20 to 13.39.0, PHP 8.3 to 8.4.27
tests passing, up from 4 with 1 failing
problems fixed: a silent date bug, a crash and an abandoned package
files changed outside composer.lock
This was not a client project. I picked a public app so the work, the tests and the notes can be shown in full. The Laerx maintainers did not ask for it.
The app
Laerx is built by Codenteq. It has admin, manager, teacher and student areas: question banks, online exams, results, live lessons, driving appointments, subscriptions paid through iyzico, Firebase push messages, and Excel import and export of students.
| Size | 165 PHP files in app/, about 8,400 lines; 42 migrations; 258 routes |
|---|---|
| Structure | Pre-Laravel 11 layout: Http/Kernel.php, providers listed in config/app.php, RouteServiceProvider |
| Packages | Laravel Excel, Intervention Image, larafirebase, EloquentFilter, laravel/ui, iyzipay, debugbar |
| Tests before | 4 tests, one of them failing on Laravel 10 already |
How it went, step by step
- Smoke tests on Laravel 10. Before changing anything I wrote 11 smoke tests: log in, wrong password, admin pages, manager pages, the unpaid-invoice gate, Excel export, creating a question, student pages, the city lookup and image conversion to webp. All 11 passed on Laravel 10. I also fixed the one existing test that already failed (it expected
/to return 200, but the app redirects it to the login page). - Laravel 10 to 11, PHP 8.4. Framework, PHPUnit 10 to 11, Collision 7 to 8, and larafirebase to the first release that allows Laravel 11. Carbon 2 to 3 came with the framework and broke one helper without an error (see below).
- Laravel 11 to 12. Framework only. I checked the 12.x changes against the app (local disk root, image validation, UUID models) and none applied, so no code changed.
- Laravel 12 to 13. The big step for packages: Laravel Excel 3.1 to 4.0, Intervention Image 2 to 4, Tinker 2 to 3, debugbar 3 to 4, PHPUnit 11 to 12. Two more smoke tests went in here and were also run against the Laravel 12 commit, where they pass.
- Notes. An
UPGRADE-NOTES.mdin the repository: what changed, what was left alone on purpose, what to watch on deploy, and what I couldn't test.
Each step is its own commit, so the history can be reviewed in pieces: the smoke tests, the example-test fix, one commit per Laravel version, the PHPUnit config, and the notes.
What broke, and what had to go
Days left on a plan went negative
Carbon 3 arrived with Laravel 11. A helper used $end->diffInDays(now()) to show schools how many days their subscription has left. Carbon 2 returned a positive whole number; Carbon 3 returns a signed float. Nothing threw an error.
Fix: ask Carbon for the absolute value. A smoke test now pins it.
Student import from Excel crashed
Laravel Excel 4 adds native return types to its interfaces. The app's import class had none, so PHP refused to load it and the student import page returned a fatal error. The export kept working, which is why a test of the export alone would not have caught it.
Fix: one return type. Behaviour is unchanged.
An abandoned image library
Intervention Image 2 has had no release since May 2022, and the static Image::make() call the app used doesn't exist in versions 3 and 4. Uploaded photos, question images and school logos are all converted to webp through it.
Rewrote the converter for the v4 API, kept webp quality at 90 (the v2 default) and the GD or Imagick choice from config.
Checked, and left alone on purpose
- The app's CSRF middleware still extends Laravel's old class name. Laravel 13 keeps it as a deprecated alias, and the payment provider's callback stays excluded from the CSRF check. A test proves it.
- The cache config has no
serializable_classeskey, so cached models load as before. Copying Laravel 13's default into the file would break them; the notes say so. - The old app structure stays. Laravel 13 still supports it, and moving to the new skeleton is a separate change.
Package changes
| Package | Before | After | |
|---|---|---|---|
| laravel/framework | 10.48.20 | 13.39.0 | 3 majors |
| maatwebsite/excel | 3.1.58 | 4.0.6 | major |
| phpoffice/phpspreadsheet | 1.29.1 | 5.10.0 | via Excel 4 |
| intervention/image | 2.7.2 | 4.4.1 | abandoned, replaced |
| nesbot/carbon | 2.72.5 | 3.15.1 | major |
| laravel/tinker | 2.9.0 | 3.0.2 | major |
| barryvdh/laravel-debugbar | 3.13.5 | 4.4.5 | major |
| phpunit/phpunit | 10.5.33 | 12.5.42 | 2 majors |
| kutia-software-company/larafirebase | 1.3.8 | 1.3.9 | minor |
| tucker-eric/eloquentfilter | 3.4.0 | 3.6.0 | minor |
| laravel/ui | 4.5.2 | 4.6.3 | minor |
Test results
| Run | Laravel | PHP | Result |
|---|---|---|---|
| Existing tests, untouched | 10.48.20 | 8.3.6 | 4 tests, 1 failing (already broken before the upgrade) |
| With smoke tests and the example-test fix | 10.48.20 | 8.3.6 | 15 tests passing |
| Step 1 | 11.57.1 | 8.4.27 | 16 passing, after the Carbon fix |
| Step 2 | 12.70.2 | 8.4.27 | 16 passing |
| Step 3 | 13.39.0 | 8.4.27 | 18 passing, 47 assertions, no deprecations |
| Step 3, routes cached | 13.39.0 | 8.4.27 | 14 smoke tests passing |
Not tested
- MySQL. Tests ran on SQLite; the migrations ran unchanged, but nothing was checked against MySQL or MariaDB.
- The front end. The npm build was not run or changed.
- Live payments, push messages and email. They need real credentials; only the payment callback's CSRF exclusion is tested.
- Pages outside the smoke list, and the Mebbis import format.
Time and what it says about pricing
The upgrade itself, from the first test run on Laravel 10 to the final passing run on 13, took about 15 minutes of wall-clock time with AI coding tools. Setting up PHP 8.4 on the machine and writing the notes and this page came on top.
Laerx is a small app with few tests, no staging server and no client to agree flows with. A client app of the same size usually takes longer: access, a database that runs, agreeing the smoke flows, and a deploy plan. A three-version upgrade like this one sits in the multi-version catch-up tier.
The code
The upgrade branch is public: qiyuanlabs-ai/laerx, branch upgrade/laravel-13. It has seven commits on top of Laerx's master: smoke tests, the example-test fix, one commit per Laravel version, the PHPUnit config, and the upgrade notes in UPGRADE-NOTES.md.
Is your app still on Laravel 10 or 11?
Tell me your Laravel and PHP versions and what the app does. I'll reply with what an Upgrade Audit would cover.
Email hello@qiyuanlabs.ai Laravel upgrade pricing