The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Migrating Legacy PHP 5.6 Applications to PHP 8.x


In the African savanna, elephant herds traverse vast distances across terrain that changes with seasons—finding water sources that may have shifted, navigating around new obstacles, and adapting to droughts that alter the entire landscape. The matriarch’s accumulated knowledge of reliable paths, water holes that endure, and dangerous routes becomes critical for the herd’s survival. Without this institutional memory, each journey becomes a perilous gamble.

Migrating from PHP 5.6 to PHP 8.x presents a similarly complex landscape. The “terrain” of your application—its dependencies, patterns, and assumptions—has changed dramatically across seven major versions. Functions you relied on have disappeared. Behaviors you took for granted have shifted. What once worked “just fine” now throws fatal errors. You need accumulated knowledge about which paths remain viable and which lead to dead ends. This guide serves as that institutional memory, helping you navigate the upgrade with confidence.

In this article, we’ll walk through a proven, phased approach to upgrading legacy PHP applications. We’ll examine the tools that automate much of the heavy lifting, the breaking changes that require manual attention, and the testing strategies that keep you from losing your way.

Understanding the PHP 5.6 to PHP 8.x Gap

PHP 5.6 reached its end-of-life in December 2018—over seven years ago at the time of this writing. Running an application on an unsupported PHP version means you’re not receiving security patches; documented vulnerabilities remain unaddressed, exposing your system to potential breaches and compliance violations under regulations like GDPR or PCI DSS. You’re also missing out on substantial performance improvements—PHP 8.x with its JIT compiler can be 2 to 5 times faster than PHP 5.6 in CPU-bound scenarios—and modern language features like union types, attributes, and the nullsafe operator.

Migrating directly from PHP 5.6 to 8.x is a significant undertaking, though it’s an essential step to reduce technical debt, improve security, and ensure your application remains maintainable for years to come. The gap spans seven major versions, with hundreds of deprecations, removed features, and behavioral changes along the way.

One may wonder: is this gap too wide to cross in a single leap? The answer is nuanced. While organizations have successfully made the jump directly from 5.6 to 8.x, doing so requires careful preparation. The approach we outline here breaks the migration into four distinct phases, each building on the previous one. This allows you to catch errors early and maintain a working codebase throughout.

Of course, the effort required varies with codebase size and quality. A small application might take days, while a large enterprise system could span weeks or months. Keep in mind that the goal isn’t just to make the code run on PHP 8.x, but to put the application on solid footing for the next decade.

Prerequisites

Before we begin the migration process, let’s establish what you’ll need:

Required Tools:

  • Git or another version control system
  • Composer (if not already in use)
  • PHP 8.x installed locally (Docker recommended)
  • PHPStan or Psalm for static analysis
  • PHPUnit or PestPHP for testing (highly recommended)

Knowledge Assumed:

  • Familiarity with PHP syntax and basic concepts
  • Understanding of your application’s architecture
  • Basic command-line proficiency
  • Access to your codebase and deployment environment

Environment Setup: Your development environment should run PHP 8.x but mirror your production stack as closely as possible. Docker is an excellent tool for this because it eliminates “works on my machine” discrepancies. You can use an official PHP image with the appropriate extensions.

Phase 1: Preparation and Auditing

This initial phase sets the foundation for a smooth upgrade. Rushing into code changes without proper preparation is, in our experience, a recipe for unexpected complications.

Version Control First

Ensure your entire application is under Git (or another VCS). Create a dedicated branch for the migration—for example, upgrade/php8. This gives you a safe space to experiment without affecting the main code line.

$ git checkout -b upgrade/php8

Now, before we proceed further, a word about safety: your version control system is your primary safety net. Any state-modifying operation should happen on this branch with the reassurance that you can always return to a known-good state. We recommend ensuring the latest “known good” version of your code is committed before each major step.

Local Environment Setup

Set up a development environment that runs PHP 8.x but mirrors your production stack. If you use Docker, you might pull an official PHP image:

$ docker run -it --rm -v $(pwd):/app -w /app php:8.2-cli bash

Inside the container, you’ll want to install any extensions your application requires. For a typical application, you might need:

# Inside the Docker container
$ docker-php-ext-install pdo_mysql mysqli mbstring

You also may notice that the docker-php-ext-install command is specific to the official PHP Docker images. If you’re not using Docker, you’ll need to install PHP 8.x and the required extensions through your system’s package manager or from source.

Dependency Audit

If you’re not already using Composer, now is the time to adopt it. Create a composer.json file if you don’t have one:

$ composer init

Then, examine each dependency’s compatibility with PHP 8.x. The composer why-not command can help identify packages that block the upgrade:

$ composer why-not php/php:^8.0

This command will show you which dependencies require PHP versions that conflict with PHP 8.0. Be prepared to update or replace incompatible libraries. Some older packages may no longer be maintained and will need to be replaced entirely.

You might wonder: what if critical dependencies don’t support PHP 8.x? That’s a common challenge. We’ll address strategies for handling this in Phase 2, though for now it’s enough to identify the blockers.

Baseline Static Analysis

Run a static analyzer (PHPStan or Psalm) on your current codebase under PHP 5.6. This gives you a baseline of existing issues and helps you understand the code’s overall health. Don’t be surprised if the analyzer reports hundreds or thousands of problems—that’s normal for legacy code.

$ vendor/bin/phpstan analyse src --level=1

You also may notice that running at level 1 gives you a manageable starting point. As you fix issues, you can increase the level for more rigorous checking. Though we run this under PHP 5.6 initially, you’ll eventually run it under PHP 8.x as well.

Note: If you have little or no test coverage, consider writing a few smoke tests for critical user flows before you begin refactoring. Even a small test suite provides a safety net that we’ll rely on heavily in Phase 4.

Phase 2: Automated Tooling for the Heavy Lifting

Manual refactoring of a large codebase is error-prone and time-consuming. We can automate the vast majority of syntax changes using tools like Rector.

Rector is a command-line tool that applies predefined rules to upgrade your code. It can convert mysql_* calls to mysqli or PDO, replace each() with foreach, add scalar type hints, and much more. Because it’s automated, it’s consistent and fast.

Installing Rector

First, install Rector as a development dependency:

$ composer require --dev rector/rector

Configuring Rector

Create a rector.php configuration file in your project root. This configuration tells Rector which PHP version to target and which rules to apply:

<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->sets([
        SetList::PHP_80,    // Upgrade to PHP 8.0
        SetList::PHP_70,    // Apply PHP 7.0 changes first
    ]);
    $rectorConfig->paths([__DIR__.'/src']);
};

Notice that we’re applying both PHP_70 and PHP_80 sets. This is important: PHP 7.0 introduced many breaking changes that Rector handles via its PHP_70 set. Jumping straight to PHP_80 without first applying PHP 7.0 rules can leave you with syntax errors. Our recommended configuration applies both sets in order.

Running Rector

Before running Rector, we strongly recommend committing your current state to version control. This way, you can review Rector’s changes and roll back if needed:

$ git add .
$ git commit -m "Pre-rector: baseline PHP 5.6 code"

Now run Rector:

$ vendor/bin/rector process

Rector will apply its transformations to your code. Let’s look at a concrete example of what you can expect. Suppose you have this legacy snippet:

<?php
// Before Rector
$result = mysql_query("SELECT * FROM users WHERE id = " . $_GET['id']);
while ($row = mysql_fetch_assoc($result)) {
    echo $row['name'];
}

After running Rector with the above configuration, you’ll see something like:

<?php
// After Rector (using PDO)
$pdo = new PDO('mysql:host=localhost;dbname=your_db;charset=utf8', 'user', 'pass');
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $_GET['id']]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    echo $row['name'];
}

You also may notice that Rector converted mysql_* functions to PDO—a significant improvement, as the original mysql_* extension was removed in PHP 7.0. Of course, Rector’s conversion is mechanical; it doesn’t understand your database credentials or connection logic. You’ll need to adjust the PDO connection details to match your actual configuration.

Other Tools to Consider

Rector handles a broad range of transformations, but you can complement it with other static analysis tools:

PHPStan (level 8 or higher) finds type errors and other bugs without executing code. It’s stricter than Rector and helps you identify areas that need manual attention.

$ vendor/bin/phpstan analyse src --level=max

Psalm is an alternative static analyzer with slightly different strengths. Some teams prefer its error messages or its integration with specific IDEs.

PHP_CodeSniffer with the PHPCompatibility ruleset can flag code that uses removed or deprecated features:

$ phpcs --standard=PHPCompatibility src

These tools work well together: Rector applies automated fixes, while PHPStan and Psalm identify issues that require manual resolution.

Alternative Approaches

Of course, Rector isn’t the only migration strategy. Some teams prefer a more manual approach, updating files incrementally as they work on features. This can be appropriate for smaller codebases or when you want to maintain continuous delivery without a dedicated migration branch.

