PHP 7.4 → 8.013 language changes15 min readEnd of life

PHP 8.0 language changes compared with PHP 7.4

A migration-focused tour of PHP 8.0 language changes: new syntax, stricter engine behavior, removed syntax and deprecations that affect code written for PHP 7.4.

PHP 8.0 is a major release, and for most codebases the migration from PHP 7.4 has two sides. The first is a set of new language features that let you write shorter, safer code: named arguments, constructor property promotion, union types, match, the nullsafe operator and throw as an expression. None of these force you to change existing code, so you can adopt them gradually once your minimum version is 8.0.

The second side is behavior that changes under your feet. These are the items that deserve attention before you upgrade. Loose comparison between numbers and non-numeric strings now compares as strings, so 0 == "foo" is false. Several warnings and notices were promoted: arithmetic on arrays now throws a TypeError, undefined variables and array keys become warnings, and a number of silent failures became Error exceptions. The precedence of the concatenation operator changed relative to + and -, nested ternaries without parentheses are a compile-time error, and curly-brace string offsets such as $str{0} no longer parse. The names match and mixed are now reserved, so any class or namespace segment using them needs renaming.

A third group is deprecations that still run but emit messages. An optional parameter placed before a required one, and sort callbacks that return a boolean instead of an integer, are the two you are most likely to meet in application code.

A sensible upgrade path looks like this:

  • Run your test suite on PHP 7.4 with error_reporting set to E_ALL and fix every notice and deprecation first.
  • Search for == comparisons that mix numbers with user-supplied strings and decide whether strict comparison is what you meant.
  • Search for unparenthesized nested ternaries, {} offsets, and string concatenation mixed with arithmetic in a single expression.
  • Check custom error handlers: the @ operator no longer silences fatal errors, so test error_reporting() & $errno instead of comparing to zero.
  • Review magic methods, because declared parameter and return types are now checked against the expected signatures.
  • Only then start using the new syntax. Because the examples below are written for PHP 8.0 and later, they run unchanged on newer releases.

Each change below states what moved, shows the PHP 7.4 form where one exists, and gives a runnable PHP 8 version.

CHANGED BEHAVIOR

Number to non-numeric string comparison changed

Loose comparison of a number with a non-numeric string now compares them as strings, so 0 == "foo" is false.

In PHP 7.4, a non-strict comparison between an integer and a non-numeric string converted the string to a number, which turned every such string into zero. That is why 0 == "foo" and 0 == "" were both true. PHP 8.0 instead casts the number to a string and compares the two strings whenever the string is not numeric. Comparisons with genuinely numeric strings, including ones with leading whitespace such as " 42", still work numerically, and trailing whitespace is now allowed too.

The practical risk is code that relied on the old coincidence: in_array, switch and == checks against user input or database values can quietly flip their result. Review them and prefer === or explicit casts when you mean a type-sensitive check. Also note that "42foo" is no longer treated as equal to 42.

Before: PHP 7.4 loose comparison
<?php
var_dump(0 == "foo");
var_dump(0 == "");
var_dump(42 == "42foo");

All three comparisons are true in PHP 7.4. In PHP 8.0 and later they are all false. Shown for comparison; not executed on this site.

After: Loose comparisons in PHP 8
<?php
$checks = [
    '0 == "foo"' => 0 == "foo",
    '0 == ""' => 0 == "",
    '0 == "0.0"' => 0 == "0.0",
    '42 == " 42"' => 42 == " 42",
    '42 == "42foo"' => 42 == "42foo",
];
foreach ($checks as $label => $result) {
    echo $label, ' is ', $result ? 'true' : 'false', "\n";
}
Output on PHP 8.5.10
0 == "foo" is false
0 == "" is false
0 == "0.0" is true
42 == " 42" is true
42 == "42foo" is false

Official migration notes

NEW

match expressions

match is a new expression that returns a value, supports multiple conditions per arm, and makes match a reserved keyword.

PHP 8.0 adds match, an expression form of branching. Unlike switch, it produces a value you can return or assign directly, has no fall-through and needs no break. Arms can list several comma-separated conditions, and a default arm covers the rest. Comparison is strict, so match does not apply the loose-comparison quirks that switch does. If no arm matches and there is no default, PHP raises an error rather than silently continuing.

