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

Complete Guide to Upgrading from PHP 7.4 to PHP 8.0


In November 2022, PHP 7.4 reached its end of life. From that moment forward, no new security patches would be released for this widely-used version. For organizations running PHP 7.4 in production, a fundamental choice emerged: remain on an unsupported version, or undertake the work required to move to PHP 8.0.

One may wonder: is this upgrade worth the effort? The answer depends on your specific circumstances. Security considerations alone often make the upgrade necessary—but PHP 8.0 brings more than just security patches. It introduces a Just-In-Time compiler, modern language features like named arguments and attributes, and stricter type checking that can expose latent bugs in your codebase.

Of course, upgrading a major version is never trivial. Breaking changes exist, dependencies may need updating, and your team will need to learn new patterns. In this guide, we’ll walk you through what you need to know to make an informed decision and execute a successful upgrade. We’ll examine the new capabilities, identify potential pitfalls, and walk through a systematic upgrade process that prioritizes safety and understanding. By the end, you’ll have a clear roadmap for navigating this transition.

Why Consider Upgrading to PHP 8.0?

Before we dive into the upgrade process, let’s examine why you might want to move to PHP 8.0—and what trade-offs to consider. One may wonder: given that PHP 8.0 introduces breaking changes, why bother upgrading at all? The answer has several dimensions, with security being the most urgent.

The Security Imperative

The most practical reason to upgrade is security. PHP 7.4 reached its end of life for security updates in November 2022. If you’re running 7.4 in production, you’re no longer receiving patches for newly discovered vulnerabilities. This isn’t merely theoretical; security researchers actively monitor older branches for unpatched issues.

Of course, security isn’t the only factor. PHP 8.0 also introduces substantive improvements that may benefit your application.

Performance Considerations

PHP 8.0 includes a Just-In-Time (JIT) compiler. However, it’s important to understand its actual impact: the JIT compiler primarily helps CPU-intensive workloads—mathematical computations, data processing, image manipulation. For typical web applications that are I/O bound (database queries, network requests), the performance gain is often modest.

Your application may or may not fall into the category that benefits substantially. We’ll examine this more closely when we discuss measuring performance.

Language Improvements

PHP 8.0 adds several language features that address longstanding pain points:

  • Named arguments reduce errors when calling functions with multiple optional parameters
  • Constructor property promotion reduces boilerplate in class definitions
  • Union types provide more flexible type declarations
  • Attributes offer a native way to add structured metadata
  • Match expressions provide a safer alternative to switch statements
  • Nullsafe operator simplifies chaining on nullable objects

These features don’t just make code more concise; they also help prevent entire categories of bugs. For new development, they’re significant quality-of-life improvements.

For existing codebases, translating to these features is optional—you can run PHP 8.0 without immediately refactoring your code. However, some of the stricter type checks may expose latent bugs.

The Trade-offs to Consider

Upgrading a major version is never trivial. You should weigh:

  • Compatibility risk: Your dependencies may not support PHP 8.0 yet
  • Testing effort: Even if all tests pass, you may encounter subtle runtime differences
  • Breaking changes: Some functions behave differently; some are removed entirely
  • Team expertise: Your team may need to learn new patterns and features

We’ll discuss how to evaluate these factors systematically in the pre-upgrade checklist.

Pre-Upgrade Checklist

Before changing a single line of code, preparation is crucial. Let’s walk through the essential steps.

1. Full Backup: Create a Restore Point

You should begin by ensuring you can return to the current state if needed. This means both your codebase and database.

If you’re using version control (which you should be), commit all current changes:

$ git add .
$ git commit -m "Pre-PHP 8.0 upgrade backup"

For production databases, create a fresh backup. The exact command depends on your database:

# For MySQL or MariaDB
$ mysqldump -u username -p database_name > backup_$(date +%Y%m%d).sql

# For PostgreSQL
$ pg_dump -U username database_name > backup_$(date +%Y%m%d).sql

Verify your backup is usable before proceeding. You can test restore it to a separate environment if feasible.

2. Dependency Audit: What Works with PHP 8.0?

Your codebase’s third-party packages may or may not support PHP 8.0. We need to identify which ones require updates.

Start by checking your current PHP platform in Composer’s configuration. From your project root:

$ php --version
PHP 7.4.33 (cli) (built: Feb 17 2023 16:57:09) ...

Now examine your composer.json file. Look at the require section to see what packages you’re using and their version constraints.

You can use Composer’s platform config to simulate PHP 8.0:

$ composer config platform.php 8.0.0

Then check for outdated packages that have PHP 8.0 compatible versions:

$ composer outdated

Of course, this requires that you have a composer.lock file from an existing project. If you’re starting from scratch, you would instead use composer require with version constraints.

The output will look something like this:

Package            Status   Local  Latest  Description
monolog/monolog    update   v2.0   v2.9.1  Sends your logs to files, sockets, inboxes, databases...
symfony/console    update   v5.4   v6.3.0  Eases the creation of beautiful and testable command line applications.

Of course, this only shows version differences. Some packages may require a major version change that introduces breaking changes of its own. That’s why you should also consult the changelog or upgrade guide for your critical dependencies.

A practical approach is to check each package’s compatibility directly on Packagist.org. Search for your package and review the “require” section to see which PHP versions are supported by each version.

For widely-used packages like Laravel, Symfony, Guzzle, etc., you can also check their official documentation for specific PHP 8.0 upgrade guides.

3. Local Environment: Never Upgrade Directly on Production

You must test the upgrade in an environment that mirrors production as closely as possible. This typically means:

  • PHP 8.0 (or newer) installed locally or in a VM/container
  • Same database version as production
  • Same web server (Apache/Nginx) configuration where applicable
  • Access to the same external services (APIs, etc.)

If your local machine runs a different OS than production, consider using Docker or a virtual machine to improve fidelity. We won’t cover specific setup instructions for every platform, but here are some common approaches:

On Ubuntu/Debian:

$ sudo apt update
$ sudo apt install php8.0 php8.0-cli php8.0-common php8.0-curl php8.0-mbstring php8.0-mysql php8.0-xml

On macOS with Homebrew:

