Skip to main content

Accessor Automation with PHP Attributes

1. Abstract

This article introduces getter and setter automation in PHP based on Attributes, Traits and Constructor Property Promotion for PHP >= 8.2 and shows how boilerplate code can be reduced for classes that require property encapsulation while simultaneously considering custom logic with setters.

In this regard, getters and setters are not physically existing with code. Instead, calls to such methods will be intercepted and processed by PHP.

If access modifiers are required with such automated getters and setters, an implementing system has to check whether a client belongs to the scope allowed to call the access-modified (sic) method. While access modifiers are usually handled by the programming language's engine, the article demonstrates the various challenges an implementer faces when moving such logic from low-level (engine) to high level (user defined) code.

The impacts of getter/ setter automation on performance is illustrated with benchmarks comparing various implementation details.

1.1 Notation

Assume there is a typed property T $employee. We may construct setters and getters such that setEmployee(T $value) has write-access and getEmployee(): T has read-access to $employee, with the following implications:

1.1.1 In this article, the generalization of such getters, setters and familiar methods use the abbreviations set*(), get*(), is*()and apply*() where * is a list of characters conforming to the regular expression \$(A-Z)[a-zA-Z0-9_]*.

1.1.1.1 For semantic purposes, we introduce is*() as a representative of a method with read-access to a boolean property.

1.1.1.2 apply*() will be used as a proxy-method for set*().

1.1.2 This article mentions "physical existing" and "virtual" methods. In the context of the topic of this article, a "physical existingn" method refers to a method that is available with the source code. A "virtual" method refers to a method that is resolved by PHP's magic method __call. (In this regard virtual accessors and virtual functions share the similarity that the target function is not known during compile time respectively to its runtime engine.)

1.1.3 Iff means If, and only if.

2. Introduction

Property Encapsulation in object-oriented programming is often connoted with implementing getters and setters for data, and falsely so with data hiding: Reducing the visibility of class-properties using access-modifiers like private and protected and later exposing the same with appropriate get- and set-methods is by no means an act of hiding data - it simply is encapsulation in its purest form and, even if rightly so applied - often such classes benefit in no way from providing such methods. More so, if the data of a class changes and the interface is already used in the system (eventually leaking across module boundaries), it will be hard to change the implementation without adding new code and leaving the old one in the class for backward compatibility, leading to code rot: Instead of simply refactoring classes to providing meaningful interfaces supporting abstractions and information hiding, classes tend do either grow or introduce breaking changes.

A complete set of getters and setters aligning with available properties may caress the ego of a developer or a project's coding standards, but bloats the source code and reduces its readability. One may doubt that the conceptual context of an Employee is supported with the following implementation:

Employee.php
class Emplyoee
{

private string $empId;

private string $name;

public function __construct(string $empId, string $name)
{
$this->setEmpId($empId);
$this->setName($name);
}

public function setEmpId(string $empId): void
{
$this->empId = $empId;
}

public function getEmpId(string $empId): string
{
return $this->empId;
}

public function setName(string $name): void
{
$this->name = $name;
}

public function getName(string $name): string
{
return $this->name;
}

// ...
}

The Employee-code has getters and setters exposing the existence of properties when following the notation of get*()/ set*() / is*(), helping data mappers and serializers when reconstituting or creating owning objects.

However, such methods don't promote the means of a model of a particular domain and not seldom are programmers tempted to implement logic or behavior in places preceding such an API, because of fearing that functionality breaks when setters unexpectedly implement logic for either updating (associated) data or using constraints on the submitted data; this can lead to anemic domain models.

$employee = new Employee();

$employee->setSalaryClass(Salary::EL13);
$employee->setRole(CompanyRole::ITResearch);

// instead of
// $employee->promoteTo(CompanyRole::ITResearch);

Often enough do we witness projects where business logic finds its way into Facades, albeit such business logic would have its rightful place in the Entity itself (in this case, the Employee-class).

