Upgrading a mature Laravel application is not a weekend task. When our in-house team at ITPenthouse faced the challenge of migrating a large-scale production project from Laravel 5.5 to Laravel 12, we knew that a "big bang" approach — jumping straight to the latest version — was a recipe for disaster. Instead, we chose a deliberate, incremental upgrade path: 5.5 → 5.6 → 5.7 → 5.8 → 6.x → 7.x → 8.x → 9.x → 10.x → 11.x → 12.x. This post documents our strategy, the technical decisions we made along the way, and the hard lessons we learned — especially around retiring the deprecated Dingo API package.

Why Incremental Upgrades Were the Only Sane Option

The project in question was not a simple CRUD application. It was a multi-service platform with hundreds of endpoints, complex business logic, dozens of queued jobs, event listeners, custom middleware, and deep integrations with third-party services. The codebase had grown over several years, accumulating technical debt that is typical for any long-lived Laravel application.

A direct jump from 5.5 to 12 would have introduced thousands of breaking changes simultaneously. Debugging a failure in that scenario means searching across seven major versions of changelog entries, deprecated features, and shifted framework internals. The regression risk was simply too high.

Our rule was simple: upgrade one minor or major version at a time, run the full test suite, fix what breaks, deploy to a staging environment, QA thoroughly, and only then move to the next version. No shortcuts.

The Step-by-Step Upgrade Strategy

Phase 1: Audit and Preparation (Pre-Upgrade)

Before touching composer.json, we invested significant time in preparation:

  • Dependency inventory: We cataloged every Composer package, noting which versions supported which Laravel releases. This gave us a clear picture of upcoming blockers.
  • Test coverage assessment: We identified gaps in our test suite and wrote additional feature and integration tests for critical business flows. Upgrading without adequate test coverage is flying blind.
  • PHP version mapping: Each Laravel version has specific PHP version requirements. We mapped out the PHP upgrades we'd need (from PHP 7.0 all the way to PHP 8.2+) and planned server-level changes accordingly.
  • Deprecation warnings: We enabled full deprecation logging on the existing 5.5 installation and fixed as many deprecation warnings as possible before starting.

Phase 2: The Incremental Climb (5.5 → 8.x)

The early upgrades (5.5 through 5.8) were relatively smooth. Laravel's minor version upgrades within the 5.x series introduced fewer breaking changes. However, each step still required careful attention:

  • 5.5 → 5.6: Logging configuration moved to its own config file. Argon2i password hashing was introduced. We updated logging configurations and verified all log channels.
  • 5.6 → 5.7: Email verification scaffolding, the new Resources directory conventions, and changes to the pagination defaults. We also dealt with the Blade or operator removal.
  • 5.7 → 5.8: String and array helpers were deprecated in favor of facades. Carbon 2.0 became the default. We ran a project-wide search-and-replace for helper functions, switching to Str:: and Arr:: facades.
  • 5.8 → 6.x: This was the first major version jump. Laravel 6.0 introduced semantic versioning, moved string and array helpers to a separate package, and required PHP 7.2+. We upgraded the server's PHP version in tandem and updated all affected code.
  • 6.x → 7.x: Blade component tags, custom casts, and HTTP client were introduced. The Symfony 5 dependency shift required updates to several packages. We also started replacing Guzzle HTTP calls with Laravel's built-in HTTP client where possible.
  • 7.x → 8.x: Model factories were completely rewritten as class-based factories. This was one of the most labor-intensive steps because our project had hundreds of factory definitions in the old closure-based format. We systematically converted every factory, which took the better part of a week for a single developer.

Phase 3: The Hard Middle (8.x → 10.x) and Killing Dingo

This is where things got genuinely difficult — primarily because of Dingo API.

Dingo had been the API routing and response layer since the project's inception. It provided API versioning, rate limiting, transformers (via Fractal), and its own exception handling. The problem: Dingo had been effectively abandoned. There was no official support for Laravel 9+, and community forks were inconsistent and unreliable.

