YOUR DIGITAL HUB
← Back to blog

Migrating a legacy PHP 5.6 to 8.3: a battle-tested strangler-pattern methodology

· 8 min read
Cover visual — PHP 5.6 to 8.3 migration with the strangler pattern

The context: why PHP 5.6 is dangerous in 2026

PHP 5.6 has been officially end of life since January 2019. More than seven years without security patches. Yet, in 2026, we still regularly audit critical business applications running on this foundation: in-house ERPs, B2B platforms, large-account back-offices. The code works, the team has other priorities, the CIO keeps postponing. Until the day a CVE on zlib, ICU or openssl forces a panic move.

The problem is not limited to security. PHP 5.6 has no strict scalar typing, no property types, no attributes, no enums, no readonly. Modern teams refuse to work on it, juniors no longer learn the syntax, and modern libraries have all dropped support. Doctrine 3, Symfony 7, PHPUnit 11, Composer 2.8: all require PHP 8.1 at minimum.

In 2026, the market standard is PHP 8.3 (security support until December 2027) or PHP 8.4 (until December 2028). Migrating is no longer an optional project.

The three migration options

Faced with a PHP 5.6 legacy, three strategies exist. Each has a distinct risk profile, cost and timeline.

Big bang

Rewrite everything on a new branch, switch over in one go. Tempting for a leadership in a hurry. But the big bang concentrates all risks on a single date. Regressions undetected in testing surface in production, with no easy rollback. On a 200,000-line codebase, we have seen big bangs cause several weeks of instability. Our field takeaway: the big bang is only justified on small applications (below 20,000 lines), well tested (coverage above 80%) and low traffic.

Incremental in-place migration

Upgrade PHP in the existing environment, progressive version bumps (5.6 → 7.0 → 7.4 → 8.0 → 8.3). Code must stay compatible at every step. This approach assumes initially clean code, few obsolete extensions, and a team available to fix incompatibilities step by step. It usually breaks between 5.6 and 7.0 (removal of mysql_*, ereg_*, change in constant resolution) and again between 7.4 and 8.0 (removal of each, create_function, change in implicit by-reference passing).

Strangler pattern

Build the new alongside the old, behind a single façade. Route traffic module by module to the new code. Old and new coexist for several months, sometimes years. Stuart Halloway popularized the pattern in 2004 for Java migrations, Martin Fowler theorized the benefits. It is the reference for major PHP migrations today.

Why the strangler pattern wins every time

Across 11 major migrations run by our team over the past 5 years, the strangler pattern was picked 9 times, with zero major production incident. The two big bangs concerned applications under 15,000 lines.

Structural advantages of the strangler pattern:

  • Trivial rollback. The façade routes traffic. One flag and you revert to the old code for a given module in 30 seconds.
  • Continuous delivery. No multi-month functional freeze. Business can keep requesting features, they are implemented on the new code.
  • Backwards-compatible tests. Regression Behat tests run identically on old and new code. The behavior contract is maintained.
  • Progressive skill ramp-up. The team absorbs the new stack module by module, without cognitive overload.
  • Spread budget. Migration is priced in 3-to-8-week lots, fits nicely into an annual budget.

The five-phase plan

Our strangler pattern methodology for PHP always splits into five phases. Durations below are medians observed across 9 engagements, for a 150,000 to 400,000 lines codebase.

Phase 1 — Audit (3 to 6 weeks)

Before touching a line, we map:

  • Exhaustive inventory of modules, bundles, controllers, entities.
  • Dependency graph via Deptrac, identification of cycles and strong couplings.
  • Static analysis on the 5.6 code with PHPStan level 0, progressive climb to measure debt.
  • List of PHP extensions used, detection of mysql_*, ereg_*, each, create_function, implicit mb_*.
  • Composer dependency audit, mapping to versions compatible with 8.3.
  • Test landscape: real coverage (often under 15% on legacy), quality of assertions.

Deliverable: a report quantifying technical debt, prioritizing modules to migrate first (highest security risk, or most stable on the business side).

Phase 2 — Adapter (2 to 4 weeks)

We stand up the coexistence infrastructure:

  • Set up an HTTP façade (Nginx or Traefik reverse proxy) routing by URL pattern.
  • Deploy the new PHP 8.3 runtime in separate containers.
  • Create a new/ repo hosting the target code (Symfony 7 for example), sharing the database with the old.
  • Feature flags via Unleash or an in-house service, allowing per-user, per-group or global toggles.
  • Dual CI/CD pipeline: both codebases are built and tested in parallel.

Phase 3 — Strangler (6 to 12 months depending on size)

The heart of the project. Module by module, we:

  1. Rewrite the module in the new stack, honoring its exact HTTP contracts.
  2. Replay existing Behat scenarios against the new code.
  3. Dark launch: the new code receives shadow traffic, its response is not returned to the user.
  4. Diff comparison between old and new: log gaps, investigate.
  5. Canary: 1% of traffic switches, then 10%, 50%, 100%.
  6. Module frozen on the new code, progressive removal of the old.

On a 250,000-line codebase, we typically migrate 3 to 5 modules per month, with a 3-person team.

Phase 4 — Cutover (2 to 4 weeks)

The last module is through. We:

  • Remove the routing façade, now redundant.
  • Decommission the PHP 5.6 infrastructure (except archive environments).
  • Recompile the list of required extensions, clean up Dockerfiles.
  • Official switchover, internal comms.