But does information hiding encourage a more verbose, intention revealing API? It is long known that visible implementation details will be re-used across module boundaries without further questioning their applicability [📖MOD]: There is a chance that a programmer would rather use setSalaryClass() and setRole() for implementing his/her own routine, instead of applying promoteTo(), even if it would fit the use case.

The following approach has the advantage that, if such a form of information hiding is used, the API still conforms to the requirements of data mappers and other programs that use getter and setter with property discovery. Explicitly declared guards can be given responsibility for putting constraints on data changes. The accessors will not be visible to a developer reading the source code file.

3. PHP Attributes

Attributes were introduced with PHP8 and can be used to provide metadata information with class implementations. While this was already possible with doc-block comments embedding annotations in previous versions of PHP, Attributes allow to do the same in a more formal context and with native support by the language itself. Similar to Java, where Annotations have their own type and can be embedded directly into code, PHP Attributes do not require doc-blocks, making parsers introducing expensive runtime behavior obsolete. PHP's Reflection API allows for reading out Attributes preceding classes, methods or properties.

For this article, we define two classes that serve as tagging Attributes: They will be used for identifying properties that require getters and setters to be available as part of their host's interface.

Setter.php
#[Attribute]
class Setter
{
}
Getter.php
#[Attribute]
class Getter
{
}

4. The AccessorTrait

Any client interested in updating or querying data of an object whose properties are attributed with #[Setter] and #[Getter] should be able to use particular get- and set-methods. For this purpose, we will use a Trait: This allows code re-usabilty without using inheritance or object composition.

In this Trait, we need to implement PHP's __call(), allowing us to capture any method call that was not treated by an actual, physical implementation of this method.

info

The following version of AccessorTrait will only showcase the basic implementation. For a complete version of this code, refer to quant/core.

AccessorTrait.php
trait AccessorTrait
{
public function __call($method, $args): mixed
{
if (($isSetter = str_starts_with($method, "set")) ||
str_starts_with($method, "get")) {

$property = lcfirst(substr($method, 3));

if ($isSetter) {
if ($this->hasSetterAttribute($property)) {
$this->applyFromSetter($property, $args[0]);
return $this;
}
} else {
if ($this->hasGetterAttribute($property)) {
return $this->$property;
}
}
}

throw new BadMethodCallException("$method not found.");
}
}

__call() will be available to the host using the Trait: Whenever a method is called that is not available on the target object, PHP will take care of passing the requested method-name along with any submitted arguments to this method. In our case, __call inspects the requested method-name for a get-/set-prefix, and, if available, queries the host for properties attributed with #[Setter] or #[Getter]. Such properties can also be declared by using Constructor Property Promotion with one or more attributes preceding them.

4.1 Hosting the AccessorTrait

As a requirement, the $empId of the previously introduced Employee-class should be immutable, but readable with getEmpId(). Using the #[Getter]-Attribute along with Constructor Property Promotion, our implementation looks like this:

Employee.php
class Employee {

use AccessorTrait;

#[Getter]
private string $name = "John Smith";

public function __construct(
#[Getter]
private string $empId
) {
}
}

Creating an instance and immediately accessing the $empId declared as private is now possible since Employee uses the AccessorTrait:

$employee = new Employee("87i-dsd-89z-978");
$employe->getEmpId(); // returns "87i-dsd-89z-978"

Also, read-access to $name is given by calling getName().

Conversely, a call to setEmpId() throws a BadMethodCallException: It's neither defined in Employee, nor is it considered with AccessorTrait::__call() since the #[Setter]-attribute for its property is missing.

When we want to make the $name property of Employee mutable, we only need to add the #[Setter]-attribute to its property; this provides the availability of setName():

Employee.php
class Employee {

use AccessorTrait;

#[Setter] #[Getter]
private string $name = "John Smith";

public function __construct(
#[Getter]
private string $empId
) {
}
}

Setting $name is now possible by calling setName().

$employee = new Employee("87i-dsd-89z-978");
$employe->setName("Thomas Anderson");