$ brew install php@8.0

Using Docker:

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

Once you have PHP 8.0 available, verify it’s working:

$ php -v
PHP 8.0.28 (cli) (built: Feb 16 2023 08:13:14) ...

4. Establish a Baseline: Ensure PHP 7.4 Tests Pass

Before making any changes, confirm your application works correctly on PHP 7.4 with all tests passing. This is your baseline.

Run your test suite:

$ vendor/bin/phpunit

Or if you use a different testing framework:

$ vendor/bin/pest
$ vendor/bin/codecept run

All tests should pass. If they don’t, fix them first. Don’t mix the two concerns—don’t try to fix failing tests and upgrade PHP at the same time.

Once you have a green test suite on PHP 7.4, you’re ready to proceed. When we later test on PHP 8.0, any new failures will be directly attributable to the PHP version change rather than unrelated code issues.

PHP 8.0 Features: What Actually Changes

Let’s examine the significant new features in PHP 8.0, with attention to when they matter and how they affect real code.

JIT (Just-In-Time) Compiler

PHP 8.0 introduces a JIT compiler—a feature that recompiles frequently-executed code into optimized native machine code at runtime. This is one of the most talked-about additions.

However, the practical impact varies considerably. Traditional PHP web applications remain I/O bound (waiting for database queries, file operations, or network responses). The JIT compiler offers little benefit in these cases where most time is spent waiting rather than computing.

The gains are more substantial for CPU-bound workloads:

  • Numerical computations and data processing
  • Image manipulation
  • Complex parsing or encoding tasks
  • Long-running CLI scripts that perform calculations

Measuring the actual difference for your specific workload is the only way to know if it matters. For some applications, benchmarks show 30-50% improvement; for others, the difference is negligible.

Enabling and Configuring the JIT Compiler

The JIT compiler is disabled by default. It’s configured in your php.ini file.

Add or modify these settings:

opcache.enable=1
opcache.jit_buffer_size=100M
opcache.jit=1235

The opcache.jit setting accepts different levels (0-1235):

LevelDescription
0Disabled
1235”tracing” JIT - most aggressive, compiles entire traces
1020”function” JIT - compiles hot functions

The “tracing” level (1235) is generally recommended for maximum performance, though it uses more memory.

After changing php.ini, restart your PHP-FPM or Apache process.

Example: Measuring JIT Impact

Suppose you have a CPU-intensive script that calculates prime numbers:

<?php
// prime.php
function isPrime(int $n): bool {
    if ($n <= 1) return false;
    for ($i = 2; $i * $i <= $n; $i++) {
        if ($n % $i === 0) return false;
    }
    return true;
}

$start = microtime(true);
$count = 0;
for ($i = 2; $i < 100000; $i++) {
    if (isPrime($i)) {
        $count++;
    }
}
$end = microtime(true);

echo "Found $count primes\n";
echo "Time: " . ($end - $start) . " seconds\n";

Run this with JIT disabled and enabled to compare. You’ll typically see something like:

# Without JIT
Found 9592 primes
Time: 2.456 seconds

# With JIT (level 1235)
Found 9592 primes
Time: 1.672 seconds

Your results will vary based on hardware and PHP build, but for this type of workload, a 30-40% improvement is plausible.

Of course, the only way to know which category your application falls into is to measure it yourself. You might be pleasantly surprised, or you might find the JIT has minimal impact—both outcomes are valid and inform your optimization priorities.

Named Arguments

Functions with many optional parameters have always been a maintenance headache. You need to remember the order of parameters, even if you only want to specify one optional value. Named arguments solve this.

Before and After

Consider the htmlspecialchars function:

// Before: Need to remember parameter positions
htmlspecialchars($string, ENT_COMPAT | ENT_HTML401, 'UTF-8', false);

To set double_encode to false, you had to specify all preceding parameters even if you wanted their defaults.

With named arguments:

htmlspecialchars($string, double_encode: false);

Only the parameters you specify are passed; all others use their defaults.

When Named Arguments Shine

This pattern is most valuable with functions that have multiple optional parameters, particularly:

  • Configuration arrays that could be replaced with optional parameters
  • Complex constructors with several settings
  • Functions with boolean flags where the meaning of true and false isn’t obvious

For example:

// Old: What does false mean here? You need to consult the docs.
imagefilter($image, IMG_FILTER_GRAYSCALE, 0, 0, 0, 0);

// New: Self-documenting
imagefilter($image, IMG_FILTER_GRAYSCALE, brightness: 0, contrast: 0);

Important Caveats

Named arguments are evaluated left-to-right. You cannot pass a named argument before a positional argument:

// This is INVALID:
htmlspecialchars(string: $string, $string, double_encode: false);

// This is VALID:
htmlspecialchars($string, double_encode: false);

Additionally, named arguments are a runtime feature. The parameter names must exist in the function signature; changing parameter names in a function you call will break your code. This is different from positional arguments where you could change parameter names as long as order stayed the same.

In practice, this means that libraries need to be careful about renaming parameters in existing function signatures—though this is relatively rare.

When to Use Them

In your own code, you can use named arguments for all new function and method definitions where it makes sense. They’re particularly helpful for:

  • DTO constructors with many optional fields
  • Configuration builder patterns
  • API endpoints with many optional parameters

However, don’t overuse them for every single function call. For simple, well-known function signatures (like array_map($callback, $array)), positional arguments remain concise and clear.

For library authors: consider whether your public API functions would benefit from optional named parameters. You can maintain backward compatibility—existing code using positional arguments will continue to work.

Attributes

Attributes provide a native, structured way to add metadata to classes, methods, properties, and functions. Before PHP 8.0, frameworks and libraries parsed docblock comments to achieve similar functionality—a brittle approach that required string parsing.

The Basics

Attributes use the #[...] syntax:

#[Attribute]
class ApiResource
{
    public string $name;
}

#[ApiResource(name: 'User')]
class UserController
{
    // ...
}

You can have multiple attributes:

#[Route('/api/posts/{id}', methods: ['GET'])]
#[Cache(ttl: 3600)]
#[ middleware(AuthMiddleware::class)]
class PostController
{
    // ...
}

