PHP 8.4 RC is out and the final release is coming in November 2024. Property hooks looked great on paper – after a month with RC builds on real code I can report what works exactly as expected, where the syntax surprises you, and what limitations matter in practice. Asymmetric visibility also has a few edge cases worth knowing before the feature goes stable.
Property hooks – what actually surprised me
<?php
declare(strict_types=1);
// Surprise 1: get hook can return a DIFFERENT type than the property's backing type
// This is intentional but takes getting used to
class ProductPriceHolder
{
public float $price {
get => round($this->price * 1.23, 2); // returns price + 23% VAT
set {
if ($value < 0) throw new \InvalidArgumentException('Price cannot be negative');
$this->price = $value; // stores NET price
}
}
}
$p = new ProductPriceHolder();
$p->price = 100.0; // stores 100.0 (net)
echo $p->price; // returns 123.0 (gross) - the get hook transforms it!
// This can be confusing: $p->price = 100.0; then $p->price !== 100.0
// Be very explicit in docblocks about what get/set each do
<?php
// Surprise 2: abstract hooks in interfaces/abstract classes
interface HasFormattedPrice
{
// Interface can declare a property with abstract hooks
public float $price { get; set; } // requires implementing class to have get and set
}
abstract class BaseProduct implements HasFormattedPrice
{
// You can partially implement - provide set, leave get abstract
public float $price {
set { $this->price = max(0.0, $value); } // concrete set
get; // still abstract - implementing class must provide get
}
}
class SimpleProduct extends BaseProduct
{
public float $price {
get => $this->price; // concrete get
// set inherited from BaseProduct
}
}
<?php
// Surprise 3: readonly properties and hooks cannot coexist (as expected, but worth noting)
class Immutable
{
// This is VALID: readonly + no hooks
public readonly float $price;
// This is INVALID: cannot have set hook on readonly
// public readonly float $price { set { ... } } // Error
}
// Computed readonly (get only hook without backing storage)
class OrderSummary
{
public function __construct(
private array $items // [['price' => float, 'qty' => int]]
) {}
// Virtual property - no backing value, computed on access
public float $total {
get => array_sum(array_map(fn($i) => $i['price'] * $i['qty'], $this->items));
}
// This property has NO storage - it is purely computed
// Trying to set it throws: Cannot write to virtual property
// $order->total = 100; // Error
}
Asymmetric visibility – edge cases
<?php
declare(strict_types=1);
// What I use it for most: collection classes
class OrderCollection
{
public private(set) int $count = 0;
public private(set) float $total = 0.0;
private array $orders = [];
public function add(\Magento\Sales\Api\Data\OrderInterface $order): void
{
$this->orders[] = $order;
$this->count++;
$this->total += $order->getGrandTotal();
}
}
// Edge case 1: asymmetric visibility in readonly classes
readonly class ImmutableConfig
{
// ERROR: readonly class + asymmetric visibility = conflict
// readonly means you can only set in constructor, not at all after
// public private(set) string $key; // Not allowed in readonly class
}
// Edge case 2: inheritance - child class can widen write visibility but not narrow
class Base
{
public protected(set) string $name = '';
}
class Child extends Base
{
// Can make write more permissive:
public public(set) string $name = ''; // OK: protected(set) -> public(set)
// Cannot narrow write:
// public private(set) string $name = ''; // Error: can't narrow parent's protected(set)
}
// Edge case 3: constructor promotion with asymmetric visibility
class Config
{
public function __construct(
public private(set) string $apiKey, // set only in constructor
public protected(set) string $baseUrl, // set in constructor and child classes
) {}
}
Practical patterns that work well
<?php
declare(strict_types=1);
// Pattern 1: Validated DTO with hooks
class CustomerAddress
{
public string $postcode {
get => $this->postcode;
set {
// Validate Polish postcode format
if (!preg_match('/^\d{2}-\d{3}$/', $value)) {
throw new \InvalidArgumentException("Invalid Polish postcode: {$value}");
}
$this->postcode = $value;
}
}
public string $phone {
get => $this->phone;
set {
$clean = preg_replace('/[^0-9+]/', '', $value);
$this->phone = $clean; // normalise on set
}
}
}
// Pattern 2: Computed money with hooks
class Money
{
public function __construct(
private int $amountInPence,
public readonly string $currency,
) {}
public float $amount {
get => $this->amountInPence / 100;
set { $this->amountInPence = (int) round($value * 100); }
}
public string $formatted {
get => number_format($this->amount, 2, ',', ' ') . ' ' . $this->currency;
}
}
$price = new Money(9999, 'PLN');
echo $price->amount; // 99.99
echo $price->formatted; // 99,99 PLN
$price->amount = 79.99; // stores 7999 internally
echo $price->formatted; // 79,99 PLN
Summary
PHP 8.4 property hooks work exactly as designed. The main practical advice after RC testing: be explicit in docblocks when get and set have different semantics (e.g. net vs gross price), prefer virtual (get-only) properties for computed values over stored ones, and be careful with inheritance and readonly combinations. For asymmetric visibility, the pattern that works most cleanly is public-read/private-set on collection classes where external code needs to observe counts and totals but only the class should modify them.
