Start a project
// case study Laravel 9→Laravel 13

Upgrading a Production Laravel API from Laravel 9 to Laravel 13

Four major versions, PHP 8.2 → 8.4 and Passport 11 → 13. The API contract, both front-ends and every admin session stayed as they were.

Delivered by Muneeb Dev · Software developer who builds with AI

41 → 0Known security advisories
0Front-end changes required
15 filesApplication code changed (+56 / −41 lines)
0Admins forced to log out
// at a glance
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_clients table 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 sodium PHP 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-stability to stable.
  • 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

StepPHPMain changesCode changesAdvisories after
Start8.2——41 (10 packages)
9 → 10.50.38.2Monolog 3, PHPUnit 103 small files6
10 → 11.57.0 + Passport 128.2Carbon 3, Symfony 7.4none (Passport migrations published)5
11 → 12.70.18.4Runtime switched to PHP 8.4, PHPUnit 11none (composer.json only)0
12 → 13.38.0 + Passport 138.4New Passport client schema, Tinker 3, PHPUnit 122 models, 1 provider, 2 config keys, 1 migration0

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 OAuthenticatable interface. 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 TABLE output 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:
    1. checks PHP ≥ 8.3 and 17 required extensions, including sodium
    2. keeps the old vendor/ folder for rollback
    3. clears stale caches
    4. puts the dependencies in place
    5. creates the Passport keys and sets their file permissions
    6. checks the database layout and hashes the client secrets
    7. finishes with live HTTP checks
  • 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

  1. Audit before upgrading. Write down why each breaking change does or doesn’t apply.
  2. Make forward-compatible fixes on the old version, where you can prove they change nothing.
  3. Delete unused packages instead of upgrading them.
  4. Go one major version at a time, with a tag and a full smoke run after each.
  5. Pin Composer’s platform PHP version, or your development machine decides your production minimum.
  6. Run the rollback for real, not just the forward migration.
  7. Test half-deployed states, and say which deploy order is safe.
  8. Build release packages from a commit, and boot-test the extracted package.
  9. A green Windows machine doesn’t prove a Linux server will work. Put the server’s needs into the deploy script.
// results
MeasureBeforeAfter
Laravel9.52.21 (end-of-life)13.38.0
PHP8.2.318.4.27 (runs on 8.3–8.5; tested on 8.5.1)
Laravel Passport11.10.613.10.0
Symfony6.47.4
Carbon2.733.15
Monolog2.113.12
PHPUnit9.612.5
league/oauth2-server8.59.4
Known security advisories41 in 10 packages0
PHP 8.4 deprecations30
Unused packagesSanctum, laravel-backup, SailRemoved
OAuth client secretsPlain textbcrypt-hashed
API routes742735 (only deprecated Passport and Sanctum routes removed)
Front-end changes—None

Have something similar?

Tell me what you're working with. You get a plain answer on what it would take and a written scope.