Modern PHP with Property Hooks and Asymmetric Visibility in PHP 8.4+: Clean OOP Without Boilerplate

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Problem: The Cost of Traditional PHP Boilerplate

Writing clean domain models in PHP has long required a frustrating tradeoff between encapsulation and developer sanity. Every time you create an entity or a data transfer object (DTO), you end up writing lines of defensive code that do little more than ferry variables across class boundaries.

To stop outside callers from meddling with internal state, standard practice meant locking properties down as private or protected, then generating pairs of getters and setters:

<?php

class UserProfile
{
    private string $username;
    private int $reputationScore = 0;

    public function __construct(string $username)
    {
        $this->setUsername($username);
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function setUsername(string $username): void
    {
        $cleaned = trim($username);
        if (strlen($cleaned) < 3) {
            throw new InvalidArgumentException('Username must be at least 3 characters.');
        }
        $this->username = $cleaned;
    }

    public function getReputationScore(): int
    {
        return $this->reputationScore;
    }

    public function increaseReputation(int $points): void
    {
        if ($points > 0) {
            $this->reputationScore += $points;
        }
    }
}

Why did this happen? Classic PHP enforced binary visibility. A property was either wide open or completely locked away. Exposing public string $username gave callers read access, but it also stripped away all formatting guards and validation checks. In a production codebase with 40 domain entities averaging 8 properties each, that single limitation easily bloats your project by 1,500 to 2,000 lines of mindless accessor code.

How State Management Has Evolved in PHP

Managing state has improved steadily over recent major releases. Here is how past approaches stack up against modern PHP 8.4:

Approach Read/Write Access Inline Validation Boilerplate Overhead
PHP 7.4 Typed Properties Uniform (public/protected/private) Requires explicit setter methods High (dozens of manual getters/setters)
PHP 8.1 Readonly Properties Immutable after initialization None on update (cannot mutate) Low, but unusable for mutable state
PHP 8.2+ Magic Methods (__get / __set) Simulated dynamic access Possible, but bypasses static typing Medium; degrades IDE autocompletion and static analysis
PHP 8.4+ Hooks & Asymmetric Visibility Fine-grained (e.g. public private(set)) Directly attached via get and set hooks Extremely low; full type safety and IDE support

Why Previous Workarounds Fell Short

Teams frequently relied on __get() and __set() magic methods to simulate computed or restricted properties. That approach came with steep downsides. Dynamic property interception runs roughly 3x to 5x slower in microbenchmarks than direct access, breaks IDE code navigation, and forces you to write custom docblock annotations so PHPStan or Psalm can understand your types.

PHP 8.1 brought readonly properties, solving the problem for static DTOs. However, domain entities that evolve over their lifecycle—like shopping carts, subscriptions, and user profiles—remained burdened by legacy getter/setter noise.

PHP 8.4 tackles this head-on with two focused language additions:

  • Asymmetric Visibility: Controls read and write permissions independently (for instance, allowing public reads while restricting writes to the class itself).
  • Property Hooks: Embeds custom read (get) and write (set) logic straight into the property declaration.

Pros and Cons: Evaluating PHP 8.4 Property Features

Key Benefits

  • Much Less Code to Maintain: A typical domain model shrinks by 40% to 60%. Your files focus on business invariants rather than repetitive accessor methods.
  • Safe Incremental Refactoring: You can start with a bare public property and introduce a hook later. Consumers continue accessing $user->username without breaking public APIs.
  • Native Tooling Support: Static analyzers like PHPStan 2.0+ and IDEs like PhpStorm understand both features without extra plugins or docblock tricks.
  • Natural Syntax: Callers read and write standard object properties with direct assignment, making consumption straightforward.

Real-World Trade-Offs

  • Risk of Hook Bloat: Hooks should stay fast and lightweight. Triggering HTTP requests, database queries, or heavy domain events inside a set hook violates separation of concerns and leads to subtle debugging headaches.
  • Constructor Promotion Limits: While asymmetric visibility works smoothly in constructor parameter promotion, property hooks cannot be declared directly inside constructor signatures.
  • Team Habits: Developers accustomed to old PSR getter conventions may need time to adapt to reading properties directly instead of calling $order->getStatus().

Environment Configuration

Both features require PHP 8.4 or newer.

1. Check Your PHP Version

php -v
# Ensure output shows PHP 8.4.0 or higher

2. Configure Composer and Quality Tools

Lock your environment to PHP 8.4 and pull in compatible static analysis and testing libraries:

{
    "require": {
        "php": ">=8.4.0"
    },
    "require-dev": {
        "phpstan/phpstan": "^2.0",
        "phpunit/phpunit": "^11.0"
    }
}

Refactoring our core billing entities to use these features cut our pull request diffs by hundreds of lines. Reviewers spent time checking business rules instead of scanning through identical getter boilerplate.

Practical Step-by-Step Refactoring

Step 1: Set Up Asymmetric Visibility

Take an order processing model. Its identifier, order total, and status should be readable by templates and reporting jobs, but write operations must remain strictly inside the model itself. Asymmetric visibility handles this directly at declaration:

<?php

declare(strict_types=1);

class Order
{
    // Fully readable, but can only be modified from within Order
    public private(set) string $status = 'pending';

    // Readable anywhere, but updates are restricted to class and subclasses
    public protected(set) DateTimeImmutable $updatedAt;

    public function __construct(
        public private(set) int $id,
        public private(set) float $amount
    ) {
        $this->updatedAt = new DateTimeImmutable();
    }

    public function markAsCompleted(): void
    {
        $this->status = 'completed';
        $this->updatedAt = new DateTimeImmutable();
    }
}

$order = new Order(id: 1042, amount: 89.95);
echo $order->status; // Prints 'pending' (Allowed: public read)

// $order->status = 'cancelled'; 
// Fatal Error: Cannot modify private(set) property Order::$status from global scope

Step 2: Add Validation and Normalization Using Hooks

When input needs trimming or format validation, attach set and get hooks directly to the property. Backed properties use an implicit $value argument, and returning a value sets the underlying property state.

<?php

declare(strict_types=1);

class CustomerAccount
{
    public string $email {
        set {
            $sanitized = filter_var(trim($value), FILTER_VALIDATE_EMAIL);
            if ($sanitized === false) {
                throw new InvalidArgumentException("Invalid email address: {$value}");
            }
            return strtolower($sanitized);
        }
    }

    // Virtual property: computed on access without backing storage
    public string $displayName {
        get => ucfirst(explode('@', $this->email)[0]);
    }

    public function __construct(string $email)
    {
        $this->email = $email; // Automatically triggers the set hook
    }
}

$account = new CustomerAccount('  [email protected] ');
echo $account->email;       // '[email protected]'
echo $account->displayName; // 'Alex.dev'

Step 3: Combine Hooks with Asymmetric Visibility

Combining both features yields concise, self-protecting domain models. Here is a voting widget where vote counts are publicly visible, but external callers cannot manually tamper with or decrement the tally:

<?php

declare(strict_types=1);

class ProductReview
{
    public private(set) int $helpfulVotes = 0 {
        set {
            if ($value < 0) {
                throw new LogicException('Votes cannot be negative.');
            }
            return $value;
        }
    }

    public function __construct(public readonly string $content) {}

    public function upvote(): void
    {
        $this->helpfulVotes++;
    }
}

$review = new ProductReview('Fast delivery and solid build quality.');
$review->upvote();
echo $review->helpfulVotes; // 1

// $review->helpfulVotes = 10; 
// Fatal Error: Cannot modify private(set) property ProductReview::$helpfulVotes from global scope

With PHP 8.4, your entity classes stay lean, expressive, and properly guarded without thousands of lines of boilerplate.

Share: