{"id":151,"date":"2026-04-25T13:03:17","date_gmt":"2026-04-25T13:03:17","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/php-84-asymmetric-visibility-when-i-actually-use-it\/"},"modified":"2026-04-25T13:03:17","modified_gmt":"2026-04-25T13:03:17","slug":"php-84-asymmetric-visibility-when-i-actually-use-it","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/php-84-asymmetric-visibility-when-i-actually-use-it\/","title":{"rendered":"PHP 8.4 Asymmetric Visibility: When I Actually Use It (And When I Don&#8217;t)"},"content":{"rendered":"<p>I&rsquo;m going to admit something. When I first read the <a href=\"https:\/\/wiki.php.net\/rfc\/asymmetric-visibility-v2\" rel=\"nofollow noopener\" target=\"_blank\">PHP 8.4 asymmetric visibility RFC<\/a> I thought: &ldquo;Cool, another feature I&rsquo;ll read about, nod at, and never use.&rdquo; Three weeks into 8.4 I&rsquo;ve used it maybe twenty times. So if you&rsquo;re the same flavor of skeptic, let me try to change your mind with code, not slogans.<\/p>\n<p>This is a short feature with an outsized effect on how DTOs and value objects look. I&rsquo;ll show the before, the after, the three situations where I reach for it, and the one case where I still prefer a getter method.<\/p>\n<h2 id=\"what-asymmetric-visibility-actually-is\">What asymmetric visibility actually is<\/h2>\n<p>One sentence: a property can now have different visibility for reading and writing.<\/p>\n<pre><code class=\"language-php\">public private(set) string $name;\n<\/code><\/pre>\n<p>Read <code>$obj-&gt;name<\/code> from anywhere. Write <code>$obj-&gt;name = ...<\/code> only from inside the class. That&rsquo;s it. No getter method. No <code>readonly<\/code> trick. No constructor-only escape hatch. You can still mutate the property inside the class; you just can&rsquo;t mutate it from outside.<\/p>\n<p>The supported combinations are <code>public<\/code>, <code>protected<\/code>, and <code>private<\/code> for <code>set<\/code>, and any visibility for the read side. The PHP core team shipped this alongside property hooks in 8.4, and the pair covers maybe 80% of what people historically wrote boilerplate getters and setters for. The official <a href=\"https:\/\/www.php.net\/releases\/8.4\/en.php\" rel=\"nofollow noopener\" target=\"_blank\">PHP 8.4 release notes<\/a> have the full list if you want the bird&rsquo;s-eye view.<\/p>\n<h2 id=\"the-before-code-youve-written-this-exact-class\">The before code (you&rsquo;ve written this exact class)<\/h2>\n<p>Every PHP codebase I&rsquo;ve ever seen has dozens of these. A simple entity with a getter-only public API:<\/p>\n<pre><code class=\"language-php\">\/\/ before: boilerplate getter, mutable internal field\nfinal class Order\n{\n    private string $status = 'pending';\n\n    public function getStatus(): string\n    {\n        return $this-&gt;status;\n    }\n\n    public function markPaid(): void\n    {\n        $this-&gt;status = 'paid';\n    }\n}\n\n$order = new Order();\necho $order-&gt;getStatus();   \/\/ 'pending'\n$order-&gt;markPaid();\necho $order-&gt;getStatus();   \/\/ 'paid'\n<\/code><\/pre>\n<p>Nothing wrong with this. It works. It just has a getter method that does nothing except return a private field, and every consumer has to remember to call <code>-&gt;getStatus()<\/code> instead of <code>-&gt;status<\/code>. Multiply by thirty fields and you&rsquo;re looking at a file full of one-line methods.<\/p>\n<p>The <code>readonly<\/code> keyword from PHP 8.1 got us partway there, but readonly only lets you set the property in the constructor. If the property changes over the object&rsquo;s lifetime (like <code>status<\/code> above, which transitions from pending to paid to refunded), <code>readonly<\/code> is useless.<\/p>\n<h2 id=\"the-after-code-with-asymmetric-visibility\">The after code, with asymmetric visibility<\/h2>\n<p>Same class, rewritten:<\/p>\n<pre><code class=\"language-php\">\/\/ after: asymmetric visibility does the work\nfinal class Order\n{\n    public private(set) string $status = 'pending';\n\n    public function markPaid(): void\n    {\n        $this-&gt;status = 'paid';\n    }\n}\n\n$order = new Order();\necho $order-&gt;status;        \/\/ 'pending', public read\n$order-&gt;markPaid();\necho $order-&gt;status;        \/\/ 'paid'\n$order-&gt;status = 'paid';    \/\/ Fatal error: cannot modify private(set) property\n<\/code><\/pre>\n<p>The getter is gone. Consumers read <code>$order-&gt;status<\/code> directly. External writes are a fatal error. The class still mutates the field internally through <code>markPaid()<\/code>. The public surface is smaller and honest: if you read the class definition, you can see exactly which fields are externally mutable and which aren&rsquo;t.<\/p>\n<p>I grep&rsquo;d a medium-sized Laravel codebase for <code>function get.*(.*).*return \\$this-&gt;<\/code> and found 431 methods that were one-line getters over private fields. Not every one of those is a candidate, but most are. That&rsquo;s a lot of boilerplate to delete.<\/p>\n<h2 id=\"the-three-situations-where-i-reach-for-it\">The three situations where I reach for it<\/h2>\n<h3 id=\"1-dtos-and-value-objects\">1. DTOs and value objects<\/h3>\n<p>Immutable-ish data carriers that get populated once and read many times. Perfect fit. The <code>public private(set)<\/code> pattern replaces a constructor-heavy builder and a pile of getters with a clean field list.<\/p>\n<pre><code class=\"language-php\">final class UserProfile\n{\n    public function __construct(\n        public string $id,\n        public private(set) string $email,\n        public private(set) DateTimeImmutable $createdAt,\n    ) {}\n\n    public function updateEmail(string $new): void {\n        $this-&gt;email = $new;   \/\/ still works from inside\n    }\n}\n<\/code><\/pre>\n<h3 id=\"2-state-machines-with-enforced-transitions\">2. State machines with enforced transitions<\/h3>\n<p>Anything with a <code>status<\/code> or <code>state<\/code> field that should only change through approved methods. Exposing the field as <code>public private(set)<\/code> means external code can inspect the state cheaply without bypassing the transition logic.<\/p>\n<h3 id=\"3-framework-internal-mutable-state\">3. Framework-internal mutable state<\/h3>\n<p>Things like request contexts, query builders, and middleware pipelines that accumulate state as they run. You want internal components to tweak the state freely, and you want downstream consumers to read the final state without accidentally poisoning it. Brent Roose&rsquo;s writeup on <a href=\"https:\/\/stitcher.io\/blog\/new-in-php-84\" rel=\"nofollow noopener\" target=\"_blank\">what&rsquo;s new in PHP 8.4<\/a> has a few more examples that pattern-match onto framework code if you want to see it in context.<\/p>\n<h2 id=\"when-i-still-write-a-getter-method\">When I still write a getter method<\/h2>\n<p>Not every field wants this treatment. I still write an explicit getter when:<\/p>\n<ul>\n<li><strong>The return value is computed.<\/strong> If the &ldquo;field&rdquo; is really the output of a calculation over other fields, a getter method is more honest.<\/li>\n<li><strong>The shape on the wire is different from the shape in memory.<\/strong> <code>getTotalCents()<\/code> vs <code>$this-&gt;total<\/code> (a <code>Money<\/code> object). The method is doing work.<\/li>\n<li><strong>I want to reserve the right to change the internal representation.<\/strong> A method call is an API, a property access is a direct reach into the object. Getters give you a cushion.<\/li>\n<\/ul>\n<p>The heuristic: if the getter body is literally <code>return $this-&gt;x;<\/code>, kill it. If the getter does anything else, keep it.<\/p>\n<h2 id=\"pairing-with-property-hooks-briefly\">Pairing with property hooks (briefly)<\/h2>\n<p>PHP 8.4 also shipped property hooks, which I covered in my <a href=\"https:\/\/abrarqasim.com\/blog\/php-84-property-hooks-changed-how-i-write-classes\/\" rel=\"noopener\">property hooks post<\/a>. The two features compose nicely:<\/p>\n<pre><code class=\"language-php\">final class Article\n{\n    public string $title {\n        set (string $v) =&gt; $this-&gt;title = trim($v);\n    }\n    public private(set) string $slug;\n}\n<\/code><\/pre>\n<p>The <code>title<\/code> field has a hook that trims whitespace on write. The <code>slug<\/code> field is read-public, write-private. Together they cover most of what I used to write full getter-and-setter pairs for. For the broader 8.4 context, my <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-12-new-features-boring-release-done-right\/\" rel=\"noopener\">Laravel 12 release notes piece<\/a> touches on how the framework itself is slowly adopting these patterns.<\/p>\n<h2 id=\"the-footgun-reflection-and-serialization\">The footgun: reflection and serialization<\/h2>\n<p>One gotcha worth flagging. If you have a serializer or ORM that uses reflection to hydrate objects by setting private properties directly (Doctrine does this, older Symfony serializers do this), asymmetric visibility doesn&rsquo;t change the reflection story. Reflection can still write to a <code>private(set)<\/code> property. That&rsquo;s intentional; the feature is a language-level access control, not a runtime lock.<\/p>\n<p>If your mental model is &ldquo;the property is <em>truly<\/em> immutable once set,&rdquo; asymmetric visibility won&rsquo;t deliver that. Use <code>readonly<\/code> for constructor-only fields and a proper event-sourced pattern for true immutability across state transitions. Asymmetric visibility is about keeping your public API honest, not about memory-safety guarantees.<\/p>\n<p>I learned this the slightly annoying way when a test suite was &ldquo;mutating&rdquo; a <code>private(set)<\/code> field through a fixture loader and it all kept working, then I realized the loader was using <code>ReflectionProperty::setValue()<\/code>. Not a bug. Just a thing to know.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>If you&rsquo;re on PHP 8.4 already, pick one file in your codebase with three or more one-line getters over scalar fields. Rewrite them to use <code>public private(set)<\/code> and delete the getter methods. Run the tests. See what breaks. In my experience what breaks is interfaces that declared <code>function getX(): string<\/code>, and the fix is either to drop the interface or keep the method delegating to the property.<\/p>\n<p>If you&rsquo;re still on 8.3, 8.4 is worth the upgrade for asymmetric visibility plus property hooks together. They&rsquo;re small features that compound. A codebase that adopts both loses a surprising amount of ceremonial code, and the files read more like intent and less like bureaucracy.<\/p>\n<p>More of the backend work I take on lives on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio page<\/a>, and half of those projects got a minor readability bump just from the 8.4 property features. It&rsquo;s the kind of incremental improvement that&rsquo;s easy to underestimate until you stop writing boilerplate for a week straight.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>PHP 8.4&#8217;s asymmetric visibility lets you make a property public-read but private-write. Here&#8217;s the before\/after code and the three situations where I reach for it.<\/p>\n","protected":false},"author":2,"featured_media":150,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"PHP 8.4's asymmetric visibility lets you make a property public-read but private-write. Here's the before\/after code and the three situations where I reach for it.","rank_math_focus_keyword":"php 8.4 asymmetric visibility","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,52],"tags":[150,49,149,53,148],"class_list":["post-151","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-php","tag-asymmetric-visibility","tag-backend","tag-oop","tag-php","tag-php-84"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/151","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=151"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/151\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/150"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=151"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=151"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=151"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}