public get, private set without getter boilerplate
For years, developers reached for a private property plus a public getter just to allow read access from the outside, without opening up write access. Asymmetric Visibility ends that detour: a property can be publicly readable and privately or protected writable at the same time, directly in the declaration, without a single line of getter code.
Table of Contents
- 1. The Problem: a public Property with Controlled Write Access
- 2. Syntax: public private(set) and public protected(set)
- 3. Combining with readonly
- 4. Asymmetric Visibility in Constructor Property Promotion
- 5. Inheritance and Visibility Rules for Overridden Properties
- 6. Difference from Property Hooks
- 7. Practical Example: Value Objects and Entities with Controlled State
- 8. Static Analysis and IDE Support
- 9. Migrating from Classic private Properties with Getters
- 10. Summary
- 11. FAQ
1. The Problem: a public Property with Controlled Write Access
Before PHP 8.4, there was no way to declare a property that was readable from the outside while restricting write access at the same time. Visibility was always symmetric: public meant readable and writable by anyone, private meant both only inside the declaring class. If you wanted a property to be publicly readable without allowing arbitrary write access, you had to declare it as private and add a public getter method that did nothing but return the value. This workaround produces pure boilerplate: one line of property declaration turns into a property plus a full method, just to allow reading.
The actual problem goes beyond line count. In large codebases with many entities and value objects, this pattern multiplies: every property that should be readable from the outside gets its own getXxx() method that does nothing but return $this->xxx;. IDEs generate these methods automatically, which hides the problem rather than solving it. Every generated method still needs to be documented, tested and maintained during refactors, even though it contains no logic of its own. Asymmetric Visibility solves this problem at its root by declaring separate visibility for read and write access directly on the property.
Another aspect that is often overlooked: the classic solution with a private property and a getter only protects access from outside the class. Inside the class itself, the property remains freely writable from anywhere in the code, even in places where a change was never intended by design. Asymmetric Visibility with protected(set) adds an even finer grain of control: allowing write access for subclasses while still forbidding it for completely external code, a combination that plain getters could never express.
| Scenario | Classic (private + Getter) | Asymmetric Visibility | Advantage |
|---|---|---|---|
| Publicly readable ID | private $id + getId() |
public private(set) string $id; |
Direct property access, no method call needed |
| Write protection from outside | Convention only, no compiler enforcement | Enforced at compile time | Violation is caught immediately, not during review |
| Constructor initialization | Property, getter and assignment across three spots | Combines directly with constructor promotion | Declaration and access rule in one place |
| Static analysis | PHPStan only sees a convention, not real encapsulation | Visibility violation is a real analysis error | Errors are found before deployment |
| Inheritance | Getter visibility freely overridable | Set-visibility cannot be widened in a child class | Parent class guarantee holds across the hierarchy |
| Readability in code review | One extra method per property required | Visibility visible directly in the declaration | Less code, intent recognizable at a glance |
2. Syntax: public private(set) and public protected(set)
The basic syntax of Asymmetric Visibility adds a second, optional visibility for write access to the classic visibility keyword: public private(set) string $sku; declares a property that is readable from anywhere but can only be written inside the declaring class. Similarly, public protected(set) int $stockLevel; allows read access from anywhere while restricting write access to the declaring class and its subclasses. If no set-visibility is specified, behavior remains symmetric as usual: read and write access follow the same visibility.
A fixed rule determines which combinations are allowed: the set-visibility must always be at least as restrictive as the get-visibility, never less restrictive. This makes public private(set), public protected(set) and protected private(set) valid. The reverse is not allowed, for example a property that is only protected readable from outside but public writable, that would mean more code could write than could ever read, which contradicts the whole idea of encapsulation. With private as the get-visibility, an additional set-visibility makes no sense, since private already represents the most restrictive level, and the parser rejects such a redundant declaration.
It is also important that Asymmetric Visibility is only available for typed properties. Untyped properties, still common in older PHP code, cannot receive a separate set-visibility, one more good reason to consistently add type declarations to existing code. Inside the declaring class, a property with Asymmetric Visibility behaves exactly like a regular property for reads and writes, there is no hidden method and no performance overhead, the restriction is enforced purely by the parser at compile time.
declare(strict_types=1);
final class Product
{
// Public get, private set: readable from anywhere, writable only inside Product
public private(set) string $sku;
// Public get, protected set: readable from anywhere, writable in Product and subclasses
public protected(set) int $stockLevel = 0;
public function __construct(string $sku)
{
$this->sku = $sku;
}
public function reduceStock(int $amount): void
{
// Allowed: write happens inside the declaring class
$this->stockLevel -= $amount;
}
}
$product = new Product('SKU-001');
echo $product->sku; // Allowed: public get
echo $product->stockLevel; // Allowed: public get
$product->sku = 'SKU-002'; // Fatal error: cannot modify private(set) property from global scope
$product->stockLevel = 500; // Fatal error: cannot modify protected(set) property from global scope
3. Combining with readonly
Asymmetric Visibility and readonly solve different problems and can be combined for even tighter control. readonly alone allows exactly one assignment from the declaring scope, any further write attempt, even inside the same class, results in an error. Plain Asymmetric Visibility without readonly allows any number of write accesses, as long as they come from the allowed scope, for example when a method is allowed to reset the same value multiple times over the object's lifecycle. The combination public private(set) readonly string $invoiceNumber; unites both: readable from outside only, writable from inside only once.
The practical difference shows up with properties that legitimately change over the object's lifetime but must never be changed from the outside. An order status that transitions from draft through confirmed to shipped needs repeated internal write access and therefore must not carry readonly, but should carry private(set), so that only methods of its own class can change the state. An invoice number, on the other hand, is assigned exactly once in the constructor and should never change again afterward, here the combination of Asymmetric Visibility and readonly is the right choice.
PHP 8.4 also relaxed the rules for readonly properties themselves: a readonly property can now still be initialized even if it was declared in the parent class but not yet populated there, which in combination with protected(set) enables inheritance scenarios that previously required extra constructor parameters. It remains important that readonly only describes how often a property can be written, while Asymmetric Visibility describes from where it can be written, both axes are configured independently of each other.
declare(strict_types=1);
final class Invoice
{
// Asymmetric visibility restricts WHO can write, readonly restricts HOW OFTEN
public private(set) readonly string $invoiceNumber;
// Without readonly: internal code may reassign multiple times
public private(set) string $status = 'draft';
public function __construct(string $invoiceNumber)
{
$this->invoiceNumber = $invoiceNumber; // Allowed: first and only assignment
}
public function finalize(): void
{
$this->status = 'finalized'; // Allowed: multiple internal writes remain possible
}
public function attemptReassign(): void
{
// $this->invoiceNumber = 'X-99'; // Fatal error: cannot modify readonly property
}
}
4. Asymmetric Visibility in Constructor Property Promotion
Constructor Property Promotion and Asymmetric Visibility complement each other particularly well, because both features aim to reduce boilerplate without sacrificing clarity. Instead of declaring a property, accepting it in the constructor and then assigning it explicitly, you write the full visibility declaration directly on the constructor parameter: public function __construct(public private(set) int $amountInCents) {}. PHP automatically generates a property with exactly this asymmetric visibility and takes care of the assignment, without a single extra line in the method body.
This combination is especially valuable for value objects made up of several fields that are immutable, or at least unchangeable from the outside. A Money class with an amount and a currency can be defined in a few lines so that both fields are publicly readable but writable only inside the class itself, for example to return a new Money object with a correctly validated currency from an add() method. The constructor stays as compact as a plain data container while access control remains fully intact.
One detail that is often overlooked in practice: the same combination rules apply to promoted properties as to regularly declared properties. The set-visibility must not be more open than the get-visibility, and without an explicit type declaration, property promotion combined with Asymmetric Visibility does not work either, since promoted properties in PHP must always be typed. If you mix several promoted parameters with different visibility combinations, for example a publicly writable flag next to several private(set) fields, that choice should be deliberate and consistent within the class design, so the intent stays understandable for other developers.
declare(strict_types=1);
final class Money
{
public function __construct(
public private(set) int $amountInCents,
public private(set) string $currency,
) {
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
// Internal write is allowed because we are inside the declaring class
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
$price = new Money(1999, 'EUR');
echo $price->amountInCents; // Allowed: public get
// $price->amountInCents = 0; // Fatal error: private(set) from outside the class
5. Inheritance and Visibility Rules for Overridden Properties
As soon as a class with Asymmetric Visibility is inherited, rules apply that resemble the classic covariance rules for method signatures. Get-visibility can be widened in a child class as usual, a property declared as protected can be made public readable in a child class. For set-visibility, however, the opposite rule applies: it can never be widened, only kept the same or narrowed further. A property declared as protected(set) in the parent class can at most become private(set) in the child class, but never public writable.
This restriction is not an arbitrary language quirk, but a necessary consequence of the Liskov Substitution Principle. If code works with a reference to the parent class and relies on the fact that a property cannot be written from outside, a subclass must not secretly lift that guarantee just because it sits at the same spot in the inheritance tree. Asymmetric Visibility therefore treats this guarantee as part of the parent class's public contract, exactly like parameter types and return types on methods.
In practice this means: whoever designs a base class with protected(set) properties grants all subclasses controlled write access, but can be sure that no subclass accidentally opens that access up to completely external code. This is especially valuable for domain models with class hierarchies, for example an Employee base class with several specialized roles, where the department field may legitimately be changed internally by each role but should never be overwritten directly by a controller or form handler.
declare(strict_types=1);
class Employee
{
// Set-visibility is protected(set): subclasses may write, outside code may not
public protected(set) string $department;
public function __construct(string $department)
{
$this->department = $department;
}
}
final class Manager extends Employee
{
public function reassign(string $department): void
{
// Allowed: protected(set) is inherited, Manager can write internally
$this->department = $department;
}
// Not allowed in a redeclaration further down the hierarchy:
// public string $department; // Fatal error: cannot widen inherited set-visibility
}
6. Difference from Property Hooks
Asymmetric Visibility is often confused with Property Hooks in practice, because both features let you control read and write access to properties more precisely. The difference is fundamental: Asymmetric Visibility is a pure visibility rule with no logic of its own, it only decides who can read and who can write a property, but never executes any code itself. Property Hooks, in contrast, add executable code on read or write, for example computed values, validation or side effects, which is a topic of its own depth and is deliberately not covered further here.
The relationship with readonly is similar: where readonly categorically forbids any further write access after the first assignment, no matter which scope it comes from, Asymmetric Visibility still allows writes from the declaring or extended scope, just not from outside. So if you need a property that legitimately changes multiple times over its life but never from the outside, you reach for Asymmetric Visibility; if you need a property that must never change again after initialization, you reach for readonly; and if you need computation logic at access time itself, you use Property Hooks.
This clear separation of concerns is one of the biggest advantages of the PHP 8.4 property extensions: instead of a single overloaded mechanism, there are three orthogonal tools that can be freely combined. Asymmetric Visibility only answers the question of who, never how or how often, and it is exactly this restriction that keeps the syntax predictable and easy to reason about.
7. Practical Example: Value Objects and Entities with Controlled State
A typical use case for Asymmetric Visibility is entities whose state should only change through explicitly defined business operations. An Order class with a list of order lines and a status field should be readable from outside at any time, for example to display the current order total, but must never be manipulated directly without checking invariants like a minimum order value or a valid status transition order. With classic getters, every field would need its own method; with Asymmetric Visibility, the declaration public private(set) array $lines = []; is enough, together with dedicated methods like addLine() and complete(), which are the only places in the code with direct write access.
The advantage over purely convention-based encapsulation is especially visible in larger teams: a new developer who accidentally writes $order->lines[] = $line; from outside the class, instead of using the intended addLine() method, immediately gets a fatal error, not a hard-to-trace bug in production when an order ends up with lines that never went through the validation logic. Asymmetric Visibility turns a convention that used to be enforced only in code review into a guarantee enforced by the parser.
Immutable value objects like the Money object shown earlier also rely on this pattern: operations like add() always produce a new instance instead of mutating the existing one, which is enforced without any extra code by combining Asymmetric Visibility with constructor promotion. Together with the Order class, this produces a domain model where every state change goes through a named, meaningful method, while read access to all fields remains unrestricted.
declare(strict_types=1);
final class Order
{
/** @var OrderLine[] */
public private(set) array $lines = [];
public private(set) string $status = 'open';
public function __construct(
public private(set) string $orderNumber,
) {
}
public function addLine(OrderLine $line): void
{
if ($this->status !== 'open') {
throw new LogicException('Cannot modify a closed order');
}
// Allowed: array mutation happens inside the declaring class
$this->lines[] = $line;
}
public function complete(): void
{
if ($this->lines === []) {
throw new LogicException('Cannot complete an order without lines');
}
$this->status = 'completed';
}
public function total(): Money
{
return array_reduce(
$this->lines,
static fn (Money $carry, OrderLine $line): Money => $carry->add($line->subtotal()),
new Money(0, 'EUR'),
);
}
}
8. Static Analysis and IDE Support
PHPStan and Psalm have extended their visibility checks to cover Asymmetric Visibility, treating a write attempt from outside the allowed scope as a genuine analysis error, not just a runtime error. This is especially valuable because a violation of the set-visibility is caught right in the CI pipeline, long before the code is ever executed. Running bin/analyse at level 5 or higher against a module that consistently uses Asymmetric Visibility gives a clear error with file name and line number for every accidental external write attempt, instead of only noticing it during review or in production.
At the reflection level, PHP 8.4 introduces new methods for inspecting Asymmetric Visibility at runtime: ReflectionProperty::isPrivateSet() and ReflectionProperty::isProtectedSet() report the set-visibility separately from the classic get-visibility, which is still queried through isPublic(), isProtected() and isPrivate(). Frameworks and serializers that access object structures via reflection need to be aware of this distinction, otherwise they may wrongly treat a publicly readable but privately writable property as fully private and skip it during serialization.
Modern IDEs like PhpStorm fully recognize the syntax and flag a write attempt outside the allowed scope while you are still typing, not only on the next analysis run. Autocompletion consistently stops suggesting an assignment context for a private(set) property once outside the class, while read access continues to be suggested as usual. This reduces the error rate beyond what static analysis alone provides, because many violations are never written in the first place instead of being fixed afterward.
9. Migrating from Classic private Properties with Getters
Migrating existing classes to Asymmetric Visibility is best started with an inventory: which getters really only contain return $this->property;, without validation, computation or side effects? These are exactly the candidates that can be switched directly to Asymmetric Visibility. Getters with additional logic, such as formatting or a fallback value, are not candidates for Asymmetric Visibility, they belong more in the realm of Property Hooks, which this article deliberately does not cover in depth.
The actual refactor happens in small, verifiable steps: first, the private property is replaced with public private(set) or public protected(set), depending on whether subclasses also need write access. The existing getter is kept for now as a deprecated delegation that simply returns return $this->property;, so existing callers do not need to be adjusted immediately. Only in a second step are call sites gradually switched from $object->getProperty() to direct access via $object->property, while a dedicated PHPStan rule tracks remaining usage of the deprecated getter.
Backward compatibility deserves particular attention for libraries and public APIs: switching from private to public private(set) is uncritical from the perspective of external code that previously could not access the property directly at all, since new direct read access is a pure extension of capability, not a restriction. It becomes more critical when a property was previously fully public and is now reduced to public private(set), because any external code that used to write directly will break, which should be clearly communicated and given a transition period in a major release. Tools like Rector already offer automated rules that recognize suitable getter patterns and convert them to Asymmetric Visibility, significantly reducing manual effort in larger codebases.
10. Summary
Asymmetric Visibility solves a problem PHP developers worked around for decades with private properties and public getters: a property should be publicly readable but only restrictively writable. With public private(set) and public protected(set), this requirement is expressed directly in the property declaration, without a single line of getter code, without runtime overhead, and with full support from static analysis and modern IDEs. Combining it with readonly additionally lets you control not only who can write, but also how often writes are allowed, with precision.
In constructor property promotion, in inheritance hierarchies and in value objects, Asymmetric Visibility shows its full value: leaner classes, clearer contracts between base class and subclass, and a domain model whose state changes exclusively flow through named, meaningful methods. Anyone migrating existing code should first identify pure, logic-free getters, replace them step by step with Asymmetric Visibility, and keep backward compatibility for public APIs in mind while doing so.
Asymmetric Visibility in PHP 8.4 - Key Takeaways
Syntax
public private(set) and public protected(set) separate read from write access. The set-visibility must always be at least as restrictive as the get-visibility.
Pure Visibility Rule
No custom logic like Property Hooks. No absolute write ban like readonly, just controlled write access from the allowed scope.
Inheritance
Get-visibility can be widened in child classes, set-visibility can only be kept the same or narrowed further.
Tooling
PHPStan, Psalm and modern IDEs catch violations before execution. New reflection methods like isPrivateSet() make set-visibility inspectable at runtime.
11. FAQ: Asymmetric Visibility in PHP 8.4
1What is Asymmetric Visibility in PHP 8.4?
2public private(set) vs. public protected(set)?
3Can private get and public set be combined?
4What happens on external writes to private(set)?
5Combining with readonly?
6Does this work with Constructor Property Promotion?
7Can a child class widen set-visibility?
8Difference from Property Hooks?
9Support in PHPStan and Psalm?
10How do I migrate existing getters?
Mironsoft
PHP 8.4 modernization and code reviews for Magento and PHP projects
Ready for Asymmetric Visibility in your PHP code?
We analyze existing classes, identify getter boilerplate, and migrate your value objects and entities to Asymmetric Visibility, readonly and constructor property promotion, backed by full PHPStan verification.
Code Review
Inventory of classic getter patterns and PHPStan analysis for visibility violations
Migration
Step-by-step transition to Asymmetric Visibility with backward compatibility for public APIs
PHP 8.4 Upgrade
Complete modernization of your codebase including constructor property promotion and value objects