The release of PHP 8.4 coupled with Symfony 7.4 (LTS) marks a pivotal moment in our ecosystem. For over a decade, we have religiously generated getters and setters, bloating our entities and DTOs with boilerplate that adds visual noise but zero business value.
With PHP 8.4’s introduction of Property Hooks and Asymmetric Visibility, we can finally retire the “Java-fication” of PHP objects. We can write concise, expressive domain models that encapsulate behavior without the verbosity.
In this article, I will explore how to modernize a Symfony 7.4 application using these features. I will refactor a legacy-style Doctrine entity into a modern, lean PHP 8.4 component, ensuring full compatibility with Symfony’s Serializer, Validator and Forms.
The Environment
Before we dive into the code, let’s ensure you are running the correct stack. Symfony 7.4 is the Long-Term Support version released in November 2025.
Ensure your local environment is ready. Run the following commands:
php -v
# Output must indicate PHP 8.4.0 or higher
composer version
# Ensure you are on Composer 2.8+
We will use standard Symfony 7.4 components. If you are starting a new project:
composer create-project symfony/skeleton:7.4.* my_project
cd my_project
composer require symfony/webapp-pack:7.4.*
composer require symfony/orm-pack
composer require symfony/serializer-pack
composer require symfony/validator-pack
If you are upgrading an existing project, ensure your composer.json restricts php to ^8.4 and symfony/* packages to ^7.4.
Asymmetric Visibility: The End of Getter/Setter Boilerplate
For years, we made properties private and added public getters to prevent external modification, while allowing internal access.
The Old Way (PHP 8.3 and below):
class User
{
private string $email;
public function __construct(string $email)
{
$this->email = $email;
}
public function getEmail(): string
{
return $this->email;
}
// No setter meant it was effectively "read-only" externally,
// but we still needed the getter to read it.
}
The PHP 8.4 Way: Asymmetric visibility allows us to define the “set” permission independently of the “get” permission.
class User
{
// Publicly readable, but only writeable by the class itself (private)
public private(set) string $email;
public function __construct(string $email)
{
$this->email = $email;
}
}
Integration with Doctrine
This works seamlessly with Doctrine ORM in Symfony 7.4. Doctrine uses reflection to hydrate properties, bypassing visibility guards, so public private(set) is perfectly safe for mapped entities.
A Modern Doctrine Entity
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
public private(set) string $name;
#[ORM\Column]
#[Assert\PositiveOrZero]
public private(set) int $stock = 0;
public function __construct(string $name)
{
$this->name = $name;
}
// Business logic method to mutate state
public function addToStock(int $quantity): void
{
if ($quantity < 1) {
throw new \InvalidArgumentException('Quantity must be positive');
}
$this->stock += $quantity;
}
}
Why this matters:
- Zero Boilerplate: No getId(), getName(), getStock().
- Safe Encapsulation: $product->stock = 100 triggers a fatal error from outside the class. You must use addToStock(), enforcing your business rules.
- DX (Developer Experience): Autocompletion works directly on properties ($product->name), which feels more “native” than method calls.
Property Hooks: Intelligent Properties
While Asymmetric Visibility controls access, Property Hooks allow us to control behavior. They replace magic methods (__get, __set) and explicit getters/setters with logic attached directly to the property.
Data Normalization (The set hook)
Imagine a User entity where the email must always be stored in lowercase.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public private(set) ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
public string $email {
// The 'set' hook intercepts assignment
set(string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email format");
}
// The backing value is updated automatically via this assignment
$this->email = strtolower($value);
}
}
public function __construct(string $email)
{
// This triggers the set hook!
$this->email = $email;
}
}
Verification:
$user = new User('ADMIN@Example.com');
echo $user->email; // Outputs: admin@example.com
Virtual Properties (The get hook)
Virtual properties do not store data; they calculate it on the fly. This is perfect for convenience accessors in Symfony DTOs or Entities that don’t need to be persisted to the database.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Customer
{
#[ORM\Column]
public string $firstName;
#[ORM\Column]
public string $lastName;
// This is a VIRTUAL property. It has no storage in memory.
// Notice we do NOT add #[ORM\Column] because it's not in the DB.
public string $fullName {
get => $this->firstName . ' ' . $this->lastName;
}
}
Integration with Symfony Serializer
One of the most powerful features of Property Hooks in Symfony 7.4 is how the Serializer component handles them.
- Backed Properties: Serialized normally.
- Virtual Properties: If they are public, the Serializer treats them just like real properties. You no longer need #[SerializedName] on a getFullName() method.
Controller Example:
namespace App\Controller;
use App\Entity\Customer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class CustomerController extends AbstractController
{
#[Route('/customer/{id}', methods: ['GET'])]
public function show(Customer $customer): JsonResponse
{
// Serialization automatically includes "fullName" in the JSON output
return $this->json($customer);
}
}
Output:
{
"firstName": "Jane",
"lastName": "Doe",
"fullName": "Jane Doe"
}
Advanced Pattern: Value Objects & DTOs
Let’s look at a more complex scenario involving a Data Transfer Object (DTO) for a Symfony Form or API payload. We will use validation attributes directly on hooked properties.
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
class RegistrationRequest
{
#[Assert\NotBlank]
public string $firstName;
#[Assert\NotBlank]
public string $lastName;
// A 'write-only' property behavior for passwords using a hook
// We only want to set it, but reading it might return a masked value or be disallowed
public string $password {
set(string $value) {
$this->password = password_hash($value, PASSWORD_BCRYPT);
}
get => '***MASKED***';
}
// Combining hooks with asymmetric visibility
// Publicly readable, but only settable internally or by hydration
public private(set) \DateTimeImmutable $registeredAt {
get => $this->registeredAt ?? new \DateTimeImmutable();
}
}
Warning: Be careful with the get hook on $registeredAt above. If the property is uninitialized, accessing it via $this->registeredAt inside the hook causes infinite recursion. The ?? check is a safe pattern if the backing field might be null.
Refactoring a Legacy Service
Let’s look at a Service class. Traditionally, we might inject dependencies and use standard methods. With PHP 8.4, we can make our service configurations cleaner.
Scenario: An EmailService that needs a default “sender” address, but we want to allow changing it temporarily with strict validation.
namespace App\Service;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class EmailService
{
public function __construct(
private readonly MailerInterface $mailer,
// Property Hook to validate configuration immediately
public string $defaultSender {
set(string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid sender email");
}
$this->defaultSender = $value;
}
}
) {}
public function sendWelcomeEmail(string $recipient): void
{
$email = (new Email())
->from($this->defaultSender)
->to($recipient)
->subject('Welcome!')
->text('Welcome to our platform.');
$this->mailer->send($email);
}
}
In services.yaml:
services:
App\Service\EmailService:
arguments:
$mailer: '@mailer'
$defaultSender: '%env(DEFAULT_EMAIL_SENDER)%'
If the environment variable DEFAULT_EMAIL_SENDER contains an invalid email, the service instantiation will fail immediately with a clear Exception, rather than failing silently later when the email is sent. This “Fail Fast” approach is enhanced by property hooks.
Symfony 7.4 Form Component Compatibility
Symfony Forms rely heavily on the PropertyAccessor component to read and write data to your objects.
Good News: The PropertyAccessor in Symfony 7.4 fully supports PHP 8.4 hooks.
- When the form reads data to populate fields, it triggers the get hook.
- When the form submits data back to the object, it triggers the set hook.
Transformations in the Entity
You can often remove Form Data Transformers by moving the logic to the Entity/DTO hook.
// Old way: create a CallbackTransformer to handle string-to-array conversion
// New way:
class TagAwareDTO
{
/** @var string[] */
private array $tags = [];
// The form field can be mapped to 'tagString'
// The underlying data is stored in $tags array
public string $tagString {
get => implode(', ', $this->tags);
set(string $value) {
$this->tags = array_map('trim', explode(',', $value));
}
}
public function getTags(): array
{
return $this->tags;
}
}
In your FormType:
$builder->add('tagString', TextType::class, [
'label' => 'Tags (comma separated)',
]);
This simplifies the FormType significantly, keeping the data transformation logic within the domain object where it belongs.
Performance Considerations & Pitfalls
While these features are excellent, “Senior” developers know when not to use them.
The Cloning Pitfall
When you clone an object, PHP performs a shallow copy. If you use asymmetric visibility, the cloned object retains the same visibility rules.
However, be careful with Virtual Properties. If a virtual property depends on another object (e.g., $this->user->name) and you clone the parent object but not the internal $user, the virtual property might still point to the old user reference. This is standard PHP object behavior, but hooks can hide this complexity.
Doctrine Proxies
Doctrine uses Proxies (subclasses) for lazy loading.
- Asymmetric Visibility: Works fine because Doctrine uses Reflection to set private properties.
- Hooks: If you define a hook on a mapped property, Doctrine will trigger it when hydrating the entity unless it uses reflection (which it does). However, be very careful about adding side effects (like logging or database calls) inside a set hook of a Doctrine entity. When Doctrine hydrates the object from the DB, you do not want those side effects to run.
Best Practice: Avoid side-effects (logging, API calls) in Entity hooks. Keep hooks strictly for data validation and transformation.
Complexity Hiding
Don’t put 50 lines of business logic inside a set hook. If the logic is complex, delegate it to a private method or, better yet, a Service. Hooks should be concise.
Bad:
public string $status {
set {
// 50 lines of code checking permissions, logging to DB, sending emails...
}
}
Good:
public string $status {
set {
$this->validateStatusTransition($value);
$this->status = $value;
}
}
Migration Guide: From 7.3/8.3 to 7.4/8.4
If you are upgrading a massive codebase, do not try to rewrite everything at once.
- Asymmetric Visibility. Run a regex search or use Rector (once updated for 8.4) to replace private properties + simple getters with public private(set). Search: private type $prop; … getProp() { return $this->prop; } Replace: public private(set) type $prop;
- Virtual Properties for API Resources. Look for classes with #[SerializedName(‘virtual_field’)] and getVirtualField() methods. Convert them to virtual properties.
- Validation. Move simple Assert logic that transforms data (like trim() or strtolower()) from controllers/listeners into Property Hooks.
Conclusion
PHP 8.4 and Symfony 7.4 allow us to write code that is arguably the cleanest in the history of the language. We are moving away from the verbose, “dumb” data structures of the past toward intelligent, self-managing objects.
Key Takeaways
- Use Asymmetric Visibility (public private(set)) to expose data safely without getters.
- Use Property Hooks for lightweight validation, transformation and virtual properties.
- Symfony Serializer and Forms support these features out of the box in 7.4.
- Keep hooks pure and fast; avoid heavy side effects.
The days of generating getters and setters are over. Welcome to the era of modern PHP.
Is your team ready to migrate to Symfony 7.4? I help organizations modernize legacy PHP applications and adopt high-performance architectures.
Let’s connect: Be in touch on LinkedIn [https://www.linkedin.com/in/matthew-mochalkin/]
Top comments (0)