PHP 7.4 is a release where the language itself gets noticeably nicer to write. The headline feature is typed properties, which let a class enforce the type of its state instead of relying on setters and docblocks. Arrow functions with fn remove most of the ceremony from short callbacks, and the null coalescing assignment operator ??= replaces a common isset pattern. Arrays gain spread syntax, numbers gain underscore separators, and return types may now be covariant in child classes.
From a migration perspective, the additions are mostly opt-in: code written for PHP 7.3 keeps running, and you can adopt the new syntax file by file. The risks sit in a smaller group of changes. fn is now a reserved keyword, so a global function or class with that name stops compiling. Reading an array offset from null, a boolean, a number or a resource now produces a notice, which can flood logs in code that was silently tolerant. A <?php tag at the very end of a file is now treated as a real opening tag.
The deprecations deserve attention because they are the first warning that code will break in a later major version. Nested ternaries without parentheses, curly-brace string and array offsets, and a few older casts and helpers all emit deprecation messages in 7.4. Fixing them now is cheap and mechanical, and the replacements work on 7.4 as well as on newer releases.
A sensible upgrade path is to run your test suite on PHP 7.4 with error_reporting set to E_ALL and watch for the new notices and deprecations before changing any code. Rename anything called fn, parenthesize nested ternaries, and replace $str{0} with $str[0]. After that, introduce typed properties gradually, starting with classes whose state is always initialized in the constructor, because an uninitialized typed property throws when read. Finally, prefer __serialize() and __unserialize() for new serialization logic.
The examples in this article are written to behave the same on PHP 7.4 and on current releases. Where a later version tightened a behavior again, the explanation says so, so you can avoid building on something that was only a transitional state.
NEW
Typed properties
Class properties can now carry type declarations, and PHP enforces them on every assignment.
Before 7.4 a property could hold anything, so classes relied on constructors, setters or comments to keep their state sane. In PHP 7.4 you can write a type directly on the property, and the engine rejects values that cannot be accepted. Without declare(strict_types=1) scalar values are still coerced when they safely can be, so the string "5" becomes an int, but a value like "abc" throws a TypeError.
The main pitfall is initialization. A typed property without a default starts out uninitialized, not null, and reading it throws an Error. Give properties defaults or assign them in the constructor. A nullable type such as ?int allows null but does not make it the default. Also check code that sets properties from outside the class, because it will now fail loudly where it once slipped through. Typed properties are a good first 7.4 feature to adopt on new or well-tested classes.
class User {
private $id;
public function setId($id) {
if (!is_int($id)) {
throw new InvalidArgumentException('id must be int');
}
$this->id = $id;
}
}Valid in PHP 7.3 and 7.4; the type is only enforced through the manual check. Shown for comparison; not executed on this site.
class User {
public int $id;
public string $name;
}
$user = new User();
$user->id = 7;
$user->name = "Ada";
try {
$user->id = "abc";
} catch (TypeError $e) {
echo "TypeError caught\n";
}
echo $user->id, " ", $user->name, "\n";
TypeError caught 7 Ada
NEW
Arrow functions with fn
The fn keyword offers a short closure syntax that captures outer variables by value automatically.
An arrow function is written as fn(args) => expression and returns the value of that single expression. It captures variables from the surrounding scope by value, and it does so implicitly, so you no longer list them in a use clause. That makes callbacks for array_map(), usort() and similar functions much shorter.
The by-value capture is the part people trip over. Assigning to a captured variable inside the arrow function changes only a private copy, so the outer variable is untouched. If you need to modify outer state, use a regular closure with use (&$var). Arrow functions also hold exactly one expression, so a body that needs several statements still needs the long function form. Remember that fn became a reserved word in 7.4, which matters for older code that used it as a name.
$factor = 10;
$nums = array_map(function ($n) use ($factor) {
return $n * $factor;
}, [1, 2, 3, 4]);Valid in PHP 7.3 and still valid in 7.4 and later. Shown for comparison; not executed on this site.
$factor = 10;
$nums = array_map(fn($n) => $n * $factor, [1, 2, 3, 4]);
echo implode(",", $nums), "\n";
$total = 0;
$add = fn($n) => $total += $n;
$add(5);
echo $total, "\n";
10,20,30,40 0
NEW
Null coalescing assignment operator
The ??= operator assigns a value only when the left side is unset or null, replacing a common isset block.
The expression $a ??= $b assigns $b to $a only if $a is not set or is null. It is the assignment partner of the ?? operator and removes the familiar three-line if (!isset(...)) guard when you fill in defaults, for example in configuration arrays or caches.
Two details matter. First, the right-hand side is evaluated lazily, so an expensive default is not computed when the left side already holds a value. Second, the check follows isset semantics, so a stored value of false, 0 or an empty string counts as set and is kept. If you want to replace falsy values as well, you still need an explicit condition or the ?: operator. Because ??= works on array elements, it is handy for building lookup tables without triggering undefined-index notices.
$config = ['host' => 'localhost'];
if (!isset($config['port'])) {
$config['port'] = 3306;
}Valid in PHP 7.3 and in later versions. Shown for comparison; not executed on this site.
$config = ['host' => 'localhost'];
$config['port'] ??= 3306;
$config['host'] ??= 'example.test';
$calls = 0;
$make = function () use (&$calls) {
$calls++;
return 'new';
};
$config['host'] ??= $make();
echo json_encode($config), " calls=", $calls, "\n";
{"host":"localhost","port":3306} calls=0
DEPRECATED
Nested ternaries need parentheses
Chaining ternary operators without explicit parentheses is deprecated because left-associativity rarely matches what authors expect.
PHP's ternary operator is left-associative, unlike in most other languages, so a ? b : c ? d : e is evaluated as (a ? b : c) ? d : e. That surprised many developers, so PHP 7.4 emits a deprecation notice whenever you nest ternaries without parentheses. The behavior itself is unchanged in 7.4, so the notice is a warning rather than a break.
Add parentheses to state your intent. If you meant the right-associative reading, as in most chains, wrap the last ternary in parentheses. Nesting inside the middle operand is unambiguous and does not need them. Later major versions reject the unparenthesized form entirely, so fixing it now avoids a hard failure on upgrade. Search for lines containing two ? operators and double-check which grouping was intended before adding the parentheses, because the old result might have depended on the left-associative reading.
$n = 5;
$label = $n < 0 ? 'negative' : $n === 0 ? 'zero' : 'positive';
echo $label, "\n";Accepted by PHP 7.3 without a message; PHP 7.4 emits a deprecation; later major versions reject it. Shown for comparison; not executed on this site.
function label(int $n): string {
return $n < 0 ? 'negative' : ($n === 0 ? 'zero' : 'positive');
}
foreach ([-5, 0, 9] as $n) {
echo label($n), "\n";
}
negative zero positive
DEPRECATED
Curly-brace offset access is deprecated
Reading array or string offsets with $var{$i} is deprecated; use square brackets instead.
PHP has long allowed $str{0} as an alternative spelling of $str[0]. In 7.4 that curly-brace form is deprecated for both arrays and strings. The square-bracket syntax does exactly the same job, so the fix is a purely mechanical replacement and does not change behavior.
The pitfall is that the old syntax tends to hide in legacy code, especially in string-parsing loops, and a regular expression search for a variable name followed directly by an opening brace is easy to get wrong because braces are also used for blocks and complex string interpolation. Only offset reads are affected; interpolation such as a braced variable inside a double-quoted string is a different feature. Run your tests with deprecations enabled to find real occurrences. Later major versions drop the curly-brace form altogether, so this is worth cleaning up during the 7.4 upgrade.
$word = "PHP";
echo $word{0}, "\n";Accepted by PHP 7.3; deprecated in PHP 7.4; not supported in later major versions. Shown for comparison; not executed on this site.
$word = "PHP";
$list = ['a', 'b', 'c'];
echo $word[0], $word[-1], "\n";
echo $list[1], $list[2], "\n";
PP bc
CHANGED BEHAVIOR
Array-style access on non-arrays notices
Using null, bool, int, float or resource values like an array now produces a notice instead of silently yielding null.
Before 7.4, writing $value['key'] where $value is null, a boolean, a number or a resource quietly evaluated to null. From 7.4 on, PHP emits a notice for such reads. The result is still null, so the program continues, but code that relied on the silent behavior can now fill logs or trip an error handler that converts notices into exceptions.
The usual sources are functions that return false or null on failure and whose result is indexed immediately, or optional data that was never initialized. Fix these by checking the value first, or use the null coalescing operator, which does not raise the notice and lets you supply a default in one expression. Later releases made this diagnostic stricter again, so guarding the access is the version-independent answer. Do not suppress the notice with the error-control operator, since that hides the real data problem.
$value = null;
echo $value['key'], "\n";Silent in PHP 7.3; PHP 7.4 emits a notice; later versions are stricter. Shown for comparison; not executed on this site.
$values = [null, true, 42, 3.5, ['key' => 'found']];
foreach ($values as $value) {
echo $value['key'] ?? 'default', "\n";
}
default default default default found
CHANGED BEHAVIOR
fn is now a reserved keyword
Because of arrow functions, fn can no longer name a function or class, though methods and class constants may still use it.
With arrow functions introduced, fn became a reserved keyword in PHP 7.4. A global function named fn, or a class called fn, now fails to compile, which means a whole file can stop loading after the upgrade rather than just one call failing. Method names and class constant names are exempt, so an existing $object->fn() call keeps working.
The remedy is to rename the function or class and update every caller. In a codebase without static analysis, search for function fn and for fn( used as a call, and also check any namespaced helper libraries you depend on, since a vendor package with such a name would break at load time. Short names like this are most common in older utility files or in code samples that were copied into projects. Treat this as a hard prerequisite check before deploying 7.4.
function fn(int $x): int {
return $x * 2;
}
echo fn(4), "\n";Valid in PHP 7.3; a parse error in PHP 7.4 and later. Shown for comparison; not executed on this site.
function double_it(int $x): int {
return $x * 2;
}
class Mapper {
public function fn(int $x): int {
return double_it($x);
}
}
echo double_it(4), " ", (new Mapper())->fn(5), "\n";
8 10
NEW
Covariant return types
A child method may now narrow its return type to a subtype of the parent's, and parameter types may be widened.
Earlier versions required an overriding method to declare exactly the same return type as the parent. PHP 7.4 adds limited variance: a child can return a more specific class, and it can accept a wider parameter type than the parent. That lets factory-style hierarchies expose precise types without docblock tricks, and callers of the child class get better type information.
The word limited is important. The engine must know every class in the relationship when the child method is declared. Across files, autoloading makes this work. Inside a single file, only references to classes that are already defined can be checked, so a return type pointing at a class declared later in the file, or in a cycle, still causes a fatal error about compatibility. Order your declarations so that parent types come first, or put classes in separate autoloaded files. The narrowed type must still be a true subtype of the parent's type.
class A {}
class B extends A {}
class Producer {
public function make(): A { return new A(); }
}
class ChildProducer extends Producer {
public function make(): A { return new B(); }
}Valid in PHP 7.3 and later; the child type must match exactly in PHP 7.3, so callers only know about A. Shown for comparison; not executed on this site.
class A {
public function name(): string { return 'A'; }
}
class B extends A {
public function name(): string { return 'B'; }
}
class Producer {
public function make(): A { return new A(); }
}
class ChildProducer extends Producer {
public function make(): B { return new B(); }
}
echo (new ChildProducer())->make()->name(), "\n";
B
NEW
Spread operator inside array literals
The ... operator can now expand arrays and Traversable values directly inside an array literal.
PHP 7.4 lets you write [...$a, ...$b] to splice the elements of other arrays or iterables into a new array literal. It is more readable than nested array_merge() calls and it also accepts any Traversable, including generators. Elements keep the order in which they appear, and integer keys are renumbered as the new array is built.
Stick to integer-keyed data in 7.4 code. Spreading values with string keys was not supported in this release, and later versions defined its behavior separately, so code that depends on it is not portable back to 7.4. The same caution applies to iterators that yield duplicate or non-integer keys. For key-preserving merges, keep using array_merge() or the + operator. Spreading also copies every element, so on very large arrays it costs memory just like a merge would.
$parts = ['apple', 'pear'];
$fruits = array_merge(['banana', 'orange'], $parts, ['watermelon']);Valid in PHP 7.3 and later. Shown for comparison; not executed on this site.
$parts = ['apple', 'pear'];
$fruits = ['banana', 'orange', ...$parts, 'watermelon'];
echo implode(",", $fruits), "\n";
function gen() {
yield 1;
yield 2;
}
echo json_encode([0, ...gen(), 3]), "\n";
banana,orange,apple,pear,watermelon [0,1,2,3]
NEW
Numeric literal separator
Underscores may separate digits in numeric literals to make long numbers readable without changing their value.
Numeric literals in PHP 7.4 may contain single underscores between digits, as in 1_000_000. The separator is purely visual: the parser ignores it, so the value is identical to the literal without underscores. It works for decimal, hexadecimal, binary, octal and floating-point literals, which helps with byte masks, large constants and physical values.
The underscore must sit between two digits. You cannot put one at the start or end of a number, next to the decimal point, or directly after a base prefix such as 0x; those forms are syntax errors. Separators also apply only to literals in source code. Strings containing underscores are not affected, so casting "1_000" still yields 1, and (int) on user input will not accept them. Use the separator in constants and fixtures, not as a way to parse formatted input.
$big = 1_000_000;
$mask = 0b0101_1111;
$max = 0xFF_FF;
$g = 6.674_083e-11;
echo $big, " ", $mask, " ", $max, "\n";
echo $g === 6.674083e-11 ? "same" : "different", "\n";
echo (int) "1_000", "\n";
1000000 95 65535 same 1
NEW
Exceptions allowed from __toString()
Throwing from __toString() is now permitted and propagates normally instead of causing a fatal error.
In earlier versions, throwing an exception inside __toString() produced a fatal error, so a failing conversion could not be handled by ordinary try/catch logic. PHP 7.4 allows the exception to propagate. Several recoverable fatal errors that used to occur during string conversion have also become Error exceptions, which makes failures consistent and catchable.
In practice this lets a value object refuse to render when it is in an invalid state and lets the caller decide what to do. The pitfall is that implicit string conversions occur in many places, such as concatenation, echo, comparisons with strings and some built-in functions, so an exception can now surface from code that never looked like it could throw. Review error handlers and logging code that converts objects to strings, because a throw there could mask the original problem. Keep conversion methods cheap and predictable wherever possible.
class Report {
public function __toString(): string {
throw new RuntimeException("cannot render");
}
}
try {
echo new Report(), "\n";
} catch (RuntimeException $e) {
echo "Caught: ", $e->getMessage(), "\n";
}
Caught: cannot render
NEW
__serialize() and __unserialize()
Two new magic methods give classes full control over serialization by returning and consuming a plain array.
PHP 7.4 introduces __serialize(), which returns an array holding all state the object needs, and __unserialize(array $data), which restores the object from that array. They supersede the older Serializable interface, which the migration guide says will be deprecated in the future, and they avoid its awkward string-based contract and its known edge cases.
If a class defines the new methods, PHP uses them in preference to the older mechanisms. Keep the data array simple and version it yourself if the format may change. Be careful with compatibility: payloads produced by the new mechanism in 7.4 cannot be read by older PHP versions, while older payloads can still be unserialized. Several SPL classes also adopted the new methods in 7.4, so their serialized format changed too. When you need to support both old and new runtimes during a rollout, test serialized data across versions before switching.
class Point implements Serializable {
private $x = 3;
public function serialize() { return (string) $this->x; }
public function unserialize($data) { $this->x = (int) $data; }
}Works in PHP 7.3 and 7.4; the interface is being phased out in favor of the new magic methods. Shown for comparison; not executed on this site.
class Point {
private $x;
private $y;
public function __construct(int $x, int $y) {
$this->x = $x;
$this->y = $y;
}
public function __serialize(): array {
return ['x' => $this->x, 'y' => $this->y];
}
public function __unserialize(array $data): void {
$this->x = $data['x'];
$this->y = $data['y'];
}
public function label(): string {
return "({$this->x}, {$this->y})";
}
}
$copy = unserialize(serialize(new Point(3, 4)));
echo $copy->label(), "\n";
(3, 4)