Another approach is to upgrade incrementally through PHP 7.0, 7.1, 7.2, 7.3, 7.4, and then to 8.0+. This is more conservative but requires more steps. Our recommendation for most teams is the direct 5.6 → 8.x approach using Rector, as it’s typically faster and the tools have matured enough to handle the gap reliably.

Phase 3: Handling Major Breaking Changes

While tools catch much of the grunt work, you still need to understand the most significant breaking changes between PHP 5.6 and 8.x. Here are the major categories you’ll confront. We recommend addressing these systematically after running Rector, using your test suite to verify each change.

Removal of mysql_* Functions

The original mysql_ extension was deprecated in PHP 5.5 and removed in PHP 7.0. You must migrate to either MySQLi or PDO. PDO is generally preferred because it supports multiple databases and offers a cleaner API.

// Old style (PHP 5.6)
$link = mysql_connect('localhost', 'user', 'pass');
mysql_select_db('database', $link);
$result = mysql_query('SELECT * FROM table');
$row = mysql_fetch_assoc($result);

// PDO style (PHP 7+)
$pdo = new PDO('mysql:host=localhost;dbname=database;charset=utf8', 'user', 'pass');
$stmt = $pdo->query('SELECT * FROM table');
$row = $stmt->fetch(PDO::FETCH_ASSOC);

You may notice the PDO approach requires more upfront code to establish the connection, but it’s more secure (supports prepared statements) and flexible (works with multiple database types). Though Rector often handles this conversion automatically, you should review the generated code to ensure proper error handling and connection management.

The each() Function Removed

each() was commonly used with list() to iterate arrays. It was removed in PHP 7.2. Replace it with a simple foreach.

// Before
while (list($key, $value) = each($array)) {
    echo "$key => $value\n";
}

// After
foreach ($array as $key => $value) {
    echo "$key => $value\n";
}

This change is straightforward. Rector handles it automatically.

Engine Exceptions and Error Handling

Internal functions now throw Error exceptions (such as TypeError, ParseError) instead of issuing fatal errors. Code that relied on set_error_handler() to catch all problems will need explicit try...catch blocks for these error types.

Consider this example:

<?php
// PHP 5.6
set_error_handler(function($errno, $errstr) {
    echo "Error caught: $errstr";
    return true; // Suppress the error
});

$result = strpos(null, 'test'); // Would trigger a warning
restore_error_handler();

In PHP 7+, the above code might not catch all errors because TypeError is thrown as an exception. You’d need:

<?php
// PHP 7+
try {
    $result = strpos(null, 'test');
} catch (TypeError $e) {
    echo "TypeError caught: " . $e->getMessage();
}

You also may notice that error handling becomes more structured with exceptions, which can make debugging easier but requires updating legacy error handlers.

Stricter Type Checking

PHP 7 introduced scalar type declarations and return type declarations. PHP 8 is even stricter. Code that passed the wrong type to a function may now throw a TypeError. While you can loosen types with the ? and mixed keywords, it’s better to fix the root cause.

// PHP 5.6 - implicit string to int conversion
function add($a, $b) { return $a + $b; }
add('5', 10); // Returns 15 - no error

// PHP 7+ - with strict_types=1, this throws TypeError
declare(strict_types=1);
function add(int $a, int $b): int { return $a + $b; }
add('5', 10); // TypeError: Argument 1 must be of type int

One may wonder: should you enable strict types immediately? We recommend enabling strict_types=1 on a case-by-case basis as you modernize modules—but be aware that mixed code will behave differently. The safest approach initially is to add type hints without strict mode, then gradually enable strict types in well-tested areas.

Reserved Keywords as Identifiers

Words like string, bool, int, float, and iterable are now reserved. If you have a class, trait, interface, or function named String, you’ll need to rename it. This often surfaces in older code that used String as a name for utility classes.

// Will cause a fatal error in PHP 7+
class String {
    public static function capitalize($text) {
        return ucfirst($text);
    }
}

You’ll need to rename such classes, perhaps to StringHelper or TextUtil. This is a mechanical change but can be widespread in large codebases.

Constructor Property Promotion

PHP 8.0 introduced constructor property promotion, which can simplify class definitions:

// Before
class User {
    public string $name;
    public int $age;
    public function __construct(string $name, int $age) {
        $this->name = $name;
        $this->age = $age;
    }
}