Reading Attributes

Attributes are meaningless unless something reads them. Here’s how you can inspect them using reflection:

<?php
#[Route('/api/users', methods: ['GET'])]
class UserController {}

$reflection = new ReflectionClass(UserController::class);
$attributes = $reflection->getAttributes(Route::class);

if (!empty($attributes)) {
    $route = $attributes[0]->newInstance();
    echo "Route: " . $route->path . "\n";
    echo "Methods: " . implode(', ', $route->methods) . "\n";
}

If you run this, you’ll see:

Route: /api/users
Methods: GET

Built-in Attributes

PHP itself includes some attributes. The most useful is #[Deprecated]:

#[Deprecated('Use the newMethod() instead')]
function oldMethod(): void
{
    // ...
}

When someone calls oldMethod(), PHP will emit an E_DEPRECATED warning with your message. This is much cleaner than manually triggering a trigger_error().

Attribute Targets

Attributes can be applied to specific language constructs. You control this with flags:

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Loggable
{
    public string $category;
}

Valid targets include:

  • Attribute::TARGET_CLASS
  • Attribute::TARGET_FUNCTION
  • Attribute::TARGET_METHOD
  • Attribute::TARGET_PROPERTY
  • Attribute::TARGET_PARAMETER
  • Attribute::TARGET_CLASS_CONSTANT
  • Attribute::TARGET_ALL

Using #[Attribute] without flags means the attribute applies to all targets.

Multiple Instances

By default, you can only have one instance of a given attribute class on a target. To allow multiple, set the $isRepeatable property:

#[Attribute]
class Tag
{
    public function __construct(public string $name) {}
}

#[Tag('api'), Tag('v1'), Tag('experimental')]
class ApiController {}

Comparison with Docblock Annotations

Attributes solve several problems with docblock parsing:

  • They’re native syntax, not comments that require string parsing
  • They’re structured data, not unstructured text
  • Static analysis tools can understand them
  • They’re expressive—can use expressions, constants, etc.
  • They won’t conflict with actual documentation (PHPDoc has its own purpose)

The transition from docblock annotations to attributes is why you’ll see both patterns in mixed codebases during the upgrade period.

When to Use Attributes

Frameworks like Laravel, Symfony, and Doctrine have begun using attributes for routing, ORM mapping, validation rules, and more. In your own code, consider attributes for:

  • API endpoint metadata (routes, rate limits, permissions)
  • Custom validation rules
  • Event listeners and subscribers
  • Caching configuration
  • Documentation generation tags

Avoid overusing attributes for configuration that doesn’t need to be close to the code—YAML or PHP configuration files are still appropriate for some use cases.

Constructor Property Promotion

The Problem It Solves

In PHP 7.4 and earlier, constructing objects required repetitive boilerplate: declare properties, then assign each constructor parameter to its corresponding property. For classes with many properties, this adds noise and creates opportunities for mistakes.

The Syntax

Constructor property promotion combines property declaration and assignment in the constructor signature:

// Before: 12 lines
class Money
{
    public Currency $currency;
    public int $amount;
    public ?DateTime $timestamp;

    public function __construct(Currency $currency, int $amount, ?DateTime $timestamp = null)
    {
        $this->currency = $currency;
        $this->amount = $amount;
        $this->timestamp = $timestamp;
    }
}

// After: 7 lines (40% reduction)
class Money
{
    public function __construct(
        public Currency $currency,
        public int $amount,
        public ?DateTime $timestamp = null,
    ) {}
}

Notice that:

  • Properties are declared with visibility (public, protected, private) in the constructor
  • Default values work normally
  • The constructor body can be empty if all assignment is handled by promotion

Using It with Variadic Parameters

You can combine property promotion with variadic parameters:

class VarArgsExample
{
    public function __construct(
        public int $id,
        public string ...$tags,
    ) {}
}

$example = new VarArgsExample(42, 'php', 'upgrade', 'security');
// $example->tags = ['php', 'upgrade', 'security']

Limitations and Gotchas

Property promotion only handles simple assignment. If you need logic in the constructor—validation, transformation, side effects—you still need a body:

class ValidatedUser
{
    public function __construct(
        public string $email,
        public string $name,
    ) {
        if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: {$this->email}");
        }
    }
}

Also, promoted properties cannot have different visibility from the constructor parameter. The visibility modifier in the constructor determines the property’s visibility.

Union Types

Before PHP 8.0: Limited Type Flexibility

Prior to PHP 8.0, you could declare a single type or leave a parameter untyped—there was no way to say “this accepts either int or float.” Workarounds included:

  • Leaving the parameter untyped and checking with is_int() || is_float() at runtime
  • Using @var int|float docblocks (which static analysis tools understood but runtime did not)

Union Types at Runtime

With union types, the runtime actually enforces that the value matches one of the allowed types:

class Number
{
    private int|float $number;

    public function setNumber(int|float $number): void
    {
        $this->number = $number;
    }

    public function getNumber(): int|float
    {
        return $this->number;
    }
}

// Valid:
$number = new Number();
$number->setNumber(42);      // int
$number->setNumber(3.14);    // float

// Invalid: TypeError
$number->setNumber("42");    // string not allowed

When a TypeError is thrown, it tells you which union types were expected:

PHP Fatal error:  Uncaught TypeError: Number::setNumber(): Argument #1 ($number) must be of type int|float, string given

The mixed Type

PHP 8.0 also introduces the mixed type, which is equivalent to array|bool|int|float|null|object|resource|string. It’s rarely needed since parameters without type are effectively mixed, but it can be useful for self-documenting code where you genuinely accept any type.

Union Types and Null

Before PHP 8.0, nullable types were indicated with ?:

function process(?string $input): void {}

In PHP 8.0, you can also write:

function process(string|null $input): void {}

Both are equivalent. The ? syntax remains shorter and is still preferred for single nullable types.

When to Use Union Types

Union types are most valuable when:

  • A parameter can legitimately be one of a small set of types
  • You want to be precise about accepted types without using mixed
  • You’re writing a library and want to document expectations clearly

Common examples:

  • int|float for numeric values that might be either
  • string|Stringable for values that can be either strings or objects implementing __toString()
  • array|Traversable for collections
  • Resource|string for configuration that accepts either a resource handle or a path

Avoid large unions; if you find yourself with 4+ types, reconsider whether a simpler approach would be better.

Match Expression

The match statement—inspired by similar constructs in other languages—addresses several shortcomings of switch.

Switch’s Limitations

Remember these switch pitfalls:

  • Implicit loose comparison (== instead of ===)
  • No forced return value (you have to break to avoid fallthrough)
  • Case values are not exhaustive (no check for missing cases)
  • Cannot match on expressions (only simple values)

Match: Strict Comparison by Default

match uses strict comparison (===):

$value = "1";
$result = match ($value) {
    '1' => 'String 1',  // Matches
    1 => 'Integer 1',   // Doesn't match
};
// $result = 'String 1'

With switch, '1' == 1 evaluates to true, which can cause subtle bugs.

Match Returns a Value

match is an expression, not a statement. It returns a value:

$status = match ($httpCode) {
    200, 201, 202 => 'success',
    404 => 'not_found',
    500 => 'server_error',
    default => 'unknown',
};

You can assign this directly to a variable, return it, or use it inline.

No Fallthrough

Each arm of a match is complete. You cannot accidentally fall through to the next case. This eliminates a whole class of bugs.

Exhaustiveness Checking (PHP 8.1+)

In PHP 8.1, match has improved exhaustiveness checking for enums. If you match against an enum without a default, the compiler will warn you if you haven’t handled all cases. This is a significant safety improvement.

When to Use Match

match is appropriate when:

  • You need a strict comparison (===)
  • You want an expression that returns a value
  • You have a fixed set of discrete values to handle
  • You want to avoid fallthrough bugs

switch remains useful for:

  • Cases that share code (though you can call a function from multiple match arms)
  • Very old codebases where compatibility matters (but you’re upgrading anyway)
  • When you deliberately want loose comparison (rare)

Nullsafe Operator

The Problem: Defensive Null Checks

Accessing properties or methods on potentially null objects requires careful null checking:

// Traditional approach
if ($session !== null) {
    $user = $session->user;
    if ($user !== null) {
        $address = $user->getAddress();
        if ($address !== null) {
            $country = $address->country;
        }
    }
}

This is verbose and obscures the actual business logic.

The Solution: ?->

The nullsafe operator short-circuits when it encounters null:

$country = $session?->user?->getAddress()?->country;

If $session is null, the whole expression evaluates to null. If $session is not null but $session->user is null, it returns null. And so on.

Works with Methods, Properties, and Chaining

The nullsafe operator works with both property access and method calls:

$name = $user?->getProfile()?->name;
$firstName = $user?->first_name;  // property access

You can chain multiple nullsafe operations:

$result = $a?->b?->c?->d ?: 'default';

Limitations

The nullsafe operator only evaluates the chain; you cannot use it for assignment:

// Invalid:
$user?->name = 'New Name';

// Valid (conditional):
if ($user !== null) {
    $user->name = 'New Name';
}

Also, the nullsafe operator cannot be used for static property or method calls—only instance access:

// Invalid:
MyClass?::staticMethod();

// Valid (null coalescing with class name):
$class = MyClass::class ?? null;

When to Use Nullsafe

Use the nullsafe operator when:

  • You’re working with optional relationships (e.g., a user might not have a profile)
  • You want concise code for null-safe navigation
  • The absence of a value is acceptable (you’ll handle null later)

Avoid it when:

  • null is an exceptional case that should be prevented earlier
  • You need to distinguish between “property is null” and “parent is null”
  • You need side effects in the chain (nullsafe short-circuits them)

Breaking Changes and Deprecations: What You’ll Actually Need to Fix

PHP 8.0 is a major version release, which means backward-incompatible changes exist. Of course, the PHP core team works hard to minimize disruption, but you should expect some work to achieve compatibility.

Let’s examine the changes you’re most likely to encounter, with practical examples and guidance on diagnosis and remediation.

Stricter Type Checks for Internal Functions

This is the most common source of upgrade issues. Internal PHP functions now enforce type checks more rigorously.

Example: strpos and $offset

Previously, you could pass null for the $offset parameter in strpos:

// PHP 7.4: Works (offset treated as 0)
strpos('hello', 'l', null);
// Returns: 2

// PHP 8.0: TypeError
// PHP Fatal error:  Uncaught TypeError: strpos(): Argument #3 ($offset) must be of type int, null given

If you see a TypeError mentioning that an argument must be of a specific type, check whether you’re passing null or a value of the wrong type.

Fix: Ensure you pass an integer, or explicitly cast: strpos($haystack, $needle, (int)$offset).

Example: Countable Interface

In PHP 7.4, count() on a null value returned 0 with a warning:

// PHP 7.4: Warning, returns 0
count(null);  // Warning: count(): Parameter must be an array or Countable

// PHP 8.0: TypeError
// PHP Fatal error:  Uncaught TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given

Fix: Check for null before calling count():

$count = is_countable($value) ? count($value) : 0;
// Or simpler in PHP 8.0+:
$count = count($value ?? []);

Common culprits: Results from database queries that might return false on error, or json_decode() with false result when JSON is malformed.

Example: Arithmetic on Non-Numeric Strings

In PHP 7.4, PHP would silently convert non-numeric strings to numbers in arithmetic operations:

echo '5' + 'foo';   // In PHP 7.4: 5 (with warning)
echo 10 - 'bar';    // In PHP 7.4: 10 (with warning)

PHP 8.0 is stricter:

echo '5' + 'foo';   // PHP 8.0: TypeError
// PHP Fatal error:  Uncaught TypeError: Unsupported operand types: string + string

Fix: Validate or sanitize before arithmetic:

function safeAdd($a, $b) {
    if (!is_numeric($a) || !is_numeric($b)) {
        throw new InvalidArgumentException("Both arguments must be numeric");
    }
    return $a + $b;
}

Or use explicit casting with awareness of edge cases:

$sum = (float)$a + (float)$b;  // 'foo' becomes 0.0
// But beware: is this the intended behavior?

