- Timeline
- 4 days, audit to release package
- Codebase size
- 742 routes, 95 controllers, 142 models
- Laravel versions crossed
- 4 (9.52.21 → 13.38.0)
- API routes changed
- 0 application routes
- Dead code and config removed
- 913 lines
- PHP 8.4 deprecations
- 3 → 0 (480 files linted)
- Full smoke-test runs
- 8 runs of 14 automated checks each
- Runtimes verified
- PHP 8.4.27 and 8.5.1; deployable on 8.3+
- Database tables changed
- 1, with the rollback tested for real
Summary
The client’s core business platform runs on a Laravel API that serves two front-ends: an Angular admin panel and a public-facing PHP website. The API was on Laravel 9, which stopped getting security fixes in February 2024. Its dependency tree carried 41 published security advisories across 10 packages.
I moved it to Laravel 13.38 on PHP 8.4 in four tested steps (9 → 10 → 11 → 12 → 13). Those steps included the two Passport major versions that change how admin login works. The application code change came to 15 files and fewer than 100 changed lines. Security advisories dropped from 41 to 0, and neither front-end needed a single change.
The system
- API: Laravel 9.52.21 on PHP 8.2. Laravel Passport 11 handled admin authentication, and a separate custom token system handled the second user type.
- Size: 742 API routes, 95 controllers, 142 models, 276 PHP files in
app/, 156 database tables and 11 scheduled jobs (daily notification emails and reports). - Integrations: a custom OAuth mail transport built on Symfony Mailer, PDF generation (DomPDF), CSV reporting and reCAPTCHA.
- Consumers: an Angular admin panel and a core-PHP website. The website has no database of its own, so every read and write goes through this API.
- Hosting: Linux shared hosting (cPanel). Development happens on Windows.
The challenge
- Security exposure. Laravel 9 had been end-of-life for over two and a half years. PHP 8.2 itself loses security support on 31 December 2026.
- No test safety net. The project had only the two example PHPUnit tests that ship with Laravel.
- Authentication was the riskiest part. Passport 13 changes the
oauth_clientstable structure, hashes client secrets, defaults to UUID client IDs and requires a new interface on token-bearing models. One mistake would lock every administrator out. - The framework jumps also pull in the libraries underneath. Carbon 2 → 3, Symfony 6.4 → 7.4, Monolog 2 → 3, PHPUnit 9 → 12, and league/oauth2-server 8 → 9.
- A long-lived codebase with history. It had unused packages and a config file that was not really a config file. The database also cannot be rebuilt from its own migrations.
- Production is deployed by hand on shared hosting. The upgrade had to ship as one rehearsed release with a rollback plan, not file by file.
The approach
1. Audit first, before touching composer.json
I did a read-only analysis of the codebase against every item in the official upgrade guides. That covered Laravel 10, 11, 12 and 13, Passport 12 and 13, and PHP 8.3, 8.4 and 8.5. I marked each breaking change either “FIX” or “verified: not used” and wrote down the reason. The result was an upgrade plan written for this one codebase.
Most of the official checklist turned out not to apply. Recording why meant nobody had to re-check those items at the next step.
The things that did need work:
- 6
diffInDays()calls that would start returning decimals under Carbon 3 - 3 implicitly nullable parameters (deprecated in PHP 8.4)
- 10
fputcsv()calls (deprecated in PHP 8.4) - 8
imagedestroy()calls (deprecated in PHP 8.5) - 2 duplicate route names (Laravel 12 changes how these behave)
- 3 installed packages that nothing used
- a missing
sodiumPHP extension that Passport’s JWT library requires
2. Phase 0: prepare the old version for the new one
- Forward-compatible fixes on Laravel 9 first. Each fix was proven to change nothing on the old stack. PHP 8.4 lint deprecations went from 3 to 0 across 480 files. After that, every later step could only fail for reasons belonging to that version.
- Removed unused packages instead of upgrading them. Sanctum was installed but never used. The backup package (
spatie/laravel-backup) was never configured or scheduled, and Sail wasn’t used either. The cheapest package to upgrade is one you delete. - Deleted 913 lines of dead files, including a 528-line old copy of a helper class and old config backups. Also fixed a duplicate route name and set
minimum-stabilitytostable. - Tagged the starting point (
pre-upgrade) so there was always a rollback target.
3. One branch, one tag per major version
All the work happened on one upgrade branch. Each Laravel version got a git tag when it passed (l10-ok, l11-ok, l12-ok, l13-ok), so every step was a known-good rollback point. Versions 10–12 were never deployed on their own, so separate branches would only have added merge work.
4. A smoke-test suite instead of the missing tests
I wrote a 15-point smoke list and automated 14 of its checks in a script. The checks cover:
- the JSON error handler
- CORS preflight requests
- login validation for both user types
- an authenticated route returning 200 with a valid token and 401 with a wrong token or no token
- logout revoking the token
- CSV export
- dashboard day-count values coming back as whole numbers
- public signup validation
- all 11 scheduled jobs being registered
- the custom artisan commands
- zero new deprecation lines in the logs
Mail went to an in-memory driver, so no real email could leave during testing. The script ran after every step and saved a report each time. Each report records the server’s actual PHP version, so it proves which runtime it tested.
5. Version by version
| Step | PHP | Main changes | Code changes | Advisories after |
|---|---|---|---|---|
| Start | 8.2 | — | — | 41 (10 packages) |
| 9 → 10.50.3 | 8.2 | Monolog 3, PHPUnit 10 | 3 small files | 6 |
| 10 → 11.57.0 + Passport 12 | 8.2 | Carbon 3, Symfony 7.4 | none (Passport migrations published) | 5 |
| 11 → 12.70.1 | 8.4 | Runtime switched to PHP 8.4, PHPUnit 11 | none (composer.json only) | 0 |
| 12 → 13.38.0 + Passport 13 | 8.4 | New Passport client schema, Tinker 3, PHPUnit 12 | 2 models, 1 provider, 2 config keys, 1 migration | 0 |
I switched to PHP 8.4 one step early on purpose, so the final step changed only the framework and not the runtime as well.
6. The Passport 13 migration: the one risky step
- Code: both token-bearing models implement the new
OAuthenticatableinterface. Integer client IDs are kept ($clientUuids = false), and so is the existing 24-hour token lifetime. - Schema: the migration adds the new columns, fills them from the old ones, then drops the old ones. It has a working
down(). - Secrets: the 40-character plain-text client secrets are now bcrypt-hashed.
- Dry run: I rehearsed it on a throwaway copy of the 55 MB, 156-table database.
- Rollback: I actually ran the rollback rather than assuming it would work, and that exposed a Laravel pitfall (described below).
- Half-deployed states: I tested new code against the old schema, and it works. Admins are not locked out if code and database go live a few minutes apart.
- Two equal deliverables: a Laravel migration for environments that run artisan, and a plain SQL script for production. I proved they produce the same result by comparing
SHOW CREATE TABLEoutput from both. - Admin sessions: tokens issued before the migration stayed valid, so nobody was logged out.
7. Deployment engineering
- Deploy script for the server. It stops at the first problem and runs these steps:
- checks PHP ≥ 8.3 and 17 required extensions, including
sodium - keeps the old
vendor/folder for rollback - clears stale caches
- puts the dependencies in place
- creates the Passport keys and sets their file permissions
- checks the database layout and hashes the client secrets
- finishes with live HTTP checks
- checks PHP ≥ 8.3 and 17 required extensions, including
- Reproducible release package. It is built from a git commit, not a working folder, and ships with dependencies already installed.
- Size: 16 MB, 8,260 files, 98 production packages, with a SHA-256 checksum.
- The extracted copy was boot-tested, and a leak check confirmed there are no environment files, keys or local paths inside.
- The shared host needs neither git nor Composer.
- Platform pinned to PHP 8.3 in Composer, so the same lock file works on PHP 8.3, 8.4 and 8.5.
Problems found along the way
1. The config file that was secretly a class
config/cors.php didn’t hold a config array. It held a full copy of the CORS middleware class. Laravel loads every config file at boot, so that copy took priority over the real class. The two copies were also different, and only one handled file downloads correctly. Following the official guide and simply restoring the config would have switched on the broken copy. Untangling it properly also fixed a long-standing CORS failure on one of the admin panel’s actions, without touching the Angular code.
2. Composer quietly raised the minimum PHP version
Resolving dependencies on PHP 8.4 pulled in Symfony 8.1 components that require PHP ≥ 8.4.1. The staging server runs PHP 8.3. Pinning Composer’s platform to PHP 8.3.0 brought Symfony back to 7.4 and put the PHP floor back at 8.3.
3. “29.41 days”
Under Carbon 3, diffInDays() returns signed decimals. Without the fix, the admin dashboard would have shown fractional day counts. All six call sites were fixed on Laravel 9, where the fix could be shown to change nothing.
4. The rollback that “passed” without rolling anything back
migrate:rollback --path=… limits which files Laravel looks up. It does not limit which batch gets undone. The first attempt quietly rolled back a different migration and reported success. Running the real rollback caught this before production could.
5. Diagnosing a staging failure from a stack trace
The first staging deploy failed admin login with “Client authentication failed”. The line numbers in the trace belonged to Passport 12, not the Passport 13 the branch uses. So the staging server had the new database schema with the old dependencies. The rule that came out of it: new code tolerates the old schema, but old code does not tolerate the new one. The deploy order is therefore code first, then database, and the runbook says so.
6. A failure that only happens on Linux
Passport 13 checks the file permissions of its keys. With the usual 644 permissions on Linux, every token operation returns a 500 error. Windows skips this check, so it never showed up locally. The deploy script now sets the right permissions.
7. The smoke test was testing the wrong server
An old PHP 8.2 test server had never been stopped. The stop command was given a Git Bash process ID, which isn’t a Windows process ID. Later test runs hit that stale server. The fix was to stop servers by port and to print the server’s PHP version in every report. A smoke test has to prove what it tested.
8. The database can’t be rebuilt from its own migrations
On an empty database, migration 16 of 162 fails, and many tables were created outside migrations anyway. Every new environment therefore starts from a database dump. Because APP_KEY encrypts stored values, the key has to travel with the dump.
Timeline
- Day 1: read-only audit of the API, the admin panel and the website against the official guides, producing a plan specific to this codebase.
- Day 2: Phase 0 plus all four version steps, each one tagged and smoke-tested. Also the Passport migration dry run, the real rollback test and the production SQL script.
- Day 3: first staging deploy, the incident diagnosed from the stack trace, and the deploy order corrected.
- Day 4: staging deploy script and reproducible release package.
Tech stack
Laravel 13 · PHP 8.4 · Laravel Passport 13 (OAuth2) · MySQL 8 · Symfony Mailer (custom OAuth transport) · DomPDF · Angular · Composer · Git · Bash · cPanel/Linux
Lessons I bring to every upgrade
- Audit before upgrading. Write down why each breaking change does or doesn’t apply.
- Make forward-compatible fixes on the old version, where you can prove they change nothing.
- Delete unused packages instead of upgrading them.
- Go one major version at a time, with a tag and a full smoke run after each.
- Pin Composer’s platform PHP version, or your development machine decides your production minimum.
- Run the rollback for real, not just the forward migration.
- Test half-deployed states, and say which deploy order is safe.
- Build release packages from a commit, and boot-test the extracted package.
- A green Windows machine doesn’t prove a Linux server will work. Put the server’s needs into the deploy script.
| Measure | Before | After |
|---|---|---|
| Laravel | 9.52.21 (end-of-life) | 13.38.0 |
| PHP | 8.2.31 | 8.4.27 (runs on 8.3–8.5; tested on 8.5.1) |
| Laravel Passport | 11.10.6 | 13.10.0 |
| Symfony | 6.4 | 7.4 |
| Carbon | 2.73 | 3.15 |
| Monolog | 2.11 | 3.12 |
| PHPUnit | 9.6 | 12.5 |
| league/oauth2-server | 8.5 | 9.4 |
| Known security advisories | 41 in 10 packages | 0 |
| PHP 8.4 deprecations | 3 | 0 |
| Unused packages | Sanctum, laravel-backup, Sail | Removed |
| OAuth client secrets | Plain text | bcrypt-hashed |
| API routes | 742 | 735 (only deprecated Passport and Sanctum routes removed) |
| Front-end changes | — | None |