A common idiom is match (true) with boolean conditions, as in the example, which replaces long if/elseif chains that only select a value.

One migration pitfall: match is now a reserved keyword, so you cannot use it as a function-style identifier for new declarations of classes, constants in the global namespace, or similar names. Rename any existing symbol called match before upgrading.

Before: PHP 7.4 with switch
<?php
function label($code) {
    switch (true) {
        case $code >= 500: return 'server error';
        case $code >= 400: return 'client error';
        case $code >= 200: return 'success';
        default: return 'other';
    }
}

Valid in PHP 7.4 and later. The match expression requires PHP 8.0 or later. Shown for comparison; not executed on this site.

After: match with boolean conditions
<?php
function label(int $code): string {
    return match (true) {
        $code >= 500 => 'server error',
        $code >= 400 => 'client error',
        $code >= 200 => 'success',
        default => 'other',
    };
}
foreach ([204, 404, 503, 100] as $code) {
    echo $code, ': ', label($code), "\n";
}
Output on PHP 8.5.10
204: success
404: client error
503: server error
100: other

Official migration notes

NEW

Named arguments

Arguments can be passed by parameter name, letting you skip optional parameters and make calls self-documenting.

PHP 8.0 lets you pass an argument by writing the parameter name followed by a colon. This removes the need to repeat default values just to reach a later optional parameter, and it makes calls with several booleans or similar values readable. In the example, selfClosing: true is given without supplying $attrs at all.

A few things to remember. Positional arguments must come before named ones. Because names are now part of how callers use your function, renaming a parameter in a library can break callers, so treat parameter names as public API. A related behavior change: when you call call_user_func_array() with an array that has string keys, those keys are now interpreted as parameter names instead of being silently ignored, so check any code that passes associative arrays there.

Before: PHP 7.4 positional arguments
<?php
echo makeTag('br', [], true);

Works in PHP 7.4 and later, but you must repeat the default for the skipped parameter. Shown for comparison; not executed on this site.

After: Calling with named arguments
<?php
function makeTag(string $name, array $attrs = [], bool $selfClosing = false): string {
    $pairs = '';
    foreach ($attrs as $key => $value) {
        $pairs .= ' ' . $key . '="' . $value . '"';
    }
    return '<' . $name . $pairs . ($selfClosing ? ' />' : '>');
}
echo makeTag(name: 'br', selfClosing: true), "\n";
echo makeTag('a', attrs: ['href' => '/home']), "\n";
Output on PHP 8.5.10
<br />
<a href="/home">

Official migration notes

NEW

Constructor property promotion

Properties can be declared directly in the constructor signature, removing repeated declarations and assignments.

With constructor property promotion, adding a visibility modifier to a constructor parameter both declares a property of that name and assigns the argument to it. A typical value-object class shrinks from three repetitive parts (property declaration, parameter, assignment) to one signature. Defaults work as usual, and you can mix promoted and ordinary parameters.

There are pitfalls. Promotion is only available in constructors, so it does not apply to ordinary methods. The property type comes from the parameter type, so a parameter without a type produces an untyped property. Remember that the constructor must be named __construct: methods named after the class are no longer treated as constructors in PHP 8.0.

The example also benefits from the Stringable interface, which PHP 8.0 adds automatically to any class that defines __toString().

Before: PHP 7.4 explicit properties
<?php
class Point {
    public int $x;
    public int $y;
    public function __construct(int $x = 0, int $y = 0) {
        $this->x = $x;
        $this->y = $y;
    }
}

Valid in PHP 7.4 (typed properties exist there) and later. Promotion needs PHP 8.0 or later. Shown for comparison; not executed on this site.

After: Promoted constructor parameters
<?php
class Point {
    public function __construct(public int $x = 0, public int $y = 0) {}
    public function __toString(): string {
        return "($this->x, $this->y)";
    }
}
echo new Point(y: 5), "\n";
echo new Point(3, 4), "\n";
Output on PHP 8.5.10
(0, 5)
(3, 4)