The @ Error Control Operator No Longer Silences Fatal Errors

The @ operator suppresses warnings and notices, but it never suppressed fatal errors. In PHP 8.0, this distinction is clearer: @ no longer suppresses Error exceptions at all.

If your code relies on @ to hide fatal errors, those errors will now terminate script execution:

// This will still be fatal in PHP 8.0:
$result = @file_get_contents('nonexistent.txt');
// Warning is suppressed, but if file_open fails due to other reasons, it may throw an Error

Fix: Proper error handling:

$result = @file_get_contents('nonexistent.txt');
if ($result === false) {
    // Handle error appropriately
    $error = error_get_last();
    // Log, fallback, or throw your own exception
}

// Better still, use exceptions:
try {
    $result = file_get_contents('nonexistent.txt');
} catch (Throwable $e) {
    // Handle error
}

String to Number Comparisons: Looser Behavior

Comparisons between strings and numbers have changed. Previously, PHP would convert the string to a number and compare:

// PHP 7.4
0 == 'foo';      // true (!) 'foo' becomes 0
'123' == 123;    // true (string converted to number)
'abc' == 0;      // true (non-numeric string becomes 0)

// PHP 8.0
0 == 'foo';      // false
'123' == 123;    // false
'abc' == 0;      // false

Why? The previous behavior was confusing and error-prone. Most developers expected string/number comparison to be strict.

How to check if you have this issue: Search your codebase for comparison operators (==, !=, <, >, etc.) where one operand could be a number and the other a string.

The safe approach: Use strict comparison (===) when you need to ensure both value and type match. If you need type conversion, do it explicitly:

// Intentional numeric comparison
if ((int)$input === 42) { }

// Intentional string comparison
if ($input === '42') { }

Throw is Now an Expression

In PHP 7.x, throw was a statement. You could not use it in contexts that expect expressions (like the ternary operator’s result).

PHP 8.0 makes throw an expression:

// PHP 7.x: Syntax error
$value = $condition ? $value : throw new InvalidArgumentException('Invalid');

// PHP 8.0: Valid
$value = $condition ? $value : throw new InvalidArgumentException('Invalid');

This change is backward-compatible; it only adds new capabilities. No old code will break.

Returning null from Non-Nullable Functions

In PHP 7.x, returning null from a function with a non-nullable return type would cause a TypeError at the call site (not in the function). PHP 8.0 throws the TypeError from within the function, making debugging easier.

The distinction rarely matters unless you have custom exception handlers that catch TypeError and depend on where it originated.

Changes to Parameter Unpacking

In PHP 7.x, you could unpack arrays with string keys if they were implicitly reindexed:

// PHP 7.4: Works but issues warning
function foo($a, $b, $c) {}
$args = ['first' => 1, 'second' => 2, 'third' => 3];
foo(...$args);  // Works with warning

// PHP 8.0: Must have sequential numeric keys starting from 0
$args = [1, 2, 3];  // Valid
foo(...$args);

Fix: Ensure arrays you unpack are sequentially indexed:

$args = array_values($assocArray);  // Reindex to 0-based
foo(...$args);

Other Notable Changes

create_function() Removed

The long-deprecated create_function() is removed. If you’re still using it:

// PHP 7.4: Deprecated
$func = create_function('$a', 'return $a * 2;');

// PHP 8.0: Fatal error

Fix: Use anonymous functions:

$func = function ($a) {
    return $a * 2;
};

Each() Function Removed

The each() function is removed:

// PHP 7.4: Deprecated but works
while (list($key, $value) = each($array)) { }

// PHP 8.0: Fatal error

Fix: Use foreach:

foreach ($array as $key => $value) { }

Unpacking Disallowed on New References

You cannot create references during unpacking:

// PHP 7.4: E_NOTICE, unpredictable behavior
function &refFunction() { 
    static $x = 0; 
    return $x++; 
}
list($a, &$b) = [1, 2];  // Sort of works but unreliable

// PHP 8.0: Fatal error
list($a, &$b) = [1, 2];

Fix: Assign references explicitly:

$b = &refFunction();

Resource-to-Number Conversion Removed

Resources are no longer converted to numbers in numeric contexts:

// PHP 7.4
$fp = fopen('file.txt', 'r');
echo (int) $fp;   // Prints a resource ID

// PHP 8.0
echo (int) $fp;   // Warning and 1

Fix: Don’t rely on resource numeric values. If you need to check if a resource is valid, use is_resource() and compare to other resources differently.

mbstring Function Signatures

Functions like mb_strcut() and mb_substr() changed their parameter types. Previously they accepted int or string for the $start parameter; now they require int only:

// PHP 7.4: Accepts '3' as string
mb_substr('hello', '2');

// PHP 8.0: TypeError if string

Fix: Cast to int: mb_substr('hello', (int)$start).

How to Identify Issues in Your Codebase

Rather than guessing what will break, systematic testing catches most issues:

  1. Enable strict error reporting during development:
error_reporting(E_ALL);
ini_set('display_errors', '1');
  1. Use static analysis tools: PHPStan and Psalm can detect many type-related issues statically, before runtime.

  2. Run your test suite on PHP 8.0: This is the single most effective method. All tests should pass. Failures will point directly to problematic code.

  3. Check your logs: PHP 8.0’s error messages are more specific. Look for TypeError, ArgumentCountError, and deprecation warnings.

At this point, don’t be discouraged if you encounter many issues—that’s normal. The fix process is systematic: examine the error, understand the changed behavior, and adjust your code.

What’s Not Changing

It’s worth noting that many PHP features remain stable:

  • Array syntax
  • String interpolation
  • Most procedural functions (with stricter types)
  • OOP basics (classes, interfaces, traits)
  • Closures

Your business logic that doesn’t depend on edge-case function behaviors may require minimal changes.

Handling Deprecated Features

Some functionalities are deprecated but still work in PHP 8.0. They may be removed in PHP 9.0. If you see deprecation warnings:

  1. Note the specific feature being used
  2. Look for recommended alternatives in the PHP documentation
  3. Plan to migrate even if it doesn’t break immediately