Instead of configuring individual properties of a target class, it is also possible to use the attributes on class level: The accessors configured with their attribute-representation will then automatically be available for all properties of the class. An example will be given below.

4.2 Conditional Updates of Properties with Guards

Since physical code for setters is not available, developers still need to make sure that data passed to virtual setters does not violate specific criteria that would otherwise leave target objects in an invalid state. Thus, the AccessorTrait proxies setters with apply*()-methods (apply*() conforming to the naming conventions of get and set). These methods are looked up in the classes using the AccessorTrait (or extending a hosting class) and - if existing, are called with the value that is provided as the new value for the target property. apply*() can then validate the submitted argument and return a value that is actually used with set*().

Employee.php
class Employee {

use AccessorTrait;

#[Setter] #[Getter]
private string $name = "John Smith";

/**
* @throws ValueError
*/
protected function applyName(string $value): string
{
if ($value === "") {
throw new ValueError("Empty name is not allowed");
}

if ($value === "John Smith") {
return $this->name;
}

return $value;
}
}

The AccessorTrait implements a method similar to the following applyFromSetter()-method, making sure an apply-method gets correctly mapped to a property:

AccessorTrait.php

public function __call($method, $args): mixed
{
// ...

if ($isSetter) {
if ($this->hasSetterAttribute($property)) {
$this->applyFromSetter($property, $args[0]);
return $this;
}
}

// ...
}

private function applyFromSetter(
string $property,
mixed $value
): void {
$applier = "apply" . ucfirst($property);

$newValue = $value;
if (method_exists($this, $applier)) {
$newValue = $this->{$applier}($value);
}
$this->$property = $newValue;
}

Iff an apply*()-method for the targeted property is available, such a guard is applied to the $value-argument, then the AccessorTrait uses its return value as the new value for the targeted property. If no guard is available, the original value will be used as the new value.

note

The new value might by the same as the old value, making it difficult to determine whether the returning value is equal to the value that was originally submitted to set*(). If a distinction is required, an exception could be thrown or a user-defined bottom-value can be used, either of them containing information about the original, invalid value.

4.3 Guarding constructor arguments

To utilize the various apply-methods that might be available with the implementation, a constructor can invoke the applyProperties-method available with the AccessorTrait: This will immediately apply any method guarding a property to the constructor argument, then assigning the computed value to it:

 public function __construct(
private string $a,
#[Setter]
private string $b
) {
$this->applyProperties([1 => $b]);
}

applyProperties expects a numeric array and will identify any property positionally. In the example above, the guard for the class-property $b will be invoked with the value submitted with the constructor argument $b.

4.4 Modifying access to getters and setters

To modify the visibility of any accessor available through the #[Getter] / #[Setter] annotation, access configuration can be achieved by applying arguments to the attributes: PHP provides means to pass additional information to attributes as constructor arguments when newInstance() is invoked on the attribute's reflection representative.

note

PHP allows for omitting a constructor in classes representing attributes. This does not prevent the Reflection API from reading such arguments out.

Using tagging Arguments with Attributes
#[Attribute(TaggingClass::TagName, AnotherTaggingClass::AnotherTag)]

Using arguments as semantics, a set of user-defined modifiers can be used to further describe the access level of a virtual getter or setter.

Consider the following implementation, where an enum Modifier exists that provides the values PUBLIC, PRIVATE, PROTECTED. Describing a class that has only private setters and public getters can then be achieved by

#[Getter(Modifier::PUBLIC)]
#[Setter(Modifier::PRIVATE)]
class A
{
use AccessorTrait;

private string $value = "";
}

This will provide public access to getValue() of instances of A, but only private access to the corresponding setter of $value. Thus, changing the values is reserved to A itself. Changing #[Setter(Modifier::PRIVATE)] to #[Setter(Modifier::PROTECTED)] or #[Setter(Modifier::PUBLIC)] gives then wider access to editing $value.

We can easily achieve attribute overriding with additional property leveled attributes: If one wishes to provide protected instead of private access to the setter of $value, an additional attribute can be provided:

#[Getter(Modifier::PUBLIC)]
#[Setter(Modifier::PRIVATE)]
class A
{
use AccessorTrait;

#[Setter(Modifier::PROTECTED)]
private string $value = "";
}
info

Since the AccessorTrait implements any conceivable logic and behavior, additional user-defined modifiers like Modifier::PACKAGE could be used for access based on namespace equality.

4.5 Inheritance and Scoping

The implementation allows for using the AccessorTrait in a class, and then all subclasses of this class can use #[Getter] / #[Setter] attributes for accessor automation.

Since Traits are basically code templates enabling horizontal code composition without affecting inheritance, the behavior with hosting classes is just like as if the code of the Trait was actually physical part of the hosting class. This proves challenging when

  1. the the scope of the owner of the setter / getter needs to be resolved
  2. when modifiers such as PROTECTED and PRIVATE are used and access of interested callers has to be resolved
Figure 1 The Trait hosts __call and has to take care of finding attributed properties in subclasses. Scoping is also its responsibility.

Gaining information about the calling scope of magic methods has already been discussed and resulted in an RFC scheduled for 8.3.

Since modifiers have to be treated in accordance to the language level behavior of PHP, using debug_backtrace() for accessing runtime information on the call stack exposes required information about the callers and the callees.

 $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
if ($accessLevel === Modifier::PRIVATE && $bt[2]["class"] !== $declaringClass) {
return false;
}

This does not severely impact performance, but the need for accessing a rather expensive function belonging to the debugging domain shows that PHP can benefit from additional runtime information when magic methods are involved. Further possible optimizations facilitating caching and production builds that make runtime evaluation unnecessary are discussed later in this article.

4.6 Static Code Analysis

The implementation provides extensions for PHPStan so that white-box tests on static code properly resolves calls to virtual getters and setters.

To make this work,several classes have to be implemented. The most notable are:

  • PHPStan\Reflection\MethodReflection
    provides information about methods-calls intercepted by __call. Of interest are the function variants returned for set- and get-contexts which provide further information about valid parameter configurations and the return value and its type for either setter or getter: For a setter, this must be the type of the property-declaring class itself; for a getter, the type of the configured property is required.

  • PHPStan\Reflection\MethodsClassReflectionExtension
    A predefined interface for implementations facilitating __call, MethodsClassReflectionExtension greatly reduces the required effort (e.g. custom rules) for implementing constraints on magic method calls. For the AccessorTrait, the logic required for the interface's hasMethod()/ getMethod() is similar to that found within the AccessorTrait itself, excluding the code required for determining the calling scope, which is statically resolved by PHPStan's engine.

  • PHPStan\Rules\Properties\ReadWritePropertiesExtension
    This extension interface is used to describe always-read or always-written properties to prevent never read / unused warnings. The extension for quant checks whether the attribute's property is used with a class that uses the AccessorTrait and optimistically returns true for the isAlwaysRead()-, isAlwaysWritten()- and isInitialized()- checks.

5. Performance considerations

The implementation details described in this article require logic and information to be evaluated at runtime, since getters / setters are only virtually existing, not physically: This affects performance to a certain degree which is examined below.

In tests, the following functionality of the AccessorTrait proved to have an impact on runtime performance:

  1. Using the Reflection API to query properties and classes for properties.
  2. Deciding whether an callee's accessor is accessible based on the modifier-configuration of the attribute.
  3. Scoping function calls from the class that hosts the trait to the classes that declare the property. E.g., if the property is declared private, the owning class must be determined and used as the scope when setting the property.

5.1 Benchmark Test Cases

To get an idea of how the runtime of the AccessorTrait compares to related implementations, the following Test Cases were measured using phpbench, an open source benchmark tool for PHP:

  1. Calls to a setter-method physically existing on a class.
  2. Calls to a method intercepted by __call.
  3. Using Doctrine\Common\Annotations::AnnotationReader to parse Docblocks and read annotations, then intercept the method call by __call.
  4. Using AccessorTrait to get the Attribute of a property, then map the name of the property to a method-call intercepted by __call.
  5. Using instances of a class hierarchy with multiple calls to physical existing getters and setters.
  6. The same as 5., but getters and setters are only virtually existing; the AccessorTrait is part of the root-class.