Official migration notes

NEW

Union types and the mixed type

Declarations can now list several types with |, and mixed explicitly means any type; mixed becomes a reserved word.

Before PHP 8.0 you could only declare a single type (optionally nullable), so "int or float" had to live in a docblock. Union types let you write int|float in parameter, return and property declarations, and the engine enforces them. PHP 8.0 also adds the mixed type, which says a value can be anything and makes that intent explicit instead of omitting the type.

Two migration notes. mixed is now a reserved word, so you cannot name a class, interface or trait Mixed, nor use it in namespaces. Also, a default value that resolves to null at runtime no longer makes a parameter type implicitly nullable; write ?int or give an explicit = null default.

Union types make wrong inputs fail early, but remember that in non-strict mode scalar values may still be coerced into one of the allowed types.

Before: PHP 7.4 docblock only
<?php
/** @param int|float $n */
function double($n) {
    return $n * 2;
}

PHP 7.4 cannot enforce int|float; the type exists only in the comment. Shown for comparison; not executed on this site.

After: Union and mixed declarations
<?php
function double(int|float $n): int|float {
    return $n * 2;
}
function show(mixed $v): string {
    return gettype($v);
}
echo double(4), ' ', double(1.5), "\n";
echo show(null), ' ', show([1]), "\n";
Output on PHP 8.5.10
8 3
NULL array

Official migration notes

NEW

Nullsafe operator

The ?-> operator short-circuits a property or method chain to null when the left side is null.

The nullsafe operator ?-> stops evaluating a chain as soon as the value on its left is null and returns null for the whole chain. This replaces nested !== null checks when walking through optional relationships, such as a user who may have no profile.

It combines naturally with ?? to provide a fallback, as in the example. The short-circuit covers the rest of the chain, so later method calls in that chain are not executed when an earlier link is null, which also means side effects in those calls are skipped.

Be careful not to use it to hide data that should never be missing. A silent null can postpone a bug to a later, harder-to-trace place. Use it for genuinely optional values, and keep explicit checks or exceptions for required ones.

Before: PHP 7.4 explicit null checks
<?php
$city = $user !== null && $user->profile !== null
    ? $user->profile->city
    : null;

Works in PHP 7.4 and later; the nullsafe operator requires PHP 8.0 or later. Shown for comparison; not executed on this site.

After: Chaining with ?->
<?php
class Profile {
    public function __construct(public ?string $city = null) {}
}
class User {
    public function __construct(public ?Profile $profile = null) {}
}
$users = [new User(new Profile('Oslo')), new User(), null];
foreach ($users as $user) {
    echo $user?->profile?->city ?? 'unknown', "\n";
}
Output on PHP 8.5.10
Oslo
unknown
unknown

Official migration notes

NEW

throw as an expression, and catch without a variable

throw can appear inside expressions such as ?? and arrow functions, and catch blocks may omit the exception variable.

In PHP 7.4 throw was a statement, so you could not use it where an expression was required. PHP 8.0 makes it an expression. That allows compact guards like $value ?? throw new Exception(...) and arrow functions whose whole body is a throw, which is handy for default callbacks that must never be called.

PHP 8.0 also lets you write catch (Exception) without capturing the exception in a variable, which removes unused variables when you only care about the type of failure.

The main pitfall is readability: a throw hidden in the middle of a long expression is easy to miss, so keep such expressions short. Also remember that with ?? the throw only runs when the left side is null or unset, not for other falsy values such as an empty string.

Before: PHP 7.4 statement-only throw
<?php
function config(array $c, string $key) {
    if (!isset($c[$key])) {
        throw new InvalidArgumentException("Missing $key");
    }
    return $c[$key];
}

Works in PHP 7.4 and later. Using throw inside ?? or an arrow function requires PHP 8.0 or later. Shown for comparison; not executed on this site.

After: throw inside ?? and an arrow function
<?php
function config(array $c, string $key): string {
    return $c[$key] ?? throw new InvalidArgumentException("Missing $key");
}
echo config(['host' => 'localhost'], 'host'), "\n";
try {
    config([], 'host');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}