For example, each() is already removed. But other deprecations exist:

  • Implicitly nullable parameter types with default null
  • Some libxml constants
  • String and Object type hints (from old PHP 5 era)

Catching these early lets you plan a smoother upgrade path to future PHP versions.

The Upgrade Process: A Step-by-Step Walkthrough

Let’s walk through the actual upgrade process systematically. We’ll assume you’ve completed the pre-upgrade checklist and have a working PHP 7.4 codebase with tests.

We’ll use a sample project to demonstrate. Suppose you have this composer.json:

{
    "require": {
        "php": "^7.4",
        "monolog/monolog": "^2.0",
        "symfony/console": "^5.4"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5"
    }
}

Step 1: Install PHP 8.0 Locally

First, ensure PHP 8.0 is available alongside your existing PHP 7.4. You can have multiple PHP versions installed simultaneously.

On Ubuntu/Debian (using Ondrej PPA):

$ sudo add-apt-repository ppa:ondrej/php
$ sudo apt update
$ sudo apt install php8.0 php8.0-cli php8.0-common php8.0-curl \
    php8.0-mbstring php8.0-mysql php8.0-xml php8.0-zip \
    php8.0-bcmath php8.0-gd

On macOS (Homebrew):

$ brew install php@8.0
# The binary may be named php8.0 or linked as php depending on your setup

On Windows: Download from windows.php.net and install manually, then update your PATH.

After installation, confirm both versions are available:

# Check PHP 7.4 (if still installed)
$ php7.4 -v
PHP 7.4.33 (cli) ...

# Check PHP 8.0
$ php8.0 -v
PHP 8.0.28 (cli) ...

Or if php now points to 8.0:

$ php -v
PHP 8.0.28 (cli) ...

Installing Required Extensions

Many extensions are separate packages. Use php -m to list installed modules. Compare what your production environment has versus what’s available.

If you discover missing extensions, install them. For example, to add imagick for image processing:

$ sudo apt install php8.0-imagick
# or
$ brew install imagick && pecl install imagick

Step 2: Configure Composer to Target PHP 8.0

Composer uses the platform.php config to simulate a PHP version when resolving dependencies. This is crucial—it ensures Composer picks packages that declare PHP 8.0 compatibility.

Safety First: Configuration Backup

Before making changes, note your current Composer config:

$ composer config --list --global | grep platform.php
# Probably nothing, which means Composer uses your actual PHP version

Save the current composer.lock as a baseline:

$ cp composer.lock composer.lock.backup-php74

Set Platform to PHP 8.0

In your project directory:

$ composer config platform.php 8.0.0

This writes to your composer.json:

{
    "config": {
        "platform": {
            "php": "8.0.0"
        }
    }
}

What this means: Composer will now resolve dependencies as if you’re running PHP 8.0, even if you still have PHP 7.4 as your default CLI version temporarily. The actual runtime PHP version is separate; we’ll switch to PHP 8.0 for running commands shortly.

Step 3: Update Dependencies

Now we ask Composer to find versions of your packages that work with PHP 8.0.

Dry Run First

Before making any changes, see what would update:

$ composer update --dry-run

You’ll see output like:

Loading composer repositories with package information
Updating dependencies (including require-dev) for accessibility
Package operations: 12 installs, 4 updates, 0 removals
  - Upgrading monolog/monolog (2.0.0 => 2.9.1)
  - Upgrading symfony/console (5.4.0 => 6.3.0)
  - Installing symfony/polyfill-php80 (1.27.0)
  ...

Important: symfony/polyfill-php80 and similar polyfill packages provide backward-compatible implementations of PHP 8.0 features for older PHP versions. You don’t need these if you’re only targeting PHP 8.0+, but they may be installed as dependencies. If you want to avoid them, you’d need to manually adjust your composer.json version constraints—though this is generally unnecessary.

Perform the Update

If the dry run looks reasonable, proceed:

$ composer update

Composer will:

  1. Resolve dependency graph with PHP 8.0 constraint
  2. Download appropriate package versions
  3. Update composer.lock
  4. Regenerate the autoloader

Watch for warnings like:

  • “Package xyz has ignored its autoload section” — investigate
  • “Conflicts between packages” — may need manual resolution

What If Composer Fails?

Common scenarios:

“Your requirements could not be resolved to an installable set of packages”

This means no combination of your current constraints works with PHP 8.0. You need to:

  1. Identify the incompatible package(s)
  2. Look for newer versions that support PHP 8.0
  3. Check if major version upgrades introduce breaking changes for your code
  4. Consider replacing the package with alternatives if no PHP 8.0 version exists

Example: If you require "some/package": "^1.0" and version 1.x only supports PHP ^7.0, but version 2.x supports PHP ^8.0, you’ll need to manually change your composer.json to "some/package": "^2.0" and then resolve the breaking changes in v2.

Composer output usually indicates which package(s) cause conflicts. Use composer why-not package-name 8.0.0 for diagnostics.

Step 4: Switch to PHP 8.0 for CLI Commands

Before running tests, ensure your php command points to PHP 8.0:

$ php -v
PHP 8.0.28 (cli) ...

One may wonder: can’t we just change the platform setting and keep using PHP 7.4 CLI? The answer is that while Composer can resolve dependencies for PHP 8.0 even when running PHP 7.4, actually executing the code requires PHP 8.0. The type errors we need to catch happen at runtime, so your tests must run under PHP 8.0 to be meaningful.

If it still shows PHP 7.4, you need to adjust your PATH or use the version-specific binary (php8.0).

Composer Wrapper: composer vs php composer.phar

If you use a global Composer installation tied to PHP 7.4, you have options:

  1. Use the PHP 8.0 binary to run Composer:
$ php8.0 /usr/local/bin/composer install
# Or wherever composer.phar resides
  1. Reinstall Composer globally using PHP 8.0:
$ php8.0 -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
$ php8.0 composer-setup.php --install-dir=/usr/local/bin --filename=composer
  1. Use local composer (the composer.json approach):
$ composer config --local bin-dir vendor/bin
$ php8.0 composer.phar install

For our walkthrough, we’ll assume php now resolves to PHP 8.0 when you run commands. Verify consistently:

$ which php
/usr/bin/php  # Should be the 8.0 version
$ php -i | grep "PHP Version"
PHP Version => 8.0.28

Step 5: Install Dependencies with PHP 8.0 Context

Now reinstall your dependencies with Composer using the PHP 8.0 platform setting:

$ composer install

Composer reads the config.platform.php setting from composer.json to resolve dependencies, and your actual PHP binary to run. This ensures the installed packages match the PHP version you’ll be using.

You should see something like:

Loading composer repositories with package information
Installing dependencies from lock file
Package operations: 156 installs, 0 updates, 0 removals
  - Installing monolog/monolog (2.9.1)
  - Installing symfony/console (6.3.0)
  ...

If you get composer.lock out of sync errors, delete composer.lock and run composer update again (after backing up).

Step 6: Run Your Test Suite on PHP 8.0

This is the critical validation step. Run your tests:

$ vendor/bin/phpunit

Or if you use Pest:

$ vendor/bin/pest

Expected outcomes:

  1. All tests pass: Excellent. You may still have runtime issues uncovered only by manual testing, but basic compatibility is good.

  2. Some tests fail with TypeError/ArgumentCountError: These indicate type-related breaking changes. See the “Breaking Changes” section above.

  3. Tests fail with deprecation warnings: These may not stop the test run but indicate code that will break in future versions. Address them.

  4. Tests fail with behavior differences: Some functions changed behavior even without type errors. Compare actual output to expected output to identify semantic changes.

Addressing Test Failures

When a test fails, the error message will point to the specific line. For example:

1) App\Service\PaymentProcessorTest::testProcessWithInvalidAmount
PHPUnit\Framework\Exception: Argument 1 passed to PaymentProcessor::process() must be of type int, string given, called in /path/to/test.php on line 24

This tells you: A test passed a string to a method expecting int.

Look at the test and the code it exercises:

// In test:
$result = $processor->process('100.50');  // String, not int

// In production code:
public function process(int $amount): Result { }

The fix depends on intent:

  • If the function should accept numeric strings, change the type to int|string (union type) or remove the type (not ideal)
  • If the function should only accept integers, fix the test to pass an integer: $processor->process((int)'100.50')
  • If the function should accept floats as well, change the signature to int|float

Often, the test is correct and the production code needs adjustment. Other times, the test was using the wrong type due to a bug in the test setup. Evaluate carefully.

When All Else Fails: Temporary Compatibility Layer

If you need to keep the application running while you gradually fix issues, you can temporarily suppress specific errors—though this should be a last resort:

// Around problematic code (not recommended long-term)
$result = @process($value);  // Silences all warnings/errors from this call

Better: Use conditional code paths:

if (PHP_VERSION_ID >= 80000) {
    // PHP 8.0+ specific behavior
    $result = process($value);
} else {
    // Fallback for PHP 7.x
    $result = process((int)$value);
}

This maintains type safety while allowing gradual migration. However, it should be temporary; the goal is to remove version-checked branches.

Step 7: Static Analysis with Rector

Now that tests pass, we can use automated tools to modernize the code and catch issues our tests might miss.

Install Rector

$ composer require --dev rector/rector

Safety Reminder: Back Up Before Automated Refactoring

Before running Rector—or any automated refactoring tool—ensure your code is committed to version control. Rector makes sweeping changes to your codebase. While it’s generally reliable, you want the ability to roll back if the results aren’t what you expected.

If you haven’t already committed:

$ git add .
$ git commit -m "Pre-Rector automated upgrade"

Run Rector with PHP 8.0 Rules

Rector can automatically apply changes that make code PHP 8.0 compatible:

$ vendor/bin/rector process src --set php80

What Rector does:

  • Converts array() to [] (syntax sugar already in 7.4, but completes)
  • Rewrites create_function() calls to anonymous functions
  • Changes each() to foreach
  • UpdatesNullable types syntax
  • Converts some switch to match where safe

Important: Rector modifies your code. Commit your current state before running it:

$ git add .
$ git commit -m "Before Rector automated upgrade"

Then run Rector. Review the changes:

$ git diff

Rector generally makes safe changes, but review is essential.

Other Static Analysis Tools

Consider also:

  • PHPStan: vendor/bin/phpstan analyse src --level=max
  • Psalm: vendor/bin/psalm --set-baseline=psalm-baseline.xml

These detect type mismatches and potential bugs that might not cause immediate test failures.

Step 8: Manual Testing

Automated tests are necessary but not sufficient. Manual testing catches behavioral issues and UI problems.

Functional Areas to Test

  1. User authentication: Login, logout, password reset
  2. CRUD operations: Create, read, update, delete for all major entities
  3. API endpoints: If you have an API, test endpoints with tools like curl, Postman, or Insomnia
  4. File uploads: Uploads often have extension-related issues
  5. External integrations: Payment gateways, email services, third-party APIs
  6. Background jobs: Queued tasks, scheduled commands
  7. Admin interfaces: Less-tested paths

Browser and Device Coverage

Test on:

  • Chrome, Firefox, Safari, Edge (latest versions)
  • Mobile viewports if responsive
  • Any legacy browsers your application supports (IE11 is unlikely to work with modern PHP versions, but front-end code might still support it)

What to Look For

  • 500 Internal Server Errors: Check your PHP error log
  • Blank pages: Usually a fatal error; check logs
  • Missing assets: Assets might have path issues
  • Forms not submitting: Could be CSRF or session issues
  • Slow performance: JIT might help, but check for regressions

Debugging Tools

If you encounter issues:

  1. Check the PHP error log:
$ tail -f /var/log/php8.0-fpm.log
# Or Apache error log
$ tail -f /var/log/apache2/error.log
  1. Enable display_errors in development only:
display_errors = On
error_reporting = E_ALL
  1. Use Xdebug with your IDE for step debugging.

Step 9: Performance Profiling (Optional)

If JIT is a motivation, measure actual performance:

  1. Install Xdebug for profiling (or use xhprof, blackfire, etc.)
  2. Profile key pages/endpoints before and after upgrade
  3. Compare metrics: response time, memory usage, CPU