Phase 5 — Decommission (1 to 2 months)

Post-cutover, we spend time to:

  • Clean up obsolete feature flags.
  • Remove old-vs-new diff comparators.
  • Archive old code logs.
  • Produce a closure report with before/after metrics: response time, PHPStan debt, coverage, CVEs closed.

The tools that make the difference

Strangler migration is not done by hand. Our tooling chain, battle-tested on recent missions:

Rector

Rector automates syntactic transformations: typing, replacing each with foreach, removing create_function, climbing PHP levels.

<?php
// rector.php
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Rector\Symfony\Set\SymfonySetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withPhpSets(php83: true)
    ->withSets([
        LevelSetList::UP_TO_PHP_83,
        SetList::CODE_QUALITY,
        SetList::DEAD_CODE,
        SetList::TYPE_DECLARATION,
        SymfonySetList::SYMFONY_72,
        SymfonySetList::SYMFONY_CODE_QUALITY,
    ])
    ->withParallel()
    ->withCache(cacheDirectory: __DIR__ . '/var/cache/rector');

On a 250,000-line codebase, Rector processes everything in about 15 minutes and automates 70 to 85% of the transformations needed. The rest requires human intervention.

PHPStan

PHPStan is the lock against regressions. We set an initial baseline level, then climb step by step. Typical configuration during migration:

# phpstan.neon
parameters:
  level: 8
  paths:
    - src
  excludePaths:
    - src/Legacy
  bootstrapFiles:
    - vendor/autoload.php
  treatPhpDocTypesAsCertain: false
  reportUnmatchedIgnoredErrors: true
  tmpDir: var/phpstan

The phpstan-baseline.neon file is versioned to freeze debt, then shrunk sprint by sprint. No PR can add an error above the baseline.

Infection

Infection measures the real quality of the test suite through mutation testing. On a 5.6 legacy, we often start at an MSI (Mutation Score Indicator) of 10 to 20%, meaning the displayed coverage is misleading. During migration, target 60 to 70%.

Deptrac

Deptrac enforces the target layered architecture (Domain, Application, Infrastructure). Configuration to forbid the Domain from calling Doctrine:

deptrac:
  paths:
    - ./src
  layers:
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Infrastructure
      collectors:
        - type: directory
          value: src/Infrastructure/.*
  ruleset:
    Domain: []
    Infrastructure:
      - Domain

Pitfalls to avoid

Across 9 strangler engagements, the traps that come up the most.

Incompatible PHP sessions

The default session serialization changes between PHP 5.6 (php handler) and PHP 7+ (possibility of php_serialize). If old and new code share sessions on a common store (Redis or database), the same handler must be forced on both environments, preferably php_serialize, from phase 2.

Encoding

PHP 5.6 let non-UTF8 strings pass silently. PHP 8.3 with mb_strict_mode throws. Systematize an input validation middleware, and force UTF-8 in php.ini (default_charset = "UTF-8").

Removed functions

each, create_function, money_format, ezmlm_hash, split: gone in 7+. Recursive grep on the codebase before starting phase 3. Rector handles most, not always in dynamically generated code.

Implicit pass by reference

function foo(&$bar) worked with calls like foo($x) in 5.6. In 7+, some contexts emit warnings, even errors. Audit functions that modify their arguments.

Mutable DateTime

PHP 8.2+ deprecates implicit DateTime mutation. Switch to DateTimeImmutable systematically, which Rector does via the DATE_IMMUTABLE set.

Phantom Composer dependencies

Legacy often ships libs not declared in composer.json, loaded by a home-made autoloader or static require. The strangler façade loses them. List all file includes with grep -r "require\|include" --include="*.php" before phase 2.

Field report: B2B platform, 500,000 users

We ran in 2024-2025 the migration of a B2B distribution platform, traffic 2.4 million sessions per month, 500,000 active users. Initial legacy: PHP 5.6.40, Symfony 2.8, Propel 1.7, 280,000 lines of PHP, 40,000 lines of legacy JavaScript.

Total duration: 14 months, 3 full-time senior developers plus a half-time lead architect.

Final target: PHP 8.3, Symfony 7.1, Doctrine ORM 3, API Platform 4.

Before/after metrics:

  • Response time p95: 1,450 ms → 380 ms (OPcache + JIT + optimized Doctrine).
  • PHPStan debt: level 0 with 8,200 errors → level 9 with 0 errors.
  • Test coverage: 11% → 74% on the business layer.
  • CI build time: 42 min → 9 min.
  • Open CVEs on dependencies: 31 critical → 0.

Operational outcome: zero major production incident over the 14 months. Three minor incidents, all caught in canary before 100% rollout, instant rollback each time.

Conclusion

Migrating a PHP 5.6 to 8.3 in 2026 is not a technology choice, it is a compliance and business continuity necessity. The strangler pattern makes it possible without budget ruin or feature freeze. Provided you respect the discipline of the five phases, seriously tool up (Rector, PHPStan, Deptrac, Infection, Behat) and accept 12 to 18 months of coexistence.

If your team still runs on a 5.x or 7.x version, do not postpone. Each extra month costs in security, hiring ability and ecosystem compatibility. Write to contact@your-digital-hub.com, we run an initial audit in 3 to 5 days, executive deliverable, no commitment.