We made the strategic decision to remove Dingo entirely during the 8.x → 9.x transition. Here's how we approached it:

  • Route migration: We migrated all Dingo routes ($api->version() groups) to native Laravel route files. API versioning was handled via route prefixes and route groups instead of Dingo's versioning layer.
  • Transformer replacement: Dingo's tight coupling with Fractal transformers meant we had transformers everywhere. We replaced them with Laravel's native API Resources (introduced in 5.5 but underutilized in our codebase). Each Fractal transformer was rewritten as a JsonResource or ResourceCollection.
  • Exception handling: Dingo had its own exception handler that intercepted and formatted API errors. We consolidated all exception handling into Laravel's native Handler.php (and later, the exception handling closure in Laravel 11+), ensuring consistent JSON error responses.
  • Rate limiting: Dingo's rate limiting was replaced with Laravel's built-in ThrottleRequests middleware and the RateLimiter facade.
  • Authentication: Dingo had its own auth middleware layer. We replaced it with Laravel Sanctum and native middleware groups.

This refactoring was executed over a three-week sprint with two senior backend developers. Every replaced endpoint was individually tested against the existing API contracts using a Postman collection with over 400 requests.

Phase 4: The Final Push (10.x → 12.x)

With Dingo removed and the codebase modernized, the final upgrades were significantly cleaner:

  • 9.x → 10.x: PHP 8.1 minimum. We adopted constructor property promotion, enums, and named arguments in new code. Updated Pest/PHPUnit configurations for PHPUnit 10.
  • 10.x → 11.x: Laravel 11 introduced the slimmed-down application skeleton. We carefully merged the new directory structure — notably the removal of the HTTP Kernel and Console Kernel in favor of bootstrap/app.php configuration. Middleware registration moved entirely.
  • 11.x → 12.x: The most recent step. We adopted the latest defaults, reviewed new validation rules, and ensured full compatibility with PHP 8.2+ features that Laravel 12 leverages.

Deployment Strategy: Gradual Rollout Across Services

Our production infrastructure runs on multiple services — separate deployments for the API layer, admin panel, queue workers, and scheduler. We did not deploy upgrades to all services simultaneously.

Instead, we used a phased deployment model:

  • Stage 1: Deploy the upgraded version to the internal admin panel first. This is lower traffic and used by our own team, giving us a safe environment to catch issues.
  • Stage 2: Roll out to queue workers and scheduled tasks. Monitor for job failures, memory leaks, and unexpected behavior in async processing.
  • Stage 3: Deploy to the public-facing API. This was always the last step, preceded by thorough monitoring setup — including error rate alerts, response time tracking, and log anomaly detection.

This approach meant that even if something slipped through testing, the blast radius was contained.

Key Takeaways for Engineering Teams

  • Never skip versions. The Laravel upgrade guides are written version-to-version for a reason. Each guide assumes you're coming from the immediately prior version. Skipping versions means compounding undocumented interactions.
  • Kill your darlings early. Legacy packages like Dingo become anchors. The longer you wait to replace them, the harder the extraction becomes. If a package is unmaintained, schedule its replacement proactively — don't wait until it blocks an upgrade.
  • Invest in tests before you start. Every hour spent writing tests before the upgrade saves three hours debugging mysterious failures during it.
  • Deploy incrementally. Don't treat a framework upgrade as a single atomic deployment. Roll it out service by service, monitoring at each stage.
  • Document everything. We maintained an internal upgrade log for each version step, noting every breaking change we encountered, every package swap, and every workaround. This document became invaluable when onboarding new team members.

The Result

The entire upgrade from Laravel 5.5 to 12 took approximately four months of calendar time, executed by a team of two to three senior backend engineers working alongside the regular feature development cycle. The project now runs on the latest Laravel version with modern PHP 8.2+ features, Laravel Sanctum for authentication, native API Resources, class-based factories, and a fully modernized deployment pipeline.

Most importantly, we had zero production incidents directly caused by the upgrade process. That's the payoff of incremental discipline over speed.

If your team is staring down a multi-version Laravel upgrade and wondering whether the slow path is worth it — it is. Every single time.