For 6., it is worth mentioning that the classes for the benchmark represent more complex use cases by using inheritance, access modifiers and the AccessorTrait hosted only on the root class, resulting in more operations when properties are looked up.

5.1.1 Benchmark Settings

For each benchmark, a Revolution of 1000 is used that gets iterated 5 times. Using a retry-threshold of 2 narrows down the deviation for which samples are treated valid.

Warm Ups are skipped to make sure at least the first sample has the benchmarked code processed with the opcode-cache.

$ vendor/bin/phpbench run Tests/Benchmarks --report=aggregate --retry-threshold=2
Test Environment
SystemValue
OSMicrosoft Windows 11 Pro, V10.0.22621
HardwareAMD Ryzen 9 5900X (amd64), 64GB RAM
Docker providerdocker 20.10.21
Runtimephp PHP8.2.3, nginx-fpm, xdebug ❌, opcache ✔
DDEV versionv1.21.6

5.2 Results

benchmarktimerstdev
1. setA(string $s)4.956μs±1.04%
2. __call: _$this->{$method} = $args[0];5.114μs±1.09%
3. Doctrine\Common\Annotations::AnnotationReader -> __call()1.689ms±1.25%
4. AccessorTrait::getBPublic()18.566μs±1.54%
5. implemented getters / setters16.397μs±1.15%
6. attributed getters / setters60.786μs±1.04%

5.3 Observations

Not surprisingly, the native implementation of a setter (1.) is the fastest with 4.956μs, with the magic method (2.) only slightly slower.

The AnnotationReader is the slowest benchmark with 1.689ms, obviously due to the fact that text parsers are involved, conversely to (6.), where attribute parsing is natively implemented: The benchmark for the complex test case using the AccessorTraits requires 60.786μs to finish.

This is roughly 3.75 times slower than the benchmark for the code implementing physical methods.

5.3.1 Memory Consumption

It should be obvious that, beginning with a given threshold of nN>1n \in \N_{>1}, nn classes using physical getters and setters use up more memory than nn classes that discard physical code and use a Trait instead. The article is guilty of not providing a value for nn where memory consumption would start to become noticeable: In the given test cases, memory consumption was negligible and is therefor not listed.

6. Conclusion

Reducing the physical visibility of getters and setters can have the effect that developers take more advantage of words and terms conceptually related to an entity when defining methods, instead of placing such methods in facades operating on setters and getters of such entities. When reducing the relevance of accessors, developers may be encouraged to work more closely with the inherent responsibility of such an entity, instead of simply interfacing its properties. The code becomes more intention revealing, more readable, and the entity's purpose is communicated with its method names.

With the benchmarks executed for the various testcases, it shows that physical existing code has runtime benefits, but impacts memory consumption, conversely to the implementation that only uses magic methods. Although the memory consumption for the given benchmarks are negligible, physical implementations greatly benefit from the opcode cache, whereas the logic evaluated with __call and the virtual getters and setters cannot be cached in the same way the physical existing getters and setters are. In a system whose codebase requires getters and setters, but where those accessors are not frequently used, the system will benefit from lower memory consumption.

With regards to particular caching mechanisms, it should be easy to provide builds of the code that uses such AccessorTraits, resulting in classes that physically provide getters and setters, increasing runtime execution. However, if custom modifiers like the above mentioned Modifier::PACKAGE are used, any implementing builder would have the responsibility to properly generate code based on the given information, to prevent accidentally exposure of data.

An attempt to provide native support for accessor automation is currently being made with PHP RFC: Property Hooks targeted for PHP 8.3. The RFC is derived from Nikita Popov's work on PHP RFC: Property Accessors.


Resources