$fail = fn() => throw new RuntimeException('boom');
try {
    $fail();
} catch (RuntimeException) {
    echo "caught without variable\n";
}
Output on PHP 8.5.10
localhost
Missing host
caught without variable

Official migration notes

CHANGED BEHAVIOR

Arithmetic on arrays now throws TypeError

Arithmetic and bitwise operators consistently throw TypeError for array, resource or non-overloaded object operands, except array + array.

PHP 8.0 makes the arithmetic and bitwise operators (+, -, *, /, **, %, <<, >>, &, |, ^, ~, ++, --) consistent: if an operand is an array, a resource or an object without operator overloading, a TypeError is thrown. Previously some operators quietly converted such values to numbers.

The one exception is array + array, which still merges arrays, as the last call in the example shows. Code that accidentally multiplied or added an array, for example because a function returned an array instead of a number, will now fail loudly rather than producing a wrong figure.

This is part of a wider tightening in PHP 8.0, where many warnings became Error exceptions and many notices became warnings, such as reading an undefined variable, property or array key. Run your tests with all errors enabled to find these before deployment.

Example: TypeError for array operands
<?php
function total(mixed $a, mixed $b): string {
    try {
        return json_encode($a + $b);
    } catch (TypeError $e) {
        return 'TypeError';
    }
}
echo total(1, 2), "\n";
echo total([1], 2), "\n";
echo total([1, 2], [3]), "\n";
Output on PHP 8.5.10
3
TypeError
[1,2]

Official migration notes

CHANGED BEHAVIOR

Concatenation precedence lowered

The . operator now binds more loosely than + and -, so "Sum: " . $a + $b adds first and then concatenates.

In PHP 7.4 the concatenation operator had the same precedence as addition and subtraction, evaluated left to right. An expression like "Sum: " . $a + $b therefore concatenated first and then tried to add $b to the resulting string, which produced a wrong result and a warning. In PHP 8.0 arithmetic binds tighter than concatenation, so the sum is computed first and then appended, which is almost always what the author intended.

The risk is the reverse: code that was accidentally relying on the old grouping now behaves differently without any error. Search for lines that mix . with +, -, << or >> and add explicit parentheses. Parentheses make the intent obvious and give identical results on both 7.4 and 8.0, so you can apply them before upgrading.

Before: PHP 7.4 grouping
<?php
$a = 1;
$b = 2;
echo "Sum: " . $a + $b;

PHP 7.4 evaluates this as ("Sum: " . $a) + $b, which is not what was meant. PHP 8.0 and later evaluate the addition first. Shown for comparison; not executed on this site.

After: Explicit parentheses
<?php
$a = 1;
$b = 2;
echo "Sum: " . $a + $b, "\n";
echo "Sum: " . ($a + $b), "\n";
Output on PHP 8.5.10
Sum: 3
Sum: 3

Official migration notes

CHANGED BEHAVIOR

Nested ternaries require parentheses

Chaining ternary operators without explicit parentheses is now a compile-time error in PHP 8.0.

An unparenthesized chain such as $s >= 90 ? 'A' : $s >= 80 ? 'B' : 'C' was accepted by PHP 7.4, even though it is easy to misread because PHP's ternary operator was left-associative, unlike most languages. PHP 8.0 rejects the construct outright, so the file will not compile.

The fix is mechanical: add parentheses around the inner ternary, as in the example, which keeps the meaning most people expect. For longer chains, a match (true) expression is often clearer, and an if block is fine when branches are complex.

Because this is a compile-time error, it takes down the whole file, not just the code path that uses it. Scan the codebase, or lint every file with php -l on PHP 8.0, before deploying the upgrade.

Before: PHP 7.4 unparenthesized chain
<?php
function grade($s) {
    return $s >= 90 ? 'A' : $s >= 80 ? 'B' : 'C';
}

Accepted by PHP 7.4. PHP 8.0 and later refuse to compile it. Shown for comparison; not executed on this site.

After: Parenthesized nested ternary
<?php
function grade(int $s): string {
    return $s >= 90 ? 'A' : ($s >= 80 ? 'B' : 'C');
}
echo grade(95), grade(85), grade(10), "\n";
Output on PHP 8.5.10
ABC

