Search by

antevemus / adeep-property-access

helitonjr

Zero-dependency PHP 8.2+ property reader for arrays and objects: dot-notation paths, array/ArrayAccess keys, getX/x/isX/hasX methods, public and magic properties, and private fields only when the class author opts in with #[Specifiable]. The shared accessor of antevemus/aspecification and antevemus/

v1.0.0 2026-10-12 01:08 UTC

This package is auto-updated.

Last update: 2026-10-12 01:35:09 UTC


README

🇺🇸 English  |  🇧🇷 Português (Brasil)

CI PHP Version Latest Version License: MIT Dependencies

Zero-dependency PHP 8.2+ property reader for arrays and objects: dot-notation paths, array/ArrayAccess keys, getX()/x()/isX()/hasX() methods, public and magic properties, and private fields only when the class author opts in with #[Specifiable]. The shared accessor of antevemus/aspecification and antevemus/alinq-collection.

Installation

composer require antevemus/adeep-property-access

One class, Antevemus\ADeepPropertyAccess\PropertyAccess, with three static methods:

Method Returns
getValue(mixed $target, string $path): mixed the value of the property or path, or null when it cannot be resolved
hasProperty(mixed $target, string $path): bool whether every segment of the path exists
accessor(string $path): Closure a fn(mixed $target): mixed that reads the path, for array_map(), usort() and LINQ selectors

Quick start

use Antevemus\ADeepPropertyAccess\PropertyAccess;

$order = [
    'id' => 42,
    'customer' => ['name' => 'Ana', 'address' => ['city' => 'Lisbon']],
    'coupon' => null,
];

PropertyAccess::getValue($order, 'customer.address.city'); // => 'Lisbon'
PropertyAccess::getValue($order, 'customer.phone'); // => null
PropertyAccess::hasProperty($order, 'coupon'); // => true
PropertyAccess::hasProperty($order, 'customer.phone'); // => false

Resolution order

Each segment of a path is resolved on the current value; the first step that matches wins:

  1. Array key, or ArrayAccess offset ($target['name'] ?? null).
  2. Public method getName(), name(), isName(), hasName(), in this order, that takes no required parameter. A method with a required parameter is not a getter and is skipped, as a private one is.
  3. isset($target->name): a public initialized property, a dynamic property (stdClass included), or __get() guarded by __isset().
  4. Declared public property, static or instance, read by reflection (an uninitialized typed property yields null).
  5. Private or protected property marked #[Specifiable], on the property or on the class that declares it, declared by the class or by an ancestor (read by reflection).
  6. null.

A path with a dot is always a path: 'a.b' reads $target['a']['b'], never a literal key 'a.b'. A path that crosses null or a scalar yields null; getValue() never throws for a missing segment.

hasProperty() follows the same steps. An array key holding null exists (array_key_exists()); an ArrayAccess offset exists when offsetExists() says so (a key of an ArrayObject or ArrayIterator holding null exists, as in an array); a declared public property exists even when it is null or uninitialized; a dynamic property exists even when it holds null; __get() without __isset() does not make a property exist.

Objects

use Antevemus\ADeepPropertyAccess\PropertyAccess;

final class Address
{
    public function __construct(public string $city) {}
}

final class Customer
{
    public function __construct(private string $name, private Address $address, private bool $active) {}

    public function getName(): string { return $this->name; }
    public function address(): Address { return $this->address; }
    public function isActive(): bool { return $this->active; }
}

$customer = new Customer('Ana', new Address('Lisbon'), true);

PropertyAccess::getValue($customer, 'name'); // => 'Ana'
PropertyAccess::getValue($customer, 'address.city'); // => 'Lisbon'
PropertyAccess::getValue($customer, 'active'); // => true
PropertyAccess::hasProperty($customer, 'email'); // => false

Magic properties, stdClass and ArrayAccess work the same way:

use Antevemus\ADeepPropertyAccess\PropertyAccess;

final class Settings
{
    private array $values = ['theme' => 'dark'];

    public function __get(string $name): mixed { return $this->values[$name] ?? null; }
    public function __isset(string $name): bool { return isset($this->values[$name]); }
}

PropertyAccess::getValue(new Settings(), 'theme'); // => 'dark'
PropertyAccess::hasProperty(new Settings(), 'language'); // => false
PropertyAccess::getValue((object)['a' => ['b' => 1]], 'a.b'); // => 1
PropertyAccess::getValue(new ArrayObject(['sku' => 'X-1']), 'sku'); // => 'X-1'
PropertyAccess::getValue(['a.b' => 'literal', 'a' => ['b' => 'path']], 'a.b'); // => 'path'

Private fields: the #[Specifiable] opt-in

