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.
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:
- In-memory — a per-request array keyed by class name; always checked first and always populated after a parse.
- File cache — a serialized file in the configured directory. Valid only if the cache file is newer than the source
.phpfile. - 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.