Official migration notes

REMOVED

Curly-brace offset access removed

Reading string or array offsets with {} such as $str{0} is no longer supported; use square brackets.

The old alternative syntax for offset access, $array{0} or $string{"key"}, has been removed in PHP 8.0. Square brackets have always been the primary syntax and behave identically, so the fix is a simple replacement of {} with [].

Because the old form is a syntax error on PHP 8, any file that still uses it will fail to load. Search legacy code, templates and vendored libraries, especially older string-manipulation helpers. Static analysis and php -l on PHP 8.0 will report each occurrence.

The square-bracket form works on PHP 7.4 as well, so you can make the change before upgrading. Negative string offsets such as $word[-1] are also fine and return the last character.

Before: PHP 7.4 curly braces
<?php
$word = 'PHP';
echo $word{0};

Accepted by PHP 7.4. PHP 8.0 and later no longer support this syntax. Shown for comparison; not executed on this site.

After: Square-bracket offsets
<?php
$word = 'PHP';
$list = ['a' => 1, 'b' => 2];
echo $word[0], $word[-1], "\n";
echo $list['b'], "\n";
Output on PHP 8.5.10
PP
2

Official migration notes

DEPRECATED

Optional parameter before a required one is deprecated

A default value on a parameter that is followed by a required parameter has no effect and now triggers a deprecation.

If a parameter with a default value comes before a required parameter, the default can never be used, because callers must always supply the later argument and therefore the earlier one too. PHP 8.0 deprecates this signature. In most cases you can simply drop the default, which does not change behavior.

There is one allowed form: a typed parameter with a null default, like A $a = null, which implicitly makes the type nullable. PHP 8.0 still accepts it, but the recommended style is an explicit ?A $a. A later PHP release deprecates the implicit nullable form too, so adopting the explicit form now avoids a second round of changes.

If you really want callers to skip a parameter, reorder so optional parameters come last, and use named arguments to select among them, as shown below.

Before: Deprecated signature
<?php
function connect(array $opts = [], string $host) {
    return $host . ' ' . count($opts);
}

PHP 7.4 accepts this silently; PHP 8.0 emits a deprecation because the default value has no effect. Shown for comparison; not executed on this site.

After: Required parameters first
<?php
function connect(string $host, array $opts): string {
    return $host . ' ' . count($opts);
}
echo connect('db.local', []), "\n";
echo connect(opts: ['a' => 1, 'b' => 2], host: 'db.local'), "\n";
Output on PHP 8.5.10
db.local 0
db.local 2

Official migration notes

DEPRECATED

Boolean sort comparison callbacks are deprecated

Comparison callbacks for sorting should return an integer, not true or false; boolean returns now emit a deprecation.

Sorting callbacks such as the one passed to usort() are expected to return a negative, zero or positive integer. Many PHP 7 programs returned $a > $b, a boolean, and it happened to work. PHP 8.0 deprecates callbacks that return true or false, and the replacement is the spaceship operator <=>, which returns exactly the integer the sort needs.

This matters more because sorting is now stable in PHP 8.0: elements that compare as equal keep their original order. A boolean callback cannot express "equal", so it can interact badly with that guarantee. The example sorts by length and shows equal-length items staying in input order. If results of sorting functions look different after the upgrade, check for callbacks that treat equal elements inconsistently.

Before: Boolean comparison callback
<?php
$nums = [5, 3, 9, 1];
usort($nums, fn($a, $b) => $a > $b);

Works silently in PHP 7.4. PHP 8.0 emits a deprecation because the callback returns a boolean. Shown for comparison; not executed on this site.

After: Integer comparison with <=>
<?php
$nums = [5, 3, 9, 1];
usort($nums, fn($a, $b) => $a <=> $b);
echo implode(',', $nums), "\n";
$words = ['bb', 'aa', 'c'];
usort($words, fn($a, $b) => strlen($a) <=> strlen($b));
echo implode(',', $words), "\n";
Output on PHP 8.5.10
1,3,5,9
c,bb,aa

Official migration notes

← PHP 7.4 changes PHP 8.1 changes →