Upgrading PrintagePart 1 of 3Parts 2 & 3 coming soon
[ Case Study ][ Laravel ][ Upgrade ][ PHP ]

How we upgraded Printage from Laravel 9 to 13

·12 min read

We built Printage's ordering platform in 2022. Four years later, the full upgrade, Laravel 9 to 13, PHP 8.0 to 8.5, React 17 to 19, took four hours of actual work.

That's the argument for building things properly the first time. We weren't perfect in 2022. No test coverage, one package that turned out to be abandoned, a hand-rolled Docker setup we eventually threw away. But the foundation was solid: clean architecture, proper separation of concerns, sensible dependency choices. When it came time to upgrade, the codebase didn't fight back. We are learning every day, and what we learned four years ago paid off this week.

Printage handles print procurement for TATA Capital and TATA Motors Finance. Every letterhead, every branded document, every client-facing print job for two of India's largest financial institutions routes through their platform. In March 2026, that platform was running Laravel 9, PHP 8.0.26, and had 12 unpatched security vulnerabilities.

Why upgrades get deferred

Laravel 9 reached end-of-life in February 2024. PHP 8.0 reached end-of-life in November 2023. The platform was live, serving enterprise clients, processing real orders daily. There was no test suite and no clear upgrade path that felt safe to run on production.

This is how it always happens. The system works. Clients are happy. There is no immediate crisis. Staying on an unsupported version feels lower risk than touching a live system and breaking something. That logic holds until it doesn't, and the thing that breaks it is usually a CVE, an audit finding, or a client asking about your security posture.

For a platform under contract with major financial institutions, the calculus is different. An unpatched vulnerability is not just a technical problem. It is a liability.

Phase 1: understand what you're dealing with

Before touching a single dependency, we audited the full stack.

bash
composer outdated

The direct dependencies looked like this:

code
enlightn/enlightn                 v2.1.0  v2.10.0
guzzlehttp/guzzle                 7.5.0   7.10.0
laravel/framework                 v9.45.1 v9.52.21
laravel/tinker                    v2.7.3  v2.11.1
laravel/ui                        v4.1.1  v4.6.3
mockery/mockery                   1.5.1   1.6.12
owen-it/laravel-auditing          v13.0.5 v13.7.4
spatie/laravel-permission         5.7.0   6.25.0

A few things became clear immediately. enlightn/enlightn had done its job at launch and was no longer needed as a permanent dependency. It was removed. spatie/laravel-permission had jumped from v5 to v6, a major version with breaking changes that would need careful handling. And laravel/ui was already flagged as legacy, which became relevant when we later replaced it entirely (that's Part 3 of this series).

Next, we ran the security audit:

bash
composer audit

The full list of advisories:

code
guzzlehttp/psr7          CVE-2023-29197
laravel/framework        CVE-2024-52301
laravel/framework        CVE-2025-27515
league/commonmark        CVE-2025-46734
league/commonmark        CVE-2026-30838
league/commonmark        CVE-2026-33347
nesbot/carbon            CVE-2025-22145
psy/psysh                CVE-2026-25129
symfony/http-foundation  CVE-2024-50345
symfony/http-foundation  CVE-2025-64500
symfony/http-kernel      CVE-2022-24894
symfony/process          CVE-2024-51736

12 advisories across 8 packages. On a platform processing orders for enterprise financial clients.

This is what deferred upgrades actually cost. Not technical debt in the abstract. Twelve specific, documented vulnerabilities sitting in a production system.

One configuration detail worth knowing: Composer's block-insecure is on by default and refuses to install packages with known security advisories. During the upgrade we had to turn it off temporarily, since intermediate package versions still carried advisories mid-process. Once all packages were upgraded and composer audit came back clean, we turned it back on. If you leave it off and forget, you've traded one problem for another.

Phase 2: code hygiene before the upgrade

This step is easy to skip. It matters more than it looks.

Before touching any version numbers, we ran Laravel Pint across the entire codebase:

bash
./vendor/bin/pint

Pint is Laravel's official code style formatter, built on PHP CS Fixer. It was introduced in Laravel 9 but we had never run it on this project. Four years of inconsistent formatting cleaned up in one command. This matters during a major upgrade because it separates style noise from actual breaking changes. When you're reading diffs across version jumps, you want every change to mean something.

We also tried Laravel Shift to automate parts of the upgrade. It is a genuinely useful tool. For this project, we found we were more comfortable with a manual step-by-step approach so we understood every change that was made. Both are valid. The important thing is that you know what changed and why.

Phase 3: upgrading PHP packages one major version at a time

You cannot upgrade from Laravel 9 to Laravel 13 in one step. The framework has breaking changes at each major version boundary. The safe path is sequential: 9 to 10, 10 to 11, 11 to 12, 12 to 13. At each step, update composer.json, run composer update, fix breaking changes, verify the application works, then move to the next.

The jump from Laravel 10 to 11 was the most significant. Laravel 11 consolidated the application structure considerably. The app/Http/Kernel.php file is gone, replaced by middleware registration in bootstrap/app.php. Many default service providers were merged into the framework. Route files changed. For a four-year-old codebase that had grown organically, this required attention rather than just running the upgrade command.

At each step, we ran composer audit to verify the advisory count was falling. By the time we reached Laravel 13, the result was clean:

bash
composer audit # No security vulnerability advisories found.

After the framework upgrades were stable, we moved the PHP runtime from 8.0.26 to 8.5.4. PHP 8.5 introduced stricter type handling in a few places, but the codebase was clean enough after the framework upgrades that there were no significant issues. Running composer audit again confirmed no regressions.

Migrating spatie/laravel-permission from v5 to v6

This package had a major version jump and required specific handling. The v6 migration involves changes to the role and permission cache driver, and the model_has_roles and model_has_permissions tables may need updating depending on how the package was configured. We ran the published migration after the upgrade and verified role assignments were intact.

Phase 4: rebuilding the development environment

The existing local setup used a hand-rolled Docker Compose configuration with a custom Dockerfile. It worked, but it was another thing to maintain and keep aligned with production.

We removed it entirely, docker-compose.yml and Dockerfile both gone, and replaced it with Laravel Sail:

bash
composer require laravel/sail --dev php artisan sail:install

Sail provides a pre-built Docker environment maintained by the Laravel team, with first-class support for MySQL, Redis, and the current PHP version. For a platform that will continue to receive upgrades, having the dev environment tied to the framework rather than hand-maintained is the right call.

We added MySQL 8.4 as the Sail service, matching the production database version. This surfaced two migration issues immediately when running:

bash
sail artisan migrate

Issue 1: $table->unsignedDecimal(...) is no longer valid in Laravel 13. The replacement is:

php
// Before $table->unsignedDecimal('amount', 10, 2); // After $table->decimal('amount', 10, 2)->unsigned();

Issue 2: The published migration from spatie/laravel-permission needed updating to match v6's schema expectations. Running the package's own migration command resolved this.

Two issues on a four-year-old codebase is a good result. Which brings us to the most important validation step.

Phase 5: validating the schema against production data

This step is non-negotiable on any upgrade of a live platform.

We exported the production database without table structure or DROP statements, just the data, and imported it into the freshly migrated local database. Old data against the new schema, in a safe environment.

bash
# Export production data only mysqldump --no-create-info --skip-add-drop-table production_db > prod_data.sql # Import into local database running the new schema sail mysql < prod_data.sql

The goal is to find data integrity problems before deploying. Column type changes, removed columns, altered indexes: any of these can make a migration succeed in a clean database and fail silently or noisily against real data.

In this case, the import passed without errors. No data modelling or repair was needed. That is not always the outcome, and finding it here rather than in production is the entire point of this step.

Phase 6: upgrading NPM packages

The frontend had the same problem as the PHP side: four years of drift. The npm outdated output told the story:

code
@vitejs/plugin-react     2.0.0   latest: 6.0.1
axios                   0.27.2   latest: 1.14.0
bootstrap                5.2.0   latest: 5.3.8
laravel-vite-plugin      0.5.2   latest: 3.0.0
react                   17.0.2   latest: 19.2.4
react-dom               17.0.2   latest: 19.2.4
vite                     3.0.4   latest: 8.0.3

Rather than upgrading packages individually with a four-year-old node_modules underneath them, we wiped the slate:

bash
npm remove @popperjs/core @vitejs/plugin-react axios bootstrap laravel-vite-plugin \ lodash postcss react react-dom sass vite moment react-multi-form react-select \ react-toastify react-vertical-timeline-component reactjs-popup xlsx rm -rf node_modules/ package-lock.json

Then reinstalled cleanly, separating dev and runtime dependencies:

bash
# Dev dependencies npm i -D @popperjs/core @vitejs/plugin-react axios bootstrap laravel-vite-plugin \ lodash postcss react react-dom sass vite # Runtime dependencies npm i moment react-select react-toastify react-vertical-timeline-component reactjs-popup xlsx

Handling an abandoned package

One package did not survive the upgrade: react-multi-form. It requires React 16. It is not maintained and has no React 19 compatible version. In the past, it had been installed with --legacy-peer-deps, which masks the incompatibility rather than solving it.

The package was used on two pages. The solution was straightforward: clone the four relevant components directly from the package's GitHub repository and maintain them as internal code.

The four components: Line.js, MultiStepForm.jsx, Pill.js, and Step.jsx. Roughly 200 lines total, no external dependencies, simple enough to own. We removed the package and copied the components into the project. No behavioral change, no dependency on an unmaintained package.

This is often the right answer when a small, focused package becomes a blocker. If the code is simple and the package is abandoned, owning the code is better than staying on an old React version.

React 17 to 19: the ReactDOM change

React 18 removed ReactDOM.render() and replaced it with the createRoot API. This was the most mechanical part of the upgrade: 17 files needed the same change.

Before:

jsx
import ReactDOM from 'react-dom'; if (document.getElementById('dashboard')) { ReactDOM.render(<Dashboard />, document.getElementById('dashboard')); }

After:

jsx
import { createRoot } from 'react-dom/client'; if (document.getElementById('dashboard')) { const container = document.getElementById('dashboard'); const root = createRoot(container); root.render(<Dashboard />); }

17 files, same change, no logic differences. Straightforward but time-consuming without automation.

We also removed splitVendorChunkPlugin from vite.config.js. It was removed from Vite in v3 and the import would break the build.

Phase 7: testing with zero coverage

The project had no automated tests. That was a known constraint from the original build, and it did not become a problem until a moment exactly like this one.

With no test suite to run, verification meant sitting down and manually testing every feature and edge case in the application. Order creation flows, permission checks, multi-step forms, file uploads, the full surface area of the platform. It took six hours.

This is the real cost of zero test coverage. Not the absence of a green CI badge. Six hours of manual work on every significant upgrade, with the risk that something gets missed. For a platform under active development serving enterprise clients, that cost compounds.

Part 2 of this series covers writing test cases for the platform and pushing toward 100% coverage. That work happened before the frontend migration, and it's what made Part 3 (replacing Bootstrap and laravel/ui with Tailwind, Inertia.js, and shadcn/ui) safe to do. Migrating a frontend without a test suite is a different category of risk entirely.

Where it ended up

After the upgrade:

  • PHP: 8.0.26 to 8.5.4
  • Laravel: 9.52.20 to 13.2.0
  • MySQL: 8.0.42 to 8.4.8
  • React: 17.0.2 to 19.2.4
  • Security advisories: 12 to 0
  • Dev environment: custom Docker setup to Laravel Sail

The platform serving TATA Capital and TATA Motors Finance is now running on a supported stack, with no known security vulnerabilities, and a development environment that stays in sync with the framework going forward.

The upgrade itself was not complicated. It was systematic. Audit first, upgrade in sequence, validate against real data, test manually. The six hours of manual testing at the end is the argument for doing this work sooner rather than later, while the scope is still manageable.

Part 2 covers writing test cases and getting the platform to 100% coverage. Part 3 covers replacing laravel/ui and Bootstrap with Inertia.js and Tailwind, including shadcn/ui components.

If your Laravel application is still on version 9 or 10, the window for a clean upgrade is narrowing. We run these as part of our platform audits. Get in touch and we can tell you what your upgrade path looks like.

[ Client ]

Working with MBC has been a game-changer for our business at Printage.

We initially built our ordering system on WordPress, but as our operations grew, it started to show serious limitations. Performance issues, lack of flexibility, and constant workarounds were slowing us down. That's when Shubham and his team stepped in-and honestly, it felt like a rescue.

They didn't just rebuild the system in Laravel; they completely rethought how it should work. The new platform is fast, reliable, and tailored exactly to our workflow. What impressed me most was their ability to understand our business deeply and translate that into a clean, scalable system.

Recently, they helped us modernize the entire application again, bringing in better UX, improved performance, and future-ready architecture. The difference is night and day.

Our clients are noticeably happier, our internal team is more efficient, and we finally have a system we can confidently build on.

If you're looking for a high-quality Laravel partner who actually understands business impact—not just code, MBC is the team you want.

VT

Vinit Thakkar

Founder, Printage