PHP 8.4 Property Hooks in Symfony: What They Change for Entity Design, DTOs, and Form Objects
PHP 8.4 property hooks are the most significant addition to the language's object model since typed properties arrived in PHP 8.0. On paper, the feature looks like a tidy quality-of-life improvement: less boilerplate, cleaner computed values, no more trivial getter/setter pairs. In practice, if you run a Symfony application backed by Doctrine ORM and use API Platform for your API layer, property hooks interact with your stack in ways the RFC does not explain and that the framework documentation still lags on.
This post covers the three patterns teams reach for first, what actually happens when each one meets Doctrine's Unit of Work, Symfony's PropertyAccessor, and API Platform's serializer — and the migration checklist you need before upgrading a production codebase from PHP 8.3.
What PHP 8.4 Property Hooks Actually Are
A hooked property replaces the separate get/set method pair with inline logic attached directly to the property declaration. A get hook runs when the property is read; a set hook runs when it is written.
class Money
{
public int $amountInCents {
get => $this->amountInCents;
set(int $value) {
if ($value < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
$this->amountInCents = $value;
}
}
}
Properties can also be virtual — they have a hook but no backing storage. A virtual get-only property computes its value on demand with no field behind it:
class FullName
{
public string $full {
get => trim("{$this->first} {$this->last}");
}
public function __construct(
public string $first,
public string $last,
) {}
}
That is the syntax. Now let us look at what happens when this meets a Symfony + Doctrine codebase.
Pattern 1: Computed Properties on Doctrine Entities
The temptation is obvious. You have a Product entity with priceNet and taxRate stored as columns. You want priceGross to be a computed read on the fly. Previously you wrote a method; now you want a virtual property.
#[ORM\Entity]
class Product
{
#[ORM\Column]
public float $priceNet;
#[ORM\Column]
public float $taxRate;
public float $priceGross {
get => $this->priceNet * (1 + $this->taxRate);
}
}
Does this survive a Doctrine flush? Yes — with an important caveat. Doctrine's metadata driver ignores properties it has no mapping for, so $priceGross is simply not tracked by the Unit of Work. Reading $product->priceGross works fine. Flushing $product does not try to persist $priceGross. So far so good.
The problem surfaces with Doctrine's lazy-loading proxies. When Doctrine generates a proxy class for Product, it must override every property that participates in its interception mechanism. Properties with hooks cannot be overridden in a subclass in the same way as plain properties — the proxy generator in Doctrine ORM versions prior to 3.3 does not understand hook syntax and will emit invalid PHP, causing a fatal error on first access.
If you are on Doctrine ORM 3.3 or later, proxy generation handles hooked properties correctly. If you are on an earlier release, the workaround is to keep computed values as regular methods and wait for your dependency constraints to allow the upgrade.
Verdict: Safe to use for read-only computed properties on entities if you are on Doctrine ORM ≥ 3.3. Do not use backing-field hooks on mapped columns — Doctrine's change-tracking will fight you.
Pattern 2: Validation Logic in DTOs
Data Transfer Objects are a natural fit for set hooks. Instead of calling Assert attributes and running the validator externally, you might want to enforce invariants at assignment time:
class CreateOrderInput
{
public string $email {
set(string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
$this->email = $value;
}
}
}
This works correctly when you instantiate the DTO manually and assign values in code. Where it breaks is with Symfony's PropertyAccessor, which is the component that Symfony Forms and the Serializer use internally to hydrate objects from incoming data.
PropertyAccessor currently resolves write paths using this priority: public property → setter method → constructor. As of Symfony 7.1, it does not call set hooks when writing to a property — it writes directly to the backing storage, bypassing your hook entirely.
This means your validation logic silently does nothing when a form submits or when API Platform deserializes a request body into your DTO. The hook code exists; it is simply never called by the framework layer.
Symfony 7.2 introduced PropertyAccessor support for property hooks via the DISALLOW_PUBLIC_PROPERTY_ACCESS strategy combined with a new hook-aware write path. If you are on Symfony 7.2 or later and you configure PropertyAccessor to use this strategy, set hooks will be called during form hydration and serializer deserialization.
If you are on Symfony 7.0 or 7.1, set hooks on DTOs used with forms or API Platform are a silent no-op for validation. Use #[Assert\Email] attributes and the Symfony Validator component instead — they work regardless of PHP or Symfony version.
Verdict: Only reliable for DTO validation in Symfony 7.2+. On earlier versions, Symfony's form and serializer stack bypasses set hooks. Use constraint attributes and the Validator as your primary validation mechanism on any version.
Pattern 3: Backing-Field-Less Form Objects
The third pattern removes the backing field entirely, using the set hook to forward writes to another property or external storage:
class RegistrationForm
{
private string $rawPassword = '';
public string $password {
get => ''; // never expose
set(string $value) {
$this->rawPassword = password_hash($value, PASSWORD_BCRYPT);
}
}
public function getRawPassword(): string
{
return $this->rawPassword;
}
}
The intent is reasonable — hash on assignment, never store plaintext. The problem here is that Symfony's FormType system reads initial values from the bound data object when rendering the form. If get returns an empty string (as in the example above), the field renders correctly. But when PropertyAccessor writes back the submitted value, the same bypass issue from Pattern 2 applies on Symfony < 7.2.
There is a second problem specific to backing-field-less properties: PHP does not allow you to declare a property without a backing field and then assign $this->propertyName = $value inside the set hook — that creates an infinite loop. The pattern above sidesteps this by writing to $rawPassword instead, which is correct. But teams often try the simpler forwarding approach and hit infinite recursion immediately.
Additionally, Symfony's PasswordHasherType and the existing PasswordType are already designed for this use case and integrate cleanly with the security layer. Building a custom hook-based solution re-implements what those types handle for you, including the "do not rehash if unchanged" logic.
Verdict: Technically possible but fragile in Symfony < 7.2, and largely unnecessary given existing Symfony form field types. Use this pattern only if you have a strong reason to own the hashing logic yourself, and only on Symfony 7.2+.
Where API Platform Fits In
API Platform's serialization layer uses the Symfony Serializer component, which in turn uses PropertyAccessor. The same Symfony version constraint applies: on Symfony < 7.2, the serializer does not invoke set hooks during deserialization. This affects PATCH and POST requests where API Platform populates your input DTO or entity from the request body.
API Platform 3.3 (compatible with Symfony 7.2) ships with explicit hook support in its metadata extraction layer. It reads hook declarations when building API resource metadata, which means you can expose a computed get-only property as a serialized field without needing a separate getter method. This is a genuine ergonomic win for read models.
#[ApiResource]
#[ORM\Entity]
class Invoice
{
#[ORM\Column]
public float $subtotal;
#[ORM\Column]
public float $vatRate;
#[ApiProperty(readable: true, writable: false)]
public float $total {
get => round($this->subtotal * (1 + $this->vatRate), 2);
}
}
On API Platform 3.3 + Symfony 7.2 + Doctrine ORM 3.3, this combination works without additional configuration. On earlier versions, mark the computed value with a #[SerializedName] annotation on a real getter method instead.
Services and the /services/ Pages
If your team is currently evaluating whether to adopt PHP 8.4 as part of a broader modernization push, the considerations above sit within a wider legacy code modernization decision. In our custom software development work we regularly help teams identify exactly where a language upgrade creates immediate value versus where it introduces risk that needs to be managed first.
Migration Checklist: PHP 8.3 → PHP 8.4 in a Symfony Codebase
Run through this before flipping the PHP version in production.
Before upgrading:
- Confirm Doctrine ORM is on 3.3 or later (
composer show doctrine/orm). If not, upgrade Doctrine first. - Confirm Symfony is on 7.2 or later if you intend to use property hooks in DTOs or form objects. If you are staying on 7.0 or 7.1, property hooks are safe for entity computed properties only.
- Audit any Doctrine entity classes for existing getter/setter pairs on mapped columns. Do not replace these with hooked properties without verifying proxy generation in a staging environment.
- Run
php bin/console doctrine:generate:proxies(or the equivalent for your proxy cache strategy) after the upgrade and check for PHP fatal errors before testing.
During upgrade:
- Upgrade in a branch. Do not co-deploy a PHP version bump with feature work.
- Enable PHP 8.4 on a single staging server first. Run your full test suite. Pay attention to any test that instantiates entities and writes to them via
PropertyAccessor— these are the tests that will expose thesethook bypass. - Use
php -lon your entiresrc/directory to catch any syntax issues before deployment.
After upgrading:
- Gradually introduce hooked properties starting with read-only computed values on non-entity classes. These are zero-risk.
- Introduce
sethooks on DTOs and form objects only after confirming Symfony 7.2 compatibility end-to-end. - Update
dateModifiedin your blog and documentation to reflect the upgrade date — Google uses freshness signals when ranking technical content.
The Short Version
PHP 8.4 property hooks are genuinely useful, but the interaction surface in a Symfony + Doctrine stack is specific. Read-only computed properties on entities are safe on Doctrine ORM 3.3+. Set hooks for validation or controlled writes require Symfony 7.2 and the updated PropertyAccessor strategy. API Platform gains ergonomic metadata support in 3.3. If your stack is below those versions, hooks are either silently bypassed or will break proxy generation.
If you are planning a PHP 8.4 upgrade and want a second pair of eyes on the risk surface before you commit, get in touch at hello@wolf-tech.io or book a free technical review at wolf-tech.io. We have run these upgrades on production codebases ranging from small SaaS applications to enterprise systems with 18 years of accumulated Doctrine mappings — the risk points are known and manageable with the right sequence.