Example with time command:

$ time curl -s http://localhost/benchmark.php > /dev/null

# Compare output:
# real 0m0.234s  (before)
# real 0m0.156s  (after)

If you don’t see improvement, JIT may not be helping your workload. That’s okay—upgrade for security and new features, not just performance.

Step 10: Deploy to Staging

Before production, deploy to a staging environment that mirrors production exactly.

  1. Push your code to staging:
$ git push staging main
  1. SSH to staging server and run Composer:
$ cd /var/www/myapp
$ composer install --no-dev --optimize-autoloader
  1. Run any database migrations:
$ php artisan migrate  # Laravel
$ bin/console doctrine:schema:update --force  # Symfony
  1. Clear caches:
$ php artisan cache:clear
$ php bin/console cache:clear --env=prod
  1. smoke test all major workflows.

If staging passes the same manual tests as local, you’re in good shape.

Tip: Before deploying to production, ensure you have a reliable rollback strategy. Keep the previous release directory accessible, maintain recent database backups, and be prepared to quickly restore services if issues arise. It’s also wise to schedule the deployment during a low-traffic window and communicate with stakeholders about potential downtime.

Step 11: Deploy to Production

Production deployment follows similar steps, with added caution:

  1. Schedule during low-traffic window
  2. Maintain rollback capability: Keep the previous release available
  3. Put up maintenance page if downtime is expected:
$ php artisan down --render="errors.maintenance"
# or
$ touch /var/www/myapp/storage/framework/down
  1. Deploy code, run composer, run migrations
  2. Warm caches (opcache, route caches, etc.)
  3. Remove maintenance page:
$ php artisan up
# or
$ rm /var/www/storage/framework/down
  1. Monitor error logs and application metrics closely for the first 24 hours.

Safety Reminder: Version-Specific Deployment

Some deployment tools (Envoyer, Deployer, Capistrano) make this process repeatable. If you’re doing manual deployments, document the steps in a script and test it on staging first.

Never deploy without testing the exact deployment procedure.

Monitoring After Deployment

Set up alerts for:

  • Error rate spikes
  • Performance degradation
  • Database errors
  • Failed background jobs

The first few hours post-upgrade are critical. Have team members available to respond.

If something goes seriously wrong, roll back:

# Typical git-based deployment
$ git revert <deploy-commit>
$ composer install
# Clear caches, etc.

This is why the initial backup matters.

What Success Looks Like

After deployment:

  • No errors in logs
  • All monitoring metrics within normal range
  • Core business flows functional
  • Test suite (if automated tests run in CI/CD) passes

At that point, the upgrade is complete. Keep PHP 7.4 around for a few weeks as a fallback, then you can safely remove it from your infrastructure.

Conclusion: Making the Decision That’s Right for You

We’ve covered quite a bit of ground. Let’s step back and put it all together.

Weighing the Trade-offs

Upgrading from PHP 7.4 to 8.0 is not a decision to take lightly. You need to balance:

  • Security against the effort and risk of upgrade
  • New features against your application’s current stability
  • Dependencies’ PHP 8.0 support against the cost of upgrading them
  • Team capacity for testing and debugging
  • Performance gains that may or may not be meaningful for your workload

There is no universal answer. If you have a stable, well-tested application with no security-sensitive data, and your dependencies work fine on PHP 7.4, you might decide to wait. But eventually, as more packages drop 7.4 support and security vulnerabilities accumulate without patches, the upgrade becomes necessary.

The Methodical Approach Works

If you do decide to upgrade, the systematic process we outlined gives you the best chance of success:

  1. Prepare thoroughly with backups and a clear baseline
  2. Audit dependencies to understand compatibility
  3. Upgrade in a controlled environment (never directly on production)
  4. Fix issues iteratively using test failures and static analysis
  5. Test exhaustively both automated and manual
  6. Deploy with rollback capability and monitor closely

The key is not rushing. We’ve seen teams attempt “big bang” upgrades where they switch versions in production without testing—this rarely ends well.

What About PHP 8.1 and 8.2?

PHP 8.0 is already end-of-life (November 2023). If you’re upgrading now, you might consider going directly to PHP 8.1 or even 8.2. The process is similar, though be aware that each major version introduces its own breaking changes.

Check compatibility:

  • PHP 8.1: Adds enums, readonly properties, fibers, intersection types
  • PHP 2.2: Adds readonly classes, disallowing dynamic properties, randomizer improvements

The good news: if you can get to PHP 8.0, upgrading to 8.1 is usually easier than going from 7.4 to 8.0. Many of the hardest changes happen in the first major version jump. Subsequent upgrades are incremental.

Long-Term Maintenance Mindset

The most durable approach is to treat dependency management as an ongoing activity, not a one-time event. Consider:

  • Regular updates: Update dependencies monthly or quarterly, not yearly
  • Automated testing: Comprehensive tests catch issues before they become crises
  • Read release notes: Subscribe to PHP news feeds, framework newsletters
  • Participate in community: Learn about upcoming changes early

This way, you’re never facing a massive version jump with decades of accumulated technical debt.

Final Thoughts

PHP 8.0 represents a significant step forward for the language. The improved type system, native attributes, JIT compiler, and numerous quality-of-life improvements make writing robust, maintainable code easier.

Will you use every feature immediately? Probably not. But you now have them available, and your codebase is positioned to leverage them incrementally.

The upgrade process, while involving, follows a predictable pattern. The tools (Composer, PHPUnit, Rector) are mature. The documentation—including this guide—is comprehensive. With patience and thorough testing, you can make the transition successfully.

If you go slowly, verify constantly, and keep rollback options available, you’ll emerge with a more secure, more modern codebase ready for the next several years of PHP’s evolution.

We’ve covered security considerations, performance implications, detailed feature documentation, breaking changes with examples, and a complete upgrade walkthrough. If you follow these guidelines, you’re well-equipped to make the transition.

Good luck with your upgrade. And remember: commit early, test often, and don’t be afraid to roll back if something goes seriously wrong.

Sponsored by Durable Programming

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

Hire Durable Programming