Skip to content

PHP 8.4 Asymmetric Visibility: The Getters I Finally Deleted

PHP 8.4 Asymmetric Visibility: The Getters I Finally Deleted

Confession: I have written the same getter method roughly four thousand times. public function getId(): int { return $this->id; }. Every value object, every entity, every little DTO that needed a field you could read but not scribble over from the outside. My hands typed it before my brain even engaged. And every time, a small voice asked why a language this old still made me hand-write a read-only property.

PHP 8.4 finally answered that. It shipped on November 21, 2024, and buried in the release notes next to the flashier property hooks was a feature called asymmetric visibility. I ignored it for about a month. Then I tried it on one class, deleted six methods, and haven’t looked back. This is the one PHP 8.4 change that actually shrank my code instead of adding to it.

What asymmetric visibility actually does

The idea is small and the payoff is big: a property can have one visibility for reading and a stricter one for writing. You spell the write side with (set) after the keyword. So public private(set) int $id means anyone can read $id, but only the class itself can change it.

That’s it. That’s the whole feature. The asymmetric visibility RFC borrowed the syntax from Swift, and it passed 24 to 7 in August 2024 after an earlier version got voted down in 2023. Reading the RFC discussion is a decent reminder that “obvious” language features take years of argument to land.

There’s also a shorthand. Because “public to read, private to write” is the case you’ll reach for ninety percent of the time, you can drop the public and just write private(set) int $id. Same meaning. I use the shorthand everywhere now.

The before: my read-only ID boilerplate

Here’s the kind of class I used to write in PHP 8.3. An Order with an ID and a status that outside code should read but never set directly.

final class Order
{
    private int $id;
    private string $status;

    public function __construct(int $id, string $status)
    {
        $this->id = $id;
        $this->status = $status;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function markShipped(): void
    {
        $this->status = 'shipped';
    }
}

Count the ceremony. Two private properties, a constructor that copies its arguments across one by one, and two getters whose only job is to hand back a value unchanged. The actual behaviour, markShipped(), is four lines lost in a sea of plumbing.

readonly from PHP 8.1 solved half of this, but only for fields that never change. status changes. So readonly was out, and I was back to a private property plus a getter plus a mutator method.

The after: public read, private set

Same class, PHP 8.4, using constructor promotion and asymmetric visibility together:

final class Order
{
    public function __construct(
        public private(set) int $id,
        public private(set) string $status,
    ) {}

    public function markShipped(): void
    {
        $this->status = 'shipped';
    }
}

Outside the class, $order->id and $order->status read exactly like public properties. Try $order->status = 'cancelled' from a controller and you get a fatal error, because the write side is private. Inside the class, markShipped() sets $this->status with no fuss.

The getters are gone. The manual assignments in the constructor are gone. What’s left is the data and the one method that does something. When I read this class back a month later, I actually see what it’s for.

Where it clicks with property hooks

Asymmetric visibility on its own controls who can write. Property hooks, the other big PHP 8.4 feature, control what happens when they do. Put them together and you get a field that’s open for reading, locked down for writing, and validated on every internal set.

final class Temperature
{
    public private(set) float $celsius {
        set (float $value) {
            if ($value < -273.15) {
                throw new \ValueError('Below absolute zero.');
            }
            $this->celsius = $value;
        }
    }

    public function __construct(float $celsius)
    {
        $this->celsius = $celsius;
    }
}

Read $temp->celsius anywhere. Only the class can assign it, and when it does, the hook rejects anything below absolute zero. No separate setter method, no getter, and the validation lives right next to the property it guards instead of three methods away.

I wrote a whole separate post on what I kept and dropped from property hooks a year in, so I won’t relitigate them here. The short version: hooks and asymmetric visibility are better together than either is alone. Larry Garfield, who co-authored both RFCs, has a good writeup on property hooks in practice if you want the design reasoning from the source.

The rules that tripped me up

I hit three walls in the first week, so save yourself the trouble.

First, the property needs a type. public private(set) $id without int in front won’t compile. Asymmetric visibility requires a declared type, full stop. In practice this is fine because I type everything anyway, but it’ll bite you on an old untyped property you’re trying to modernize.

Second, the write side has to be equal to or stricter than the read side. You can write public private(set) or public protected(set) or protected private(set). You cannot write private public(set), because a property you can’t read but anyone can write makes no sense. The compiler agrees and tells you so.

Third, and this one cost me an afternoon: a private(set) property can still be written by other instances of the same class, not just $this. That’s normal PHP visibility behaviour, but it surprised me when a clone-and-modify helper on the same class happily reassigned a field I thought was locked. Worth knowing before you assume “private set” means “frozen after construction.”

Brent’s rundown of what’s new in PHP 8.4 covers the edge cases in more depth, and it’s the page I keep open in a tab whenever I forget the exact syntax.

When I don’t bother

Asymmetric visibility isn’t free of judgment calls. If a field genuinely never changes after construction, readonly says that more clearly and stops writes even from inside the class. I reach for readonly first and only drop to private(set) when the value has to mutate somewhere in the object’s life, like a status field or a running total.

And for a quick internal array-shaped bag of data that never leaves one function, I still just use an array. Not every three-line structure needs to become a class with controlled visibility. The feature earns its place on domain objects that outlive a single request and get passed around, where “who is allowed to change this” is a real question. For a throwaway, it’s overkill.

If you want to see how I lean on these patterns in actual projects rather than toy examples, I keep a few writeups on my work page.

Try this one thing this week

Open the last value object or entity you wrote. Find every private property that has a public getter and nothing else. Replace the pair with public private(set) in a promoted constructor and delete the getter. Run your tests. If they pass, you just deleted code that never needed to exist, and the class reads better for it.

That’s the whole pitch. PHP 8.4 didn’t hand me a new paradigm. It handed me back the afternoons I was spending typing return $this->id;, and after a year of shipping it, that trade still feels great.