// After (PHP 8.0+)
class User {
    public function __construct(
        public string $name,
        public int $age
    ) {}
}

You can adopt this syntax as you refactor classes—though it’s not required for compatibility. It does, however, make code more concise. Though Rector can apply this transformation automatically, we recommend using it selectively on classes you’re actively working with, as blanket conversion can make diffs harder to review.

Other Notable Changes

Several other removals and changes warrant attention:

  • ereg and split (POSIX regex) functions were removed; use PCRE (preg_*) instead.
  • The /e modifier for preg_replace was removed due to security concerns. Replace with preg_replace_callback.
  • create_function() was removed; use anonymous functions instead.
  • md5() and sha1() now return lowercase hex strings; if you compare against uppercase strings, use strcasecmp() or normalize with strtolower().
  • The ereg extension is entirely removed; convert all ereg_* calls to preg_*.

There are many more changes—too many to list exhaustively here. For a complete inventory, consult the official PHP migration guides: php.net/manual/en/migration70.incompatible.php and php.net/manual/en/migration80.incompatible.php.

Phase 4: Testing, Testing, and More Testing

Your test suite is the safety net that lets you refactor with confidence. If you lack comprehensive tests, this phase will require extra effort—but it’s non-negotiable for a production system.

Unit Tests

Use a framework like PHPUnit or PestPHP. Run tests continuously as you apply Rector changes. Expect failures initially; fix them one by one.

$ vendor/bin/phpunit

If you don’t have tests yet, start by adding them for the most critical paths: authentication, data modification, payment processing, or whatever represents your application’s core functionality. Though writing tests for legacy code can feel like a chore, it pays dividends quickly during migration.

Integration Tests

Verify that different components work together. This is especially important for database interactions and external API calls. Integration tests catch issues that unit tests might miss—such as database schema mismatches or misconfigured connections.

End-to-End Tests

Tools like Codeception or Behat simulate real user interactions through a browser. They catch issues that unit tests might miss—such as JavaScript errors or layout problems that arise from changed HTML output.

Performance Benchmarks

Establish a baseline for critical request times under PHP 5.6. After upgrading, run the same benchmarks to confirm the expected gains.

You might set up a simple benchmarking script:

<?php
// benchmark.php - Run this under both PHP 5.6 and PHP 8.x
$start = microtime(true);
// Simulate representative workload
for ($i = 0; $i < 1000; $i++) {
    $data = json_encode(['id' => $i, 'name' => "User $i"]);
    $parsed = json_decode($data, true);
}
$end = microtime(true);
echo "Time: " . ($end - $start) . " seconds\n";

Run it with:

$ time php benchmark.php

Notice the use of time to capture real/user/sys times. Though microtime gives you precise measurement, the time command shows overall resource usage. You also may want to run multiple iterations and average the results.

Low Test Coverage Scenarios

Low test coverage is a high-distress situation. If you can’t achieve reasonable coverage before the upgrade, schedule extra time for manual exploratory testing of critical paths. At minimum, identify the most important user flows—login, checkout, data export—and verify them meticulously after each major change.

One may wonder: what constitutes “reasonable” coverage? That depends on your application’s complexity, but as a rule of thumb, aim for at least 50% coverage on critical business logic, with 100% coverage on payment and security-related code if feasible.

Troubleshooting: Common Issues and Solutions

Based on real-world migration experience, here are a few traps to watch out for. We’ve organized these by frequency and impact.

Assuming Rector Fixes Everything

Problem: Blindly accepting all Rector changes without review.

Solution: Rector handles mechanical transformations brilliantly, but it cannot reason about application logic. Review its changes carefully—especially around database queries and error handling—to ensure the behavior remains correct. We recommend running Rector on a file-by-file basis, running tests after each batch of changes, and committing frequently.

Neglecting Third-Party Code

Problem: Vendored dependencies (committed into your repository) may not be upgraded automatically if they’re not on the include path; Composer dependencies may block PHP 8.x.

Solution: If you vendor dependencies, ensure Rector processes the vendor/ directory (though you may want to exclude somevendor code). If you rely on Composer to fetch packages, verify each package supports PHP 8.x using composer why-not php/php:^8.0. Some older packages may need to be replaced entirely. Check if newer versions exist; if not, consider forking and upgrading the package yourself, or finding alternatives.

Skipping the 7.x Steps

Problem: Running Rector with only SetList::PHP_80 and encountering syntax errors.

Solution: As mentioned earlier, PHP 7.0 introduced many breaking changes that Rector handles via its PHP_70 set. Our recommended configuration applies both PHP_70 and PHP_80 sets. If you’re starting from PHP 5.6, you need both. Though it might seem inefficient to apply PHP 7.0 changes to a codebase running PHP 8.x, these rules transform syntax that would otherwise be invalid—like converting mysql_* functions or each() constructs.

Forgetting About Extensions

Problem: Required PHP extensions are missing in the PHP 8.x environment.

Solution: Verify that all required PHP extensions have PHP 8.x equivalents and are installed. Common ones like mbstring, gd, curl, and pdo_mysql are still present, but configuration directives may have changed. Run php -m on both your old and new environments and compare the module lists. Install any missing extensions in your PHP 8.x environment before testing.

Ignoring Warnings

Problem: PHP 8.x is more opinionated about dubious code (e.g., passing null to a non-nullable parameter). Treating warnings as ignorable leads to runtime issues.

Solution: Treat every warning as a potential bug—fix them before they become production issues. Enable error_reporting = E_ALL in your development environment and address all notices, warnings, and deprecations. While this may produce many messages initially, they’ll point you directly to problem areas.

Type Hint Mismatches

Problem: After Rector adds type hints, your code may pass wrong types due to implicit conversions that worked in PHP 5.6.

Solution: Use PHPStan at --level=max to identify type mismatches. Then, either fix the actual code to pass the correct types or adjust the type hints if they’re too restrictive. Remember: Rector adds type hints based on inferred types; these inferences can be wrong in complex logic. Review each added type hint for correctness.

Deprecated Functions

Problem: Your code uses functions that were deprecated in PHP 7.x and removed in PHP 8.x.

Solution: PHP_CodeSniffer with PHPCompatibility will flag these. Replace deprecated functions with modern alternatives:

  • split()explode() or preg_split()
  • ereg()preg_match()
  • mysql_*mysqli_* or PDO
  • each()foreach
  • create_function() → anonymous functions

Case Sensitivity in Hash Functions

Problem: md5() and sha1() now return lowercase hex strings; code comparing against uppercase strings will fail.

Solution: Normalize comparisons using hash_equals() with lowercase strings, or use strcasecmp() for case-insensitive comparison. Better yet, standardize on lowercase throughout.

Verification and Testing

How do you know when the migration is complete and successful? Here are our recommended verification steps.

Run Your Full Test Suite

$ vendor/bin/phpunit --coverage-text

All tests should pass. Coverage isn’t strictly required, but running with coverage helps identify gaps.

Manual Smoke Testing

After automated tests pass, manually exercise critical user flows:

  • Login and logout
  • CRUD operations on all major data types
  • File uploads and downloads
  • Third-party integrations (payment gateways, APIs)
  • Admin functions

Pay special attention to edge cases that your test suite may not cover.

Performance Verification

Run your benchmarks from earlier. You should see performance improvements in most applications, though the exact gain depends on your workload. CPU-bound code benefits most from the JIT compiler introduced in PHP 8.0.

Dependency Audit

Run Composer commands to ensure your dependency tree is healthy:

$ composer validate --strict
$ composer install --dry-run

These commands verify that your composer.json is valid and that all dependencies can be installed without conflicts.

Static Analysis Final Pass

Run PHPStan at the highest level your codebase can tolerate:

$ vendor/bin/phpstan analyse src --level=max

Address all errors and warnings. While you may not achieve zero issues immediately, aim to resolve everything that indicates real problems.

Conclusion: Long-Term Maintenance

Migrating a legacy PHP 5.6 application to PHP 8.x is more than a version bump—it’s a modernization project that pays dividends in security, performance, and developer satisfaction. By following a phased approach—preparing thoroughly, leveraging automated tools like Rector, addressing breaking changes methodically, and relying on a solid test suite—you can manage the migration with confidence.

Remember: you don’t have to do this alone. The PHP community has produced excellent tools and documentation. When in doubt, consult the official migration guides and Stack Overflow. And if the migration feels overwhelming, consider engaging experts familiar with the process—sometimes an investment in outside help pays for itself by accelerating the timeline and reducing risk.

After the upgrade, consider adopting stricter coding standards, improving test coverage, and gradually incorporating newer PHP features (like union types, attributes, and enums) to keep the codebase healthy. The migration is a milestone, not an endpoint. Your journey to a maintainable, modern PHP application continues.

Good luck, and happy upgrading!

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming