Case study · open-source app · November 2026

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.

10 → 13

Laravel 10.48.20 to 13.39.0, PHP 8.3 to 8.4.27

18

tests passing, up from 4 with 1 failing

3

problems fixed: a silent date bug, a crash and an abandoned package

9

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.

Size165 PHP files in app/, about 8,400 lines; 42 migrations; 258 routes
StructurePre-Laravel 11 layout: Http/Kernel.php, providers listed in config/app.php, RouteServiceProvider
PackagesLaravel Excel, Intervention Image, larafirebase, EloquentFilter, laravel/ui, iyzipay, debugbar
Tests before4 tests, one of them failing on Laravel 10 already

How it went, step by step

  1. 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).
  2. 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).
  3. 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.
  4. 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.
  5. Notes. An UPGRADE-NOTES.md in 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.

Laravel 11, before the fixPlan ending in 10 days showed −9
AfterShows 9, as on Laravel 10

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.

Laravel 13, before the fixFatal error on import
AfterStudents imported, linked to the school

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.

BeforeIntervention Image 2.7.2
AfterIntervention Image 4.4.1

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_classes key, 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

PackageBeforeAfter
laravel/framework10.48.2013.39.03 majors
maatwebsite/excel3.1.584.0.6major
phpoffice/phpspreadsheet1.29.15.10.0via Excel 4
intervention/image2.7.24.4.1abandoned, replaced
nesbot/carbon2.72.53.15.1major
laravel/tinker2.9.03.0.2major
barryvdh/laravel-debugbar3.13.54.4.5major
phpunit/phpunit10.5.3312.5.422 majors
kutia-software-company/larafirebase1.3.81.3.9minor
tucker-eric/eloquentfilter3.4.03.6.0minor
laravel/ui4.5.24.6.3minor

Test results

RunLaravelPHPResult
Existing tests, untouched10.48.208.3.64 tests, 1 failing (already broken before the upgrade)
With smoke tests and the example-test fix10.48.208.3.615 tests passing
Step 111.57.18.4.2716 passing, after the Carbon fix
Step 212.70.28.4.2716 passing
Step 313.39.08.4.2718 passing, 47 assertions, no deprecations
Step 3, routes cached13.39.08.4.2714 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