PHP 8.4: property hooks and the end of the getter/setter ceremony
PHP 8.4 released November 21st. Property hooks are the feature. Everything else, and there’s quite a bit of it, is secondary.
Property hooks
For twenty years, if you wanted behavior on property access in PHP you had to write getters and setters:
class User {
private string $_name;
public function getName(): string { return $this->_name; }
public function setName(string $name): void {
$this->_name = strtoupper($name);
}
}
PHP 8.4 adds hooks directly on the property:
class User {
public string $name {
set(string $name) {
$this->name = strtoupper($name);
}
}
}
You can define get and set hooks independently. A property with only a get hook is computed on access:
class Circle {
public float $area {
get => M_PI * $this->radius ** 2;
}
public function __construct(public float $radius) {}
}
No backing storage, no explicit getter method, full IDE support. Interfaces can declare properties with hooks too, which means contracts can now specify behavior on property access, something that was flat-out impossible before.
Asymmetric visibility
A lighter option for when you just want public read, private write:
class Version {
public private(set) string $value = '1.0.0';
}
$v = new Version();
echo $v->value; // works
$v->value = '2.0'; // Error
Kills the private $x + public getX() pattern for read-only public properties without needing full readonly semantics.
array_find() and friends
$first = array_find($users, fn($u) => $u->isActive());
$any = array_any($users, fn($u) => $u->isPremium());
$all = array_all($users, fn($u) => $u->isVerified());
These have been in every other language’s standard library for decades. In PHP, you had to use array_filter() + index access or write a manual loop. They exist now: array_find(), array_find_key(), array_any(), array_all().
Instantiation without extra parentheses
// before
(new MyClass())->method();
// after
new MyClass()->method();
A syntax restriction that was always annoying and never justified is gone.
Lazy objects
Objects whose initialization is deferred until first property access:
$user = $reflector->newLazyProxy(fn() => $repository->find($id));
// No database call yet
$user->name; // Now the proxy initializes
The direct audience is framework ORM and DI container authors, not application developers. But the effect shows up in every app that uses Doctrine or Symfony: lazy loading implemented at the language level rather than through code generation.
PHP 8.4 is a language that barely resembles the PHP 5 most of us started with. Property hooks in particular: they’re not a workaround, they’re a design feature.
#[\Deprecated] for your own code
PHP has emitted deprecation notices for built-in functions for years. 8.4 lets you wire the same mechanism into your own code:
class ApiClient {
#[\Deprecated(
message: 'Use fetchJson() instead',
since: '2.0',
)]
public function get(string $url): string { ... }
}
Calling a deprecated method now emits E_USER_DEPRECATED, just like calling mysql_connect(). IDEs pick it up, static analyzers flag it, the error log captures it. Before this, the only option was a @deprecated PHPDoc comment: fine for IDEs, completely invisible to the engine.
BcMath\Number makes arbitrary precision usable
The bcmath functions have been in PHP since forever, but their procedural API makes chaining anything painful. 8.4 adds BcMath\Number, an object wrapper with operator overloading:
$a = new BcMath\Number('10.5');
$b = new BcMath\Number('3.2');
$result = $a + $b; // BcMath\Number('13.7')
$result = $a * $b - new BcMath\Number('1');
echo $result; // 32.6
The +, -, *, /, **, % operators all work. The object is immutable. Scale propagates automatically through operations. Financial calculations, which used to mean chains of bcadd(bcmul(...), ...), now just read like arithmetic.
New procedural functions complete the picture: bcceil(), bcfloor(), bcround(), bcdivmod().
RoundingMode enum replaces PHP_ROUND_* constants
round() has always taken a $mode int from a set of PHP_ROUND_* constants. 8.4 replaces that with a RoundingMode enum with cleaner names and four additional modes that weren’t available before:
round(2.5, mode: RoundingMode::HalfAwayFromZero); // 3
round(2.5, mode: RoundingMode::HalfTowardsZero); // 2
round(2.5, mode: RoundingMode::HalfEven); // 2 (banker's rounding)
round(2.5, mode: RoundingMode::HalfOdd); // 3
// The four new modes (only available via the enum)
round(2.3, mode: RoundingMode::TowardsZero); // 2
round(2.7, mode: RoundingMode::AwayFromZero); // 3
round(2.3, mode: RoundingMode::PositiveInfinity); // 3
round(2.3, mode: RoundingMode::NegativeInfinity); // 2
The old PHP_ROUND_* constants still work. The enum is the path forward.
Multibyte string functions that should have existed
mb_trim(), mb_ltrim(), mb_rtrim(): trim functions that respect multibyte character boundaries, not just ASCII whitespace. Also new: mb_ucfirst() and mb_lcfirst() for proper title-casing of multibyte strings.
$s = "\u{200B}hello\u{200B}"; // Zero-width spaces
echo mb_trim($s); // "hello"
echo mb_ucfirst('über'); // "Über"
These fill gaps that have been sitting there since mbstring was introduced.
request_parse_body() for non-POST requests
PHP automatically parses application/x-www-form-urlencoded and multipart/form-data into $_POST and $_FILES, but only for POST requests. PATCH and PUT requests with the same content types needed manual parsing with file_get_contents('php://input') and custom code.
// Inside a PATCH handler
[$_POST, $_FILES] = request_parse_body();
The function returns a tuple. Same parsing logic PHP uses for POST, now available for any HTTP method.
A new DOM API that follows the spec
The existing DOMDocument API was built on an older DOM level 3 spec with PHP-specific quirks layered on top. 8.4 adds a parallel Dom\ namespace that implements the WHATWG Living Standard:
$doc = Dom\HTMLDocument::createFromString('<p class="lead">Hello</p>');
$p = $doc->querySelector('p');
echo $p->classList; // "lead"
echo $p->id; // ""
$doc2 = Dom\HTMLDocument::createFromFile('page.html');
Dom\HTMLDocument parses HTML5 correctly, tag soup included. Dom\XMLDocument handles strict XML. The new classes are strict about types, return proper node types, and expose modern properties like classList, id, className. The old DOMDocument stays, unchanged, for backward compatibility.
PDO gets driver-specific subclasses
PDO::connect() and direct instantiation now return driver-specific subclasses when available:
$pdo = PDO::connect('mysql:host=localhost;dbname=test', 'user', 'pass');
// $pdo is now a Pdo\Mysql instance
$pdo = new Pdo\Pgsql('pgsql:host=localhost;dbname=test', 'user', 'pass');
Each driver subclass (Pdo\Mysql, Pdo\Pgsql, Pdo\Sqlite, Pdo\Firebird, Pdo\Odbc, Pdo\DbLib) can expose driver-specific methods without polluting the base PDO interface. Doctrine, Laravel, and similar ORMs can now type-hint against the specific driver class when they need driver-specific behavior.
OpenSSL gets modern key support
openssl_pkey_new() and related functions now support Curve25519 and Curve448, the modern elliptic curves that have replaced older NIST curves in most security recommendations:
$key = openssl_pkey_new(['curve_name' => 'ed25519', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
$details = openssl_pkey_get_details($key);
x25519 and x448 for key exchange, ed25519 and ed448 for signatures. All four now work with openssl_sign() and openssl_verify().
PCRE: variable-length lookbehind
The bundled PCRE2 library update (10.44) brings variable-length lookbehind assertions, something Perl and Python regex engines had and PHP couldn’t do:
// Match "bar" only when preceded by "foo" or "foooo"
preg_match('/(?<=foo+)bar/', 'foooobar', $matches);
Lookbehind assertions used to require a fixed-width pattern. Now they can match patterns of variable length. The r modifier (PCRE2_EXTRA_CASELESS_RESTRICT) is also new: it prevents mixing ASCII and non-ASCII characters in case-insensitive matches, closing a class of Unicode confusion attacks.
DateTime gets microseconds and timestamp factory
$dt = DateTimeImmutable::createFromTimestamp(1700000000.5);
echo $dt->getMicrosecond(); // 500000
$with_micros = $dt->setMicrosecond(123456);
createFromTimestamp() accepts a float for sub-second precision. getMicrosecond() and setMicrosecond() round out the API for the microsecond component that’s been inside DateTime internally but inaccessible directly.
fpow() for IEEE 754 compliance
pow(0, -2) in PHP has historically returned an implementation-defined value. 8.4 deprecates pow() with a zero base and negative exponent and introduces fpow(), which strictly follows IEEE 754: fpow(0, -2) returns INF, as the standard defines:
echo fpow(2.0, 3.0); // 8.0
echo fpow(0.0, -1.0); // INF
echo fpow(-1.0, INF); // 1.0
Worth knowing in any code doing mathematical computations where IEEE compliance matters.
The cost of bcrypt goes up
The default cost for password_hash() with PASSWORD_BCRYPT went from 10 to 12. This hits any code calling password_hash($pass, PASSWORD_BCRYPT) without an explicit cost. The goal is to keep the default roughly “a few hundred milliseconds on modern hardware” as hardware gets faster.
If you store bcrypt hashes and upgrade to 8.4, existing hashes stay valid: password_verify() reads the cost from the hash itself. New hashes use cost 12. password_needs_rehash() returns true for old hashes if you pass ['cost' => 12], so you can upgrade them on next login.
Deprecations that matter
Implicitly nullable parameters are deprecated. If a parameter has a default of null, the type has to say so explicitly:
// Deprecated in 8.4
function foo(string $s = null) {}
// Correct
function foo(?string $s = null) {}
function foo(string|null $s = null) {}
trigger_error() with E_USER_ERROR is deprecated: replace it with an exception or exit(). The E_USER_ERROR level was always an awkward hybrid between a recoverable error and a fatal one, and nobody was sure which.
lcg_value() is deprecated too. Use Random\Randomizer::getFloat() instead. The LCG generator had poor randomness properties and no seeding control.