PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

PHP 8.4 RC – property hooks in practice, surprises, asymmetric visibility

by Henryk Tews / Tuesday, 02 July 2024 / Published in PHP

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.

About Henryk Tews

What you can read next

PHP 7.2 – object type hint, sodium instead of mcrypt, deprecations

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}