PHP 8.4 in Symfony in Practice: Property Hooks, Asymmetric Visibility
AI generated
SF
{ }
Symfony · PHP 8.4 · Modernization · Migration
PHP 8.4 in Symfony in Practice
Property Hooks, Asymmetric Visibility and a safe migration path

PHP 8.4 brings Property Hooks and Asymmetric Visibility, two language features that directly reduce boilerplate in Symfony entities and DTOs. Teams that back the upgrade with Rector and PHPStan can bring these benefits into existing projects without risk and replace outdated patterns step by step.

18 min read Property Hooks · Asymmetric Visibility · Rector Symfony 7.x · PHP 8.4

1. Why PHP 8.4 matters for Symfony teams now

PHP 8.4 is not just a maintenance release. It brings Property Hooks and Asymmetric Visibility, two language features that directly affect the everyday code in Symfony projects. Anyone who writes entities, DTOs, or value objects today knows the pattern: a private property, a getter, a setter with validation, often three times as many lines as strictly necessary. PHP 8.4 reduces exactly this boilerplate, without requiring any change to Symfony itself, because these features work at the language level and are fully compatible with the existing component system.

The second reason to look at PHP 8.4 now is the support timeline. Symfony 7.x officially supports PHP 8.4, and active support for older PHP versions is winding down step by step. A project still stuck on PHP 8.2 loses security patches earlier and also loses the ability to use new Symfony components without a compatibility layer. Introducing PHP 8.4 in Symfony is therefore less of an optional nicety and more of a planned part of maintaining technical debt.

This article shows concretely which PHP 8.4 features have the biggest leverage in Symfony projects, how to find deprecations before an upgrade, and how Rector and PHPStan automate the migration instead of turning it into weeks of manual work. Every section includes runnable examples from real Symfony structures: entities, serializer DTOs, and configuration classes.

2. Property Hooks: getters and setters without boilerplate

Property Hooks are the most visible PHP 8.4 feature for Symfony developers. Instead of encapsulating a private property with separate get/set methods, you define hooks directly on the property. A get hook runs on read, a set hook runs on write, and both can transform or validate the value before it is actually stored. This is especially useful in Doctrine entities, where validation and normalization were previously often pushed into dedicated setter methods.

The advantage over classic getters and setters is not just the line count. The property itself is still accessed like a normal field, so $product->price = 1999 instead of $product->setPrice(1999). For existing code that already uses getters and setters this is not a breaking change, because Property Hooks can be introduced incrementally without immediately changing a class's public API. In practice, teams migrate the classes with the most validation logic first, because that is where the effect is largest.


<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
    private int $priceCents = 0;

    // Property Hook: validation runs on every write, no separate setter needed
    public int $price {
        get => (int) round($this->priceCents / 100);
        set {
            if ($value < 0) {
                throw new \InvalidArgumentException('Price cannot be negative');
            }
            $this->priceCents = $value * 100;
        }
    }

    #[ORM\Column(type: 'string', length: 255)]
    public string $name {
        set(string $value) {
            $this->name = trim($value);
        }
    }
}

$product = new Product();
$product->price = 1999;   // triggers the set hook, validates and stores cents
echo $product->price;    // triggers the get hook, returns 1999

A detail that is often overlooked when starting with PHP 8.4 Property Hooks: hooks cannot be defined on static properties, and a get hook without a matching set hook makes the property effectively read only from the outside, which elegantly replaces virtual, computed properties that used to be implemented as a plain getter method. For Symfony serializer DTOs this means: computed fields such as a fullName built from first and last name can now be expressed as a real property, which the serializer recognizes correctly without any extra normalizer configuration.

3. Asymmetric Visibility: controlled mutability

Asymmetric Visibility solves a problem that comes up frequently in Symfony DTOs and value objects: a property should be readable from the outside but only writable from within the class. Before PHP 8.4 the only options were either readonly, which forbids any later change, or a private property with a public getter. With public private(set) you can now express exactly the semantics you actually mean: readable from outside, changeable from inside, with no getter method at all.

