Magic Annotations (@property & @method)
Dynamic properties and magic methods are widely used across modern PHP frameworks (such as Laravel Eloquent models, DTOs, and dynamic service repositories). TypePHP provides transparent runtime enforcement for class-level @property, @property-read, @property-write, and @method annotations.
Class-Level Magic Properties (@property, @property-read, @property-write)
When a property does not physically exist on a class, PHP routes property writes through __set() and property reads through __get(). TypePHP intercepts these dynamic accesses and validates values against class-level @property, @property-read, and @property-write annotations declared on the class, parent classes, interfaces, or traits.
<?php
declare(strict_types=1);
namespace App\DTOs;
/**
* @property-read string $status
* @property-write string $name
* @property positive-int $score
*/
class UserDTO
{
private array $data = [];
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
public function __get(string $name): mixed
{
return $this->data[$name] ?? null;
}
}1. Dynamic Property Writes (__set) [Default: Enabled]
By default, TypePHP intercepts assignments to dynamic properties and validates the incoming value against @property and @property-write annotations before the write occurs:
$user = new UserDTO();
// Valid dynamic property write
$user->score = 100;
$user->name = 'Alice';
// Invalid dynamic property write ($score = -50 violates positive-int)
$user->score = -50;
// Throws: TypeError: Property UserDTO::$score must be of type positive-int, negative int (-50) given
// Invalid dynamic property write ($name = '' violates non-empty-string)
$user->name = '';
// Throws: TypeError: Property UserDTO::$name must be of type non-empty-string, empty string ('') given2. Dynamic Property Reads (__get) [Opt-in via magic_properties.read => true]
TypePHP can also validate values returned from dynamic property reads ($value = $user->status) through __get() against @property and @property-read annotations:
$user = new UserDTO();
// 1. Valid Read: Returns a string satisfying @property-read string
$user->name = 'Alice';
$user->data['status'] = 'active';
echo $user->status; // Output: 'active'
// 2. Invalid Read: Property was unpopulated or returned null
unset($user->data['status']);
$currentStatus = $user->status;
// Throws: TypeError: Property UserDTO::$status must be of type string, null returnedWhy is read checking opt-in (
read: falseby default)?
In frameworks using Active Record (such as Laravel Eloquent), newly instantiated models ($post = new Post()) initially returnnullfor unhydrated attributes before being saved or populated from the database.Keeping
read: falseby default ensures existing framework models run without unexpected crashes on empty attributes. You can enable'read' => truefor strict DTOs, value objects, and clean architecture layers where reading an unpopulated property should be strictly prevented.
Class-Level Magic Methods (@method)
When a method is called dynamically via __call() or __callStatic(), TypePHP intercepts the invocation and validates both incoming arguments and returned values against class-level @method annotations:
<?php
declare(strict_types=1);
namespace App\Services;
/**
* @phpstan-type StatusUnion 'active'|'pending'
*
* @method positive-int processOrder(positive-int $id, non-empty-string $sku)
* @method static list<int> fetchBatch(int ...$ids)
* @method bool updateStatus(StatusUnion $status)
*/
class OrderService
{
public function __call(string $name, array $arguments): mixed
{
return $arguments[0] ?? null;
}
public static function __callStatic(string $name, array $arguments): mixed
{
return $arguments;
}
}
$service = new OrderService();
// Valid Dynamic Call
$service->processOrder(42, 'SKU-99');
// Invalid Argument ($id = -5 violates positive-int)
$service->processOrder(-5, 'SKU-99');
// Throws: TypeError: OrderService::processOrder(): Argument $id must be of type positive-int
// Invalid Static Variadic Argument ('invalid' violates int)
OrderService::fetchBatch(1, 2, 'invalid');
// Throws: TypeError: OrderService::fetchBatch(): Argument $ids[2] must be of type intDocBlock Inheritance for Magic Annotations
Child classes automatically inherit magic property and method annotations declared across their entire object hierarchy:
- Parent Classes: A child class extending a parent inherits all parent
@propertyand@methodannotations. - Interfaces: A class implementing an interface inherits magic annotations declared on the interface.
- Traits: A class using a trait inherits all magic annotations declared on the trait.
- Overriding: If a child class redeclares an
@propertyor@methodannotation, the child's annotation takes precedence.
Best Practice: Quoted Literals in @method Signatures
phpdoc-parser's grammar for @method parameter signatures can encounter ambiguity when parsing unparenthesized single quotes directly inside parameter types (such as @method bool setStatus('active'|'pending' $status)). When phpdoc-parser encounters this grammar ambiguity, it drops that specific @method tag.
Recommended Best Practice: Define complex union string literals or array shapes using a local @phpstan-type alias, and reference the alias in your @method annotation:
/**
* Recommended: Clean & Grammar-Safe via @phpstan-type
*
* @phpstan-type StatusUnion 'active'|'pending'
*
* @method bool setStatus(StatusUnion $status)
*/
class OrderService
{
public function __call(string $name, array $arguments) { ... }
}Configuration Toggles
Magic property and magic method validations are customizable in typephp.php:
// typephp.php
return [
/*
|--------------------------------------------------------------------------
| Magic Annotations (@property & @method)
|--------------------------------------------------------------------------
| Enforces class-level annotations for dynamic properties and magic methods
| routed through __get, __set, __call, and __callStatic.
|
| 'magic_properties' supports granular options:
| - 'write': (Default: true) Validates dynamic assignments via __set()
| - 'read' : (Default: false) Validates dynamic property reads via __get()
| Enable this for strict DTOs and clean architecture models.
|
| Alternatively, set 'magic_properties' => false to disable all checks.
*/
'magic_properties' => [
'write' => true,
'read' => false,
],
'magic_methods' => true, // Set to false to disable dynamic @method checks
];Boolean Shorthand: Passing
'magic_properties' => falsedisables both writes and reads, while'magic_properties' => trueenables writes with reads defaulting tofalsefor complete backward compatibility.