Annotation Reader

The Annotation Reader parses PHP docblock annotations for classes, properties, and methods — returning immutable, iterable collections with flexible access patterns and optional file-based caching.

explanation

Installation

Install via Composer:

composer require quellabs/annotation-reader

Setup & Configuration

Construct an AnnotationReader by passing a Configuration object. The two relevant settings are whether to use file-based caching and where to store the cache files.

use Quellabs\AnnotationReader\AnnotationReader;
use Quellabs\AnnotationReader\Configuration;

$config = new Configuration();
$config->setUseAnnotationCache(true);
$config->setAnnotationCachePath(__DIR__ . '/cache');

$reader = new AnnotationReader($config);

Without caching, annotations are parsed on every request. With caching enabled, the reader compares the modification time of the source file against the cache file and only re-parses when the source has changed.

Annotation Syntax

Annotations are written in PHP docblocks using the @AnnotationName syntax. Parameters are passed as named key-value pairs in parentheses. The reader supports strings, numbers, booleans, arrays, and the ::class magic constant — but not arbitrary PHP expressions or other class constants.

/**
 * @Table(name="products")
 * @Cache(ttl=3600, enabled=true)
 */
class Product {
    /**
     * @Column(type="integer", primary=true, autoincrement=true)
     */
    private int $id;

    /**
     * @Column(type="string", length=255)
     * @Validate("required")
     * @Validate("maxLength", 255)
     */
    private string $name;
}

The ::class magic constant is resolved using the file's use imports, so short names work as expected:

use App\Services\ValidationService;
use App\Events\UserCreated;

/**
 * @Table("posts")
 * @ListenTo("debug.signals")
 */
class UserService {
    /**
     * @Inject(service=ValidationService::class)
     */
    private ValidationService $validator;
}

Reading Annotations

The reader provides three read methods — one per reflection target. Each returns an AnnotationCollection. An optional third argument narrows the result to a specific annotation class.

// All annotations on the class docblock
$classAnnotations = $reader->getClassAnnotations(MyClass::class);

// Only @Cache annotations on the class
$cacheAnnotations = $reader->getClassAnnotations(MyClass::class, Cache::class);

// All annotations on a property
$propertyAnnotations = $reader->getPropertyAnnotations(MyClass::class, 'name');

// All annotations on a method, filtered by type
$methodAnnotations = $reader->getMethodAnnotations(MyClass::class, 'save', Validate::class);

Convenience boolean methods are available when you only need to know whether an annotation exists:

if ($reader->classHasAnnotation(MyClass::class, Entity::class)) { /* ... */ }
if ($reader->methodHasAnnotation(MyClass::class, 'save', Transactional::class)) { /* ... */ }
if ($reader->propertyHasAnnotation(MyClass::class, 'name', Column::class)) { /* ... */ }

Working with AnnotationCollection

All methods return an AnnotationCollection — an immutable, iterable object that implements ArrayAccess, Countable, and Iterator. It supports both numeric and class-name indexing.

$annotations = $reader->getMethodAnnotations(MyClass::class, 'getUsers');

// Numeric access
$first = $annotations[0];

// Class-name access — returns the first annotation of that type
$route = $annotations[Route::class];

// Iteration — each step yields one annotation object
foreach ($annotations as $annotation) { /* ... */ }

// Collection introspection
$count   = count($annotations);
$isEmpty = $annotations->isEmpty();
$first   = $annotations->first();
$last    = $annotations->last();

Multiple Annotations of the Same Type

A method or property may carry more than one annotation of the same class. Use all() to retrieve every instance, and hasMultiple() to branch on multiplicity.

/**
 * @InterceptWith("AuthValidator")
 * @InterceptWith("LoggingInterceptor")
 * @Route("/api/users")
 */
public function getUsers(): Response { /* ... */ }
$annotations = $reader->getMethodAnnotations(MyClass::class, 'getUsers');

// First occurrence only
$first = $annotations[InterceptWith::class];

// All occurrences as a new AnnotationCollection
$all = $annotations->all(InterceptWith::class);

if ($annotations->hasMultiple(InterceptWith::class)) {
    foreach ($all as $interceptor) { /* ... */ }
}

Filtering

filter() accepts a callable and returns a new AnnotationCollection containing only the annotations for which the callback returns true. Operations can be chained because every method returns a collection.

// Keep only active annotations
$active = $annotations->filter(fn($a) => $a->isActive());

// Chain: all InterceptWith annotations that are active
$activeInterceptors = $annotations
    ->all(InterceptWith::class)
    ->filter(fn($interceptor) => $interceptor->isActive());

Array Conversion

AnnotationCollection provides three conversion methods for different use cases.

toIndexedArray() returns a plain numerically-indexed array preserving insertion order. Use this for sequential processing or serialization.

$array = $annotations->toIndexedArray();
// [0 => Route(...), 1 => InterceptWith("Auth"), 2 => InterceptWith("Log"), 3 => Cache(...)]

toArray() uses the class name as key for the first occurrence of each type; subsequent duplicates fall back to a numeric key. Useful when you need fast single-annotation lookup alongside duplicate preservation in one structure.

$array = $annotations->toArray();
// [Route::class => Route(...), InterceptWith::class => InterceptWith("Auth"), 0 => InterceptWith("Log"), Cache::class => Cache(...)]

$route           = $array[Route::class];
$firstInterceptor = $array[InterceptWith::class];
$secondInterceptor = $array[0];

toGroupedArray() maps each class name to an array of all its instances. The cleanest choice when you need to process every annotation of each type.

$grouped = $annotations->toGroupedArray();
// [
//   InterceptWith::class => [InterceptWith("Auth"), InterceptWith("Log")],
//   Route::class         => [Route("/api/users")],
//   Cache::class         => [Cache(ttl=3600)]
// ]

foreach ($grouped[InterceptWith::class] as $interceptor) { /* ... */ }

Implementing Annotation Classes

Annotation classes must implement AnnotationInterface, which requires a constructor that accepts a named-parameters array and a getParameters() method.

use Quellabs\AnnotationReader\AnnotationInterface;

class Route implements AnnotationInterface {

    private string $path;
    private string $method;

    public function __construct(array $parameters) {
        $this->path   = $parameters['value'] ?? $parameters['path'] ?? '/';
        $this->method = $parameters['method'] ?? 'GET';
    }

    public function getParameters(): array {
        return ['path' => $this->path, 'method' => $this->method];
    }

    public function getPath(): string  { return $this->path; }
    public function getMethod(): string { return $this->method; }
}

Caching Behaviour

When caching is enabled the reader operates in three layers, checked in order:

  1. In-memory — a per-request array keyed by class name; always checked first and always populated after a parse.
  2. File cache — a serialized file in the configured directory. Valid only if the cache file is newer than the source .php file.
  3. Live parse — the lexer/parser runs against the docblock string; result is written to both the file cache and the in-memory cache.
// Cache disabled — parses on every instantiation, still uses in-memory deduplication
$config->setUseAnnotationCache(false);

// Cache enabled — reads from file when valid, re-parses when source changes
$config->setUseAnnotationCache(true);
$config->setAnnotationCachePath('/var/cache/annotations');

Cache files are named by replacing namespace separators with # and appending .cache, e.g. App#Entity#User.cache. The directory is created automatically on first write.