ruwork/annotation-tools
Annotation tools for Laravel and PHP: read, cache, and validate Doctrine-style annotations with a lightweight parser, helping you wire annotation-driven configuration, routing, or metadata into your app without heavy dependencies or repeated reflection overhead.
Install and inspect
composer require ruwork/annotation-tools
The package is a thin layer over doctrine/annotations (it will pull in doctrine/annotations and doctrine/cache/psr/cache as transitive deps depending on the version you resolve). Before writing any code, read two places:
src/ — the package is small; the public surface is typically a reader/parser facade plus a handful of annotation value classes. That is the entire API you will use.tests/ — the tests are the de facto documentation. They show the exact docblock syntax the parser accepts and how nested/array values are normalised.Register it once in the container
Do not instantiate the reader ad hoc; bind it as a singleton so the parsed metadata is reused within a request.
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Doctrine\Common\Annotations\Reader::class, function () {
return new \Doctrine\Common\Annotations\AnnotationReader();
});
If the package ships its own reader/parser class, bind that instead — the point is one shared instance.
First use case: metadata-driven behaviour
The canonical day-to-day use is attaching declarative metadata to a class and reading it at runtime instead of maintaining a parallel config array. A realistic first case: per-model export/audit rules.
use Doctrine\Common\Annotations\Annotation\Target;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY"})
*/
final class Sensitive
{
/** @var string */
public $level = 'high';
public function __construct(array $values = [])
{
$this->level = $values['value'] ?? $values['level'] ?? 'high';
}
}
/**
* @Sensitive(level="critical")
*/
class Invoice extends Model
{
/** @Sensitive */
public $iban;
}
Then a single service reads it:
$reflection = new ReflectionClass(Invoice::class);
$classAnnotation = $reader->getClassAnnotation($reflection, Sensitive::class);
foreach ($reflection->getProperties() as $property) {
$meta = $reader->getPropertyAnnotation($property, Sensitive::class);
}
That is the whole loop: annotate → reflect → read → branch. Everything else is caching and integration.
Pattern 1 — Reader as an injected collaborator
Never call new AnnotationReader() inside a loop or a model method. Wrap it in your own small service so you can cache and test it:
final class AnnotationMetadata
{
public function __construct(private Reader $reader) {}
public function forClass(string $class): array
{
$reflection = new ReflectionClass($class);
return [
'class' => $this->reader->getClassAnnotation($reflection, Sensitive::class),
'properties' => collect($reflection->getProperties())
->mapWithKeys(fn ($p) => [$p->getName() => $this->reader->getPropertyAnnotation($p, Sensitive::class)])
->filter()
->all(),
];
}
}
Inject AnnotationMetadata, not the raw reader, into controllers, policies, and jobs. That gives you one seam for caching and one seam for migrating to PHP 8 attributes later.
Pattern 2 — Cache the parsed result, not the reflection
Docblock parsing is the expensive part. Cache the hydrated metadata keyed by class name, with a hash of the file mtime so deploys invalidate it:
public function forClass(string $class): array
{
$file = (new ReflectionClass($class))->getFileName();
$key = 'annotations:'.md5($class.filemtime($file));
return Cache::rememberForever($key, fn () => $this->parse($class));
}
Cache::rememberForever is safe here because the key embeds the mtime. In production, warm it in a console command during deploy; in local dev, skip the cache entirely.
Pattern 3 — Discovery via Composer's classmap, not glob()
To build a registry ("find every class annotated with @FeatureFlag"), do not scan the filesystem. Use Composer's classmap so you only touch autoloadable classes:
$classmap = require base_path('vendor/composer/autoload_classmap.php');
foreach (array_keys($classmap) as $class) {
if (!str_starts_with($class, 'App\\')) {
continue;
}
if ($this->annotations->forClass($class)['class'] ?? null) {
$this->register($class);
}
}
This mirrors how Symfony bundles build their metadata registries, and it is the only reliable way to find annotated classes without autoloading arbitrary paths.
Pattern 4 — Annotation value objects instead of raw strings
Keep annotations dumb: constructor takes an array, maps it to typed public properties, no behaviour. Put behaviour in the consumer. This keeps annotations serialisable and cacheable, and means a later migration to attributes is a one-line swap (#[Sensitive(level: 'critical')] with a promoted constructor).
Pattern 5 — Laravel integration glue
If the upstream package ships a Symfony Bundle (common for the Ruwork family), do not try to install it. Port the four lines that matter into a service provider: bind the reader, register the metadata service, and publish any config. Treat the bundle class as reference source, not a dependency.
Pattern 6 — Testing
Because metadata is just reflection, tests are cheap: define fixture classes with known annotations in tests/Fixtures/ and assert on the parsed structure. For consumers, don't mock the reader — feed it a real fixture class. Mocking reflection rarely pays off.
The package is unmaintained (last release 2018-05-07). Zero stars, zero dependents means zero community signal. Pin the exact version in composer.json and expect no upstream fixes. Two concrete risks: (a) doctrine/annotations 2.x drops AnnotationReader in favour of attributes-only in many code paths — verify which major you resolve; (b) PHP 8.1+ emits deprecations from older docblock tooling. Budget half a day to fork it into packages/ if you hit a blocker, which is realistic for a package this small.
Every annotation class needs @Annotation and @Target. Without @Target, Doctrine's reader throws AnnotationException at parse time — often with a message that names the consumer class, not the annotation, which is confusing. Always declare targets explicitly.
You must use the annotation class in the consuming file — or write the FQCN. @Sensitive works only if the file imports it. In a docblock, @Sensitive is resolved through the file's use statements, not through the namespace of the class it annotates if there's a conflict. When in doubt, write @\App\Annotations\Sensitive.
PHPStan/phpdoc tags collide. @var, @throws, @author, and @see are legitimate docblock tags. If the reader tries to compile them as annotations you get errors. Doctrine handles known tags, but custom tooling in your codebase may not — annotate the reader with @IgnoreAnnotation or configure the reader's ignored-names list.
Constructors are mandatory for parameterised annotations. @Sensitive("critical") passes ['value' => 'critical'] to the annotation's constructor. If your annotation class has no __construct, Doctrine creates it via newInstanceWithoutConstructor and sets public properties directly — which silently ignores positional values. Always write the constructor that maps value.
Cache keys must include file mtime and the parser version. If you later migrate from annotations to attributes, stale cache entries will keep returning old data. Salt the key with a constant you bump on changes.
Beware class_exists() during discovery. Instantiating a ReflectionClass autoloads the class, which runs its static initialisers and any side effects in its file. Discovery over the Composer classmap means you will autoload every App\ class. Guard this behind a deploy-time command, not a runtime path.
Docblock parsing is ~10–100× slower than a config lookup. This is fine for a handful of classes at boot, fatal if you do it per row in a loop. If you find yourself calling the reader inside foreach ($models as $model), hoist the metadata lookup or cache by class name.
Extension points are thin. The reader is the extension point: wrap it. Do not subclass the package's parser unless you have read its source — the internal API is undocumented and unversioned. Your own AnnotationMetadata service is the thing you should be able to swap over time; treat the third-party bits as an implementation detail you can delete.
Migrate to native attributes when you can. For new code on PHP 8, #[Sensitive(level: 'critical')] with ReflectionClass::getAttributes() gives you the same result with no dependency, no docblock grammar, and IDE support. Use this package's readers behind your wrapper so the swap is a single class change rather than a codebase-wide rewrite.
How can I help you explore Laravel packages today?