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:
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.
#[Attribute]
class Setter
{
}
#[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.
The following version of AccessorTrait
will only showcase the basic implementation. For a complete
version of this code, refer to quant/core.
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:
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()
:
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*()
.
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:
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.
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.
PHP allows for omitting a constructor in classes representing attributes. This does not prevent the Reflection API from reading such arguments out.
#[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 = "";
}
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
- the the scope of the owner of the
setter
/getter
needs to be resolved - when modifiers such as
PROTECTED
andPRIVATE
are used and access of interested callers has to be resolved
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 forset
- andget
-contexts which provide further information about valid parameter configurations and the return value and its type for eithersetter
orgetter
: For asetter
, this must be the type of the property-declaring class itself; for agetter
, 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 theAccessorTrait
, the logic required for the interface'shasMethod()
/getMethod()
is similar to that found within theAccessorTrait
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 preventnever read
/unused
warnings. The extension for quant checks whether the attribute's property is used with a class that uses theAccessorTrait
and optimistically returnstrue
for theisAlwaysRead()
-,isAlwaysWritten()
- andisInitialized()
- 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:
- Using the Reflection API to query properties and classes for properties.
- Deciding whether an callee's accessor is accessible based on the modifier-configuration of the attribute.
- 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:
- Calls to a
setter
-method physically existing on a class. - Calls to a method intercepted by
__call
. - Using
Doctrine\Common\Annotations::AnnotationReader
to parse Docblocks and read annotations, then intercept the method call by__call
. - Using
AccessorTrait
to get the Attribute of a property, then map the name of the property to a method-call intercepted by__call
. - Using instances of a class hierarchy with multiple calls to physical existing
getters
andsetters
. - The same as 5., but
getters
andsetters
are only virtually existing; theAccessorTrait
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
System | Value |
---|---|
OS | Microsoft Windows 11 Pro, V10.0.22621 |
Hardware | AMD Ryzen 9 5900X (amd64), 64GB RAM |
Docker provider | docker 20.10.21 |
Runtime | php PHP8.2.3, nginx-fpm, xdebug ❌, opcache ✔ |
DDEV version | v1.21.6 |
5.2 Results
benchmark | time | rstdev |
---|---|---|
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 / setters | 16.397μs | ±1.15% |
6. attributed getters / setters | 60.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 , classes using physical getters and setters use up more memory than classes
that discard physical code and use a Trait
instead. The article is guilty of not providing a value for 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 AccessorTrait
s,
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.