For Symfony applications this matters especially for aggregates and domain objects that change their internal state without exposing that change as a public setter method. An order status, for example, should only be changed through defined methods such as markAsShipped(), but should remain readable from outside at any time. Asymmetric Visibility turns this rule into a language feature instead of a convention that has to be enforced in code review.


<?php
declare(strict_types=1);

namespace App\Domain;

final class Order
{
    // Readable from outside, only writable from within this class
    public private(set) string $status = 'pending';

    public private(set) \DateTimeImmutable $updatedAt;

    public function __construct(
        public readonly string $id,
    ) {
        $this->updatedAt = new \DateTimeImmutable();
    }

    public function markAsShipped(): void
    {
        $this->status = 'shipped';
        $this->updatedAt = new \DateTimeImmutable();
    }
}

$order = new Order('order-123');
echo $order->status;       // fine, reading is allowed
$order->markAsShipped();
// $order->status = 'shipped'; // Fatal error: cannot modify from outside

A common mistake when switching over: confusing Asymmetric Visibility with a fully readonly class. readonly allows exactly one assignment, after which the property is frozen forever, even for the owning class itself. private(set) allows any number of changes as long as they originate from the owning class. For value objects that should never change after construction, readonly is still correct. For domain objects with a controlled lifecycle, private(set) is the more accurate PHP 8.4 feature.

4. New array functions in everyday code

Beyond the major language features, PHP 8.4 also brings smaller but noticeable improvements: array_find(), array_any(), and array_all() replace patterns that used to be solved with array_filter() plus reset(), or with a manual foreach loop. array_find() returns the first element that satisfies a callback, or null if none matches, without first filtering the entire list and then separately extracting the first element.

In Symfony controllers and services that frequently iterate over collections of entities or DTOs, this noticeably reduces the code. array_any() and array_all() replace the classic count(array_filter(...)) > 0 or count(array_filter(...)) === count(...) with a single, clearly named function that also short-circuits as soon as the result is known, instead of iterating through the entire list.


<?php
declare(strict_types=1);

/** @var Product[] $products */

// Before PHP 8.4: filter, then grab the first element
$filtered = array_filter($products, fn (Product $p) => $p->price > 5000);
$expensive = reset($filtered) ?: null;

// PHP 8.4: array_find stops at the first match, no intermediate array
$expensive = array_find(
    $products,
    fn (Product $p) => $p->price > 5000,
);

// array_any: true as soon as one element matches, short-circuits
$hasOutOfStock = array_any(
    $products,
    fn (Product $p) => $p->stock === 0,
);

// array_all: true only if every element matches
$allActive = array_all(
    $products,
    fn (Product $p) => $p->isActive,
);

Important for existing Symfony projects: these functions are pure additions, not replacements that would break existing code. A gradual PHP 8.4 upgrade can leave old array_filter patterns untouched and use the new functions only in new code, which makes reviews easier because diffs stay limited to actually changed code instead of rewriting whole files for the sake of consistency alone.

5. Detecting and avoiding deprecations

Every PHP 8.4 upgrade also introduces new deprecation warnings, which are easily overlooked in Symfony projects because they are not logged by default in production. Implicitly nullable parameters, meaning a type hint like string $value = null without an explicit ?string, have been deprecated since PHP 8.4 and should be cleaned up before the upgrade, because they will likely become an actual error in a coming version rather than just a warning.

The most reliable way to find deprecations before a production PHP 8.4 rollout is to run the Symfony test suite with the symfony/phpunit-bridge enabled, which collects deprecations and reports them at the end of the test run instead of silently swallowing them. Teams that make this report a mandatory CI step prevent new deprecations from accumulating unnoticed.


<?php
declare(strict_types=1);

// Deprecated since PHP 8.4: implicit nullable without explicit ?
function formatPrice(string $currency = null): string
{
    return $currency ?? 'EUR';
}

// Correct: explicit nullable type
function formatPriceFixed(?string $currency = null): string
{
    return $currency ?? 'EUR';
}

// phpunit.xml.dist: surface deprecations instead of hiding them
// <phpunit bootstrap="tests/bootstrap.php">
//   <php>
//     <env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0"/>
//   </php>
// </phpunit>

The SYMFONY_DEPRECATIONS_HELPER environment variable with max[self]=0 fails the test run as soon as the application's own code produces a new deprecation, while deprecations from third-party packages are counted separately. This separation matters because a team rarely has immediate control over every bundle dependency, but does have control over its own application code, which should be the first thing cleaned up for a smooth PHP 8.4 upgrade.

6. Symfony components that benefit from PHP 8.4

The Symfony Serializer benefits directly from Property Hooks, because computed or normalized properties no longer require extra normalizer classes. A DTO with a set hook that trims and normalizes incoming strings behaves the same way when deserialized from JSON as when instantiated manually, because the hook applies regardless of the caller. This reduces the number of custom normalizers that used to be written for simple transformations.

The DependencyInjection container also benefits indirectly: Asymmetric Visibility allows building service configuration objects that are effectively immutable after the container is compiled, without relying on full readonly, which used to cause problems for objects with late initialization through compiler passes. The Symfony Validator can use Property Hooks to anchor constraint checks directly on the property instead of relying exclusively on attributes and external validator classes, which keeps simple invariants closer to the actual rule for simple cases.

These improvements require no change to Symfony itself, because the framework passes PHP language features through instead of abstracting them away. A team introducing PHP 8.4 in Symfony therefore benefits immediately, without waiting for a Symfony minor release that adds explicit support.

7. Preparing Composer and Docker images

Before PHP 8.4 can be used in production, composer.json must allow the new version and every dependency must be compatible. Composer's platform configuration helps simulate the exact target version locally, even if the development environment still runs an older PHP version, which is especially useful in teams with mixed local setups.


{
    "require": {
        "php": ">=8.4",
        "symfony/framework-bundle": "^7.2"
    },
    "config": {
        "platform": {
            "php": "8.4.0"
        },
        "sort-packages": true
    },
    "scripts": {
        "check-deprecations": [
            "@php bin/phpunit --configuration phpunit.xml.dist"
        ]
    }
}

For Docker images, moving to PHP 8.4 usually only requires changing the base image version, for example from php:8.3-fpm to php:8.4-fpm, provided all the PHP extensions in use are already built for PHP 8.4. It is important to build the image in a staging environment first and run the complete test suite there before switching the production pipeline over to PHP 8.4, because extension compatibility is not always visible at the Composer level alone.

8. Rector and PHPStan for automated migration

Manually searching a large Symfony codebase for places that could benefit from PHP 8.4 is inefficient and error prone. Rector has shipped ready-made rule sets for PHP 8.4 since version 1.2, which automatically transform classic getter/setter pairs into Property Hooks whenever the pattern is unambiguous enough. This reduces manual migration to the cases where Rector deliberately leaves things unchanged for safety reasons.


<?php
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Php84\Rector\Property\PropertyHookRector;
use Rector\Set\ValueObject\LevelSetList;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([__DIR__ . '/src']);

    $rectorConfig->sets([
        LevelSetList::UP_TO_PHP_84,
    ]);

    // Optional: enable individual rules explicitly for a controlled rollout
    $rectorConfig->rule(PropertyHookRector::class);
};

PHPStan complements Rector by checking, after the automated transformation, whether types remain consistent, especially for Property Hooks whose get and set types can theoretically diverge. A level 5 run after every Rector batch catches cases where the automatic conversion was syntactically correct, but the original validation logic from the setter was not fully carried over into the set hook. This combination of Rector for the transformation and PHPStan for verification makes a PHP 8.4 upgrade practical even in a large Symfony monolith, instead of requiring weeks of manual follow-up work.

9. Rollout strategy for the team

A PHP 8.4 rollout in a running Symfony project should proceed in clearly separated phases: first raise the runtime environment to PHP 8.4 and get the complete test suite green without using any new language features. Only after that do teams start using new features such as Property Hooks in new code, while existing code stays unchanged until it is touched anyway. This separation prevents a single large pull request from mixing infrastructure changes with style changes and becoming unreadable for reviewers as a result.

The table below compares the common migration approaches for PHP 8.4 in existing Symfony projects.

Approach Effort Risk Recommendation
Big bang: rewrite everything at once Very high High Only reasonable for small codebases
Raise the runtime only, no new syntax Low Low First step in every project
Rector-assisted batch migration Medium Low Recommended for existing code
New features only in new code Very low Very low Sensible alongside any approach

In practice, successful teams combine the last three rows of the table: raise the runtime first, then use Rector for clearly automatable patterns, and in parallel write new classes with PHP 8.4 features from the start. The big bang approach from the first row is almost always the weaker choice, because it worsens reviewability and rollback ability at the same time.

Mironsoft

Symfony modernization and PHP version upgrades without production risk

Ready to bring PHP 8.4 safely into your Symfony application?

We audit your Symfony codebase for deprecations, set up Rector rule sets for automated migration, and support the PHP 8.4 rollout all the way into production.

Deprecation audit

Full analysis with symfony/phpunit-bridge before every upgrade

Rector migration

Automated conversion to Property Hooks and Asymmetric Visibility

Docker rollout

Staging-verified PHP 8.4 images for your CI/CD pipeline

10. Summary

PHP 8.4 in Symfony cuts boilerplate exactly where it matters most in everyday code: getters and setters are replaced by Property Hooks, controlled mutability becomes a language feature with Asymmetric Visibility instead of a convention, and new array functions such as array_find() replace clunky filter chains. These improvements apply directly to Doctrine entities, serializer DTOs, and domain objects, without requiring any change to Symfony itself.

The safe path to PHP 8.4 runs through three steps: first surface and fix deprecations with the symfony/phpunit-bridge, then raise the runtime environment and get the test suite green, and only then gradually introduce new features with Rector-assisted automation. PHPStan secures every step by catching type inconsistencies after automated transformations. Teams that follow this order can bring PHP 8.4 into existing Symfony projects without production risk.

PHP 8.4 in Symfony — The Essentials at a Glance

Property Hooks

Replace getters/setters directly on the property, ideal for Doctrine entities and serializer DTOs with validation.

Asymmetric Visibility

public private(set) turns controlled mutability into a language feature instead of a code review convention.

Deprecations first

symfony/phpunit-bridge with SYMFONY_DEPRECATIONS_HELPER as a mandatory CI step before every upgrade.

Rector plus PHPStan

Automated migration followed by type checking makes the upgrade practical even in large codebases.

11. FAQ: PHP 8.4 in Symfony

1Do I need to update Symfony for PHP 8.4?
Symfony 7.x officially supports PHP 8.4. Older versions like 6.4 LTS work partially, check the compatibility matrix first.
2What are Property Hooks?
get and set logic directly on a property, without separate getter and setter methods.
3Asymmetric Visibility vs. readonly?
readonly allows one assignment, then frozen. private(set) allows any internal changes, read only from outside.
4Do implicit nullable parameters break things immediately?
No, only a deprecation warning in PHP 8.4. Likely an error in a future version, so clean it up early.
5Can Rector auto-convert getters/setters?
Yes, since Rector 1.2 for unambiguous cases. More complex logic still needs manual review.
6How do I find deprecations up front?
Run the test suite with symfony/phpunit-bridge and SYMFONY_DEPRECATIONS_HELPER, collects all warnings as a report.
7Do new array functions work with Doctrine Collections?
Only with native arrays directly. For Doctrine Collections use toArray() first or equivalent Collection methods.
8Convert existing code right away?
No, gradually. New classes directly with Property Hooks, existing ones only when touched anyway.
9Which extensions to check before the Docker switch?
APCu, Redis, Intl, Xdebug and all project-specific extensions. Run a test build in staging with the full suite.
10Does PHP 8.4 slow the application down?
No, compile-time constructs with no measurable overhead. JIT-optimized execution is sometimes marginally faster.