A private or protected property is read by name only when the author of the class marked it with #[Specifiable]:

  • on a property, that private or protected property becomes readable by name;
  • on a class, every property declared by that class becomes readable by name. The mark does not reach the properties of a parent class nor of a subclass: each class opts in for the properties it declares. A marked property declared by an ancestor is reached from an instance of a subclass.
use Antevemus\ADeepPropertyAccess\Attributes\Specifiable;
use Antevemus\ADeepPropertyAccess\PropertyAccess;

final class Account
{
    #[Specifiable]
    private string $status = 'active';

    private string $passwordHash = 'hash-value';
}

#[Specifiable]
final class Invoice
{
    private int $number = 1001;
    protected float $total = 99.9;
}

PropertyAccess::getValue(new Account(), 'status'); // => 'active'
PropertyAccess::getValue(new Account(), 'passwordHash'); // => null
PropertyAccess::hasProperty(new Account(), 'passwordHash'); // => false
PropertyAccess::getValue(new Invoice(), 'total'); // => 99.9

Why an opt-in. The accessor receives a property name as text, and that name often comes from a request: a filter or an ordering built by a screen, a query string turned into a specification. It cannot tell a name written by the author of the class from a name typed by a user. Without the opt-in, a range or a prefix filter over a private field (passwordHash between 'a' and 'b') would work as a read oracle: which candidates pass the filter reveals the private value, character by character. An unmarked non-public property therefore behaves as a property that does not exist.

#[Antevemus\ASpecification\Attributes\Specifiable] and #[Antevemus\ALinq\Attributes\Specifiable] count as the same marker. They are recognized by name (attributes are never instantiated), so this package depends on neither library and code already marked for them keeps working.

What is never called

For the same reason, the accessor only calls public getX()/x()/isX()/hasX() methods without required parameters. It never calls:

  • a private or protected method, nor __call() / __callStatic() (a private getter of a class with __call() is not reached through __call());
  • a method whose name starts with __ (PHP reserves those names for magic methods: reading the "property" __construct does not run the constructor again);
  • a method with a required parameter (it is skipped, and the next step answers).
use Antevemus\ADeepPropertyAccess\PropertyAccess;

final class Report
{
    public string $title = 'Monthly report';

    public function getTitle(string $locale): string { return "title in $locale"; }

    private function getSecret(): string { return 'secret'; }

    public function __call(string $method, array $arguments): string { return "called $method"; }
}

PropertyAccess::getValue(new Report(), 'title'); // => 'Monthly report'
PropertyAccess::getValue(new Report(), 'secret'); // => null
PropertyAccess::hasProperty(new Report(), 'secret'); // => false

Pipelines: accessor()

use Antevemus\ADeepPropertyAccess\PropertyAccess;

$people = [
    ['name' => 'Bruno', 'address' => ['city' => 'Porto']],
    (object)['name' => 'Ana', 'address' => (object)['city' => 'Lisbon']],
];

$city = PropertyAccess::accessor('address.city');
array_map($city, $people); // => ['Porto', 'Lisbon']

usort($people, fn($a, $b) => $city($a) <=> $city($b));
array_map(PropertyAccess::accessor('name'), $people); // => ['Ana', 'Bruno']

Performance

What a class answers for a name (which method serves it, which declared public property, which marked field) is the same for every instance of that class, so it is discovered once by reflection and kept in a plan per class and name. Nothing that depends on the instance is kept: ArrayAccess offsets, isset()/__isset(), dynamic properties (stdClass included) and the values themselves are evaluated on every call. The plan table is bounded (4096 entries); when it is full it is dropped and rebuilt on demand, so names coming from requests cannot grow it without limit in a long-running worker.

Relationship with ASpecification and ALinq

antevemus/aspecification (Helpers\PropertyAccessor, up to 1.14) and antevemus/alinq-collection (Helpers\ALinqPropertyAccess, up to 1.5) each carried a copy of this accessor, kept equivalent by hand. This package is that accessor, extracted so that both libraries read the same value from the same candidate by construction. Their 2.0.0 releases are planned to depend on it (see ROADMAP.md); their own #[Specifiable] attributes stay and keep working, recognized by name.

The behaviour differs from those 1.x copies only in edge cases, all listed in CHANGELOG.md: mainly, a public method with a required parameter is no longer called as a getter (the copies threw ArgumentCountError from getValue() while hasProperty() answered true).

Requirements

  • PHP 8.2 or later (tested on 8.2, 8.3 and 8.4).
  • No extension and no Composer package at runtime.

License

MIT, see LICENSE. Provenance of the last resolution step (Domian) in NOTICE.md; the Apache License 2.0 text in THIRD_PARTY_NOTICES.md.