Skip to content

Utilities to help ease parsing and manging of attributes

License

Notifications You must be signed in to change notification settings

Crell/AttributeUtils

Repository files navigation

Attribute Utilities

Latest Version on Packagist Software License Total Downloads

AttributeUtils provides utilities to simplify working with and reading Attributes in PHP 8.1 and later.

Its primary tool is the Class Analyzer, which allows you to analyze a given class or enum with respect to some attribute class. Attribute classes may implement various interfaces in order to opt-in to additional behavior, as described below. The overall intent is to provide a simple but powerful framework for reading metadata off of a class, including with reflection data.

Install

Via Composer

$ composer require crell/attributeutils

Usage

Basic usage

The most important class in the system isAnalyzer,which implements theClassAnalyzerinterface.

#[MyAttribute(a:1,b:2)]
classPoint
{
publicint$x;
publicint$y;
publicint$z;
}

$analyzer=newCrell\AttributeUtils\Analyzer();

$attrib=$analyzer->analyze(Point::class,MyAttribute::class);

// $attrib is now an instance of MyAttribute.
print$attrib->a.PHP_EOL;// Prints 1
print$attrib->b.PHP_EOL;// Prints 2

All interaction with the reflection system is abstracted away by theAnalyzer.

You may analyze any class with respect to any attribute. If the attribute is not found, a new instance of the attribute class will be created with no arguments, that is, using whatever it's default argument values are. If any arguments are required, aRequiredAttributeArgumentsMissingexception will be thrown.

The net result is that you can analyze a class with respect to any attribute class you like, as long as it has no required arguments.

The most important part ofAnalyzer,though, is that it lets attributes opt-in to additional behavior to become a complete class analysis and reflection framework.

Reflection

If a class attribute implementsCrell\AttributeUtils\FromReflectionClass,then once the attribute has been instantiated theReflectionClassrepresentation of the class being analyzed will be passed to thefromReflection()method. The attribute may then save whatever reflection information it needs, however it needs. For example, if you want the attribute object to know the name of the class it came from, you can save$reflection->getName()and/or$reflection->getShortName()to non-constructor properties on the object. Or, you can save them if and only if certain constructor arguments were not provided.

If you are saving a reflection value literally, it isstrongly recommendedthat you use a property name consistent with those in theReflectClassattribute. That way, the names are consistent across all attributes, even different libraries, and the resulting code is easier for other developers to read and understand. (We'll coverReflectClassmore later.)

In the following example, an attribute accepts a$nameargument. If one is not provided, the class's short-name will be used instead.

#[\Attribute]
classAttribWithNameimplementsFromReflectionClass
{
publicreadonlystring$name;

publicfunction__construct(?string$name=null)
{
if($name) {
$this->name=$name;
}
}

publicfunctionfromReflection(\ReflectionClass$subject):void
{
$this->name??=$subject->getShortName();
}
}

The reflection object itself shouldnever everbe saved to the attribute object. Reflection objects cannot be cached, so saving it would render the attribute object uncacheable. It's also wasteful, as any data you need can be retrieved from the reflection object and saved individually.

There are similarlyFromReflectionProperty,FromReflectionMethod,FromReflectionClassConstant,andFromReflectionParameterinterfaces that do the same for their respective bits of a class.

Additional class components

The class attribute may also opt-in to analyzing various portions of the class, such as its properties, methods, and constants. It does so by implementing theParseProperties,ParseStaticProperties,ParseMethods,ParseStaticMethods,orParseClassConstantsinterfaces, respectively. They all work the same way, so we'll look at properties in particular.

An example is the easiest way to explain it:

#[\Attribute(\Attribute::TARGET_CLASS)]
classMyClassimplementsParseProperties
{
publicreadonlyarray$properties;

publicfunctionpropertyAttribute():string
{
returnMyProperty::class;
}

publicfunctionsetProperties(array$properties):void
{
$this->properties=$properties;
}

publicfunctionincludePropertiesByDefault():bool
{
returntrue;
}
}

#[\Attribute(\Attribute::TARGET_PROPERTY)]
classMyProperty
{
publicfunction__construct(
publicreadonlystring$column='',
) {}
}

#[MyClass]
classSomething
{
#[MyProperty(column:'beep')]
protectedproperty$foo;

privateproperty$bar;
}

$attrib=$analyzer->analyze(Something::class,MyClass::class);

In this example, theMyClassattribute will first be instantiated. It has no arguments, which is fine. However, the interface methods specify that the Analyzer should then parseSomething's properties with respect toMyProperty.If a property has no such attribute, it should be included anyway and instantiated with no arguments.

The Analyzer will dutifully create an array of twoMyPropertyinstances, one for$fooand one for$bar;the former having thecolumnvaluebeep,and the latter having the default empty string value. That array will then be passed toMyClass::setProperties()forMyClassto save, or parse, or filter, or do whatever it wants.

IfincludePropertiesByDefault()returnedfalse,then the array would have only one value, from$foo.$barwould be ignored.

Note: The array that is passed tosetPropertiesis indexed by the name of the property already, so you do not need to do so yourself.

The property-targeting attribute (MyProperty) may also implementFromReflectionPropertyto get the correspondingReflectionPropertypassed to it, just as the class attribute can.

The Analyzer includes only object level properties inParseProperties.If you want static properties, use theParseStaticPropertiesinterface, which works the exact same way. Both interfaces may be implemented at the same time.

TheParseClassConstantinterface works the same way asParseProperties.

Methods

ParseMethodsworks the same way asParseProperties(and also has a correspondingParseStaticMethodsinterface for static methods). However, a method-targeting attribute may also itself implementParseParametersin order to examine parameters on that method.ParseParametersrepeats the same pattern asParsePropertiesabove, with the methods suitably renamed.

Class-referring components

A component-targeting attribute may also implementReadsClass.If so, then the class's attribute will be passed to thefromClassAttribute()method after all other setup has been done. That allows the attribute to inherit default values from the class, or otherwise vary its behavior based on properties set on the class attribute.

Excluding values

When parsing components of a class, whether they are included depends on a number of factors. TheincludePropertiesByDefault(),includeMethodsByDefault(),etc. methods on the variousParse*interfaces determine whether components that lack an attribute should be included with a default value, or excluded entirely.

If theinclude*()method returns true, it is still possible to exclude a specific component if desired. The attribute for that component may implement theExcludableinterface, with has a single method,exclude().

What then happens is the Analyzer will load all attributes of that type, then filter out the ones that returntruefrom that method. That allows individual properties, methods, etc. to opt-out of being parsed. You may use whatever logic you wish forexclude(),although the most common approach will be something like this:

#[\Attribute(\Attribute::TARGET_PROPERTY)]
classMyPropertyimplementsExcludable
{
publicfunction__construct(
publicreadonlybool$exclude=false,
) {}

publicfunctionexclude():bool
{
return$this->exclude;
}
}

classSomething
{
#[MyProperty(exclude:true)]
privateint$val;
}

If you are taking this manual approach, it is strongly recommended that you use the naming convention here for consistency.

Attribute inheritance

By default, attributes in PHP are not inheritable. That is, if classAhas an attribute on it, andBextendsA,then asking reflection what attributesBhas will find none. Sometimes that's OK, but other times it is highly annoying to have to repeat values.

Analyzeraddresses that limitation by letting attributes opt-in to being inherited. Any attribute — for a class, property, method, constant, or parameter — may also implement theInheritablemarker interface. This interface has no methods, but signals to the system that it should itself check parent classes and interfaces for an attribute if it is not found.

For example:

#[\Attribute(\Attribute::TARGET_CLASS)]
classMyClassimplementsInheritable
{
publicfunction__construct(publicstring$name='') {}
}

#[MyClass(name:'Jorge')]
classA{}

classBextendsA{}

$attrib=$analyzer->analyze(B::class,MyClass::class);

print$attrib->name.PHP_EOL;// prints Jorge

BecauseMyClassis inheritable, the Analyzer notes that it is absent onBso checks classAinstead. All attribute components may be inheritable if desired just by implementing the interface.

When checking for inherited attributes, ancestor classes are all checked first, then implemented interfaces, in the order returned byclass_implements().Properties will not check for interfaces, of course, as interfaces cannot have properties.

Attribute child classes

When checking for an attribute, the Analyzer uses aninstanceofcheck in Reflection. That means a child class, or even a class implementing an interface, of what you specify will still be found and included. That is true for all attribute types.

Sub-attributes

Analyzercan only handle a single attribute on each target. However, it also supports the concept of "sub-attributes." Sub-attributes work similarly to the way a class can opt-in to parsing properties or methods, but for sibling attributes instead of child components. That way, any number of attributes on the same component can be folded together into a single attribute object. Any attribute for any component may opt-in to sub-attributes by implementingHasSubAttributes.

The following example should make it clearer:

#[\Attribute(\Attribute::TARGET_CLASS)]
classMainAttribimplementsHasSubAttributes
{
publicreadonlyint$age;

publicfunction__construct(
publicreadonlystringname = 'none',
) {}

publicfunctionsubAttributes():array
{
return[Age::class =>'fromAge'];
}

publicfunctionfromAge(?Age$sub):void
{
$this->age=$sub?->age??0;
}
}

#[\Attribute(\Attribute::TARGET_CLASS)]
classAge
{
publicfunction__construct(publicreadonlyint$age=0) {}
}

#[MainAttrib(name:'Larry')]
#[Age(21)]
classA{}

classB{}

$attribA=$analyzer->analyze(A::class,MainAttrib::class);

print"$attribA->name,$attribA->age\n ";// prints "Larry, 21"

$attribB=$analyzer->analyze(B::class,MainAttrib::class);

print"$attribB->name,$attribB->age\n ";// prints "none, 0"

ThesubAttributes()method returns an associative array of attribute class names mapped to methods to call. They may be strings, or an inline closure, or a closed reference to a method, which may be private if desired. For example:

#[\Attribute(\Attribute::TARGET_CLASS)]
classMainAttribimplementsHasSubAttributes
{
publicreadonlyint$age;
publicreadonlystring$name;

publicfunction__construct(
publicreadonlystringname = 'none',
) {}

publicfunctionsubAttributes():array
{
return[
Age::class =>$this->fromAge(...),
Name::class =>function(?Name$sub) {
$this->name=$sub?->name??'Anonymous';
}
];
}

privatefunctionfromAge(?Age$sub):void
{
$this->age=$sub?->age??0;
}
}

After theMainAttribis loaded, the Analyzer will look for any of the listed sub-attributes, and then pass their result to the corresponding method. The main attribute can then save the whole sub-attribute, or pull pieces out of it to save, or whatever else it wants to do.

An attribute may have any number of sub-attributes it wishes.

Note that if the sub-attribute is missing,nullwill be passed to the method. That is to allow a sub-attribute to have required parameters if and only if it is specified, while keeping the sub-attribute itself optional. You thereforemustmake the callback method's argument nullable.

Sub-attributes may also beInheritable.

Multi-value sub-attributes

By default, PHP attributes can only be placed on a given target once. However, they may be marked as "repeatable," in which case multiple of the same attribute may be placed on the same target. (Class, property, method, etc.)

The Analyzer does not support multi-value attributes, but it does support multi-value sub-attributes. If the sub-attribute implements theMultivaluemarker interface, then an array of sub-attributes will be passed to the callback instead.

For example:

#[\Attribute(\Attribute::TARGET_CLASS)]
classMainAttribimplementsHasSubAttributes
{
publicreadonlyarray$knows;

publicfunction__construct(
publicreadonlystringname = 'none',
) {}

publicfunctionsubAttributes():array
{
return[Knows::class =>'fromKnows'];
}

publicfunctionfromKnows(array$knows):void
{
$this->knows=$knows;
}
}

#[\Attribute(\Attribute::TARGET_CLASS| \Attribute::IS_REPEATABLE)]
classKnowsimplementsMultivalue
{
publicfunction__construct(publicreadonlystring$name) {}
}

#[MainAttrib(name:'Larry')]
#[Knows('Kai')]
#[Knows('Molly')]
classA{}

classB{}

In this case, any number ofKnowsattributes may be included, including zero, but if included the$nameargument is required. ThefromKnows()method will be called with a (possibly empty, in the case ofB) array ofKnowsobjects, and can do what it likes with it. In this example the objects are saved in their entirety, but they could also be mushed into a single array or used to set some other value if desired.

Note that if a multi-value sub-attribute isInheritable,ancestor classes will only be checked if there are no local sub-attributes. If there is at least one, it will take precedence and the ancestors will be ignored.

Note: In order to make use of multi-value sub-attributes, the attribute class itself must be marked as "repeatable" as in the example above or PHP will generate an error. However, that is not sufficient for the Analyzer to parse it as multi-value. That's because attributes may also be multi-value when implementing scopes, but still only single-value from the Analzyer's point of view. See the section on Scopes below.

Finalizing an attribute

Attributes that opt-in to several functional interfaces may not always have an easy time of knowing when to do default handling. It may not be obvious when the attribute setup is "done." Attribute classes may therefore opt-in to theFinalizableinterface. If specified, it is guaranteed to be the last method called on the attribute. The attribute may then do whatever final preparation is appropriate to consider the object "ready."

Caching

The mainAnalyzerclass does no caching whatsoever. However, it implements aClassAnalyzerinterface which allows it to be easily wrapped in other implementations that provide a caching layer.

For example, theMemoryCacheAnalyzerclass provides a simple wrapper that caches results in a static variable in memory. You should almost always use this wrapper for performance.

$analyzer=newMemoryCacheAnalyzer(newAnalyzer());

A PSR-6 cache bridge is also included, allowing the Analyzer to be used with any PSR-6 compatible cache pool.

$anaylzer=newPsr6CacheAnalyzer(newAnalyzer(),$somePsr6CachePoolObject);

Wrappers may also compose each other, so the following would be an entirely valid and probably good approach:

$analyzer=newMemoryCacheAnalyzer(newPsr6CacheAnalyzer(newAnalyzer(),$psr6CachePool));

Advanced features

There are a couple of other advanced features also available. These are less frequently used, but in the right circumstances they can be very helpful.

Scopes

Attributes may opt-in to supporting "scopes". "Scopes" allow you to specify alternate versions of the same attribute to use in different contexts. Examples include different serialization groups or different languages. Often, scopes will be hidden behind some other name in another library (like language), which is fine.

If an attribute implementsSupportsScopes,then when looking for attributes additional filtering will be performed. The exact logic also interacts with exclusion and whether a class attribute specifies a component should be loaded by default if missing, leading to a highly robust set of potential rules for what attribute to use when.

As an example, let's consider providing alternate language versions of a property attribute. The logic is identical for any component, as well as for sub-attributes.

#[\Attribute(\Attribute::TARGET_CLASS)]
classLabeledimplementsParseProperties
{
publicreadonlyarray$properties;

publicfunctionsetProperties(array$properties):void
{
$this->properties??=$properties;
}

publicfunctionincludePropertiesByDefault():bool
{
returntrue;
}

publicfunctionpropertyAttribute():string
{
returnLabel::class;
}
}

#[\Attribute(\Attribute::TARGET_PROPERTY| \Attribute::IS_REPEATABLE)]
classLabelimplementsSupportsScopes,Excludable
{
publicfunction__construct(
publicreadonlystring$name='Untitled',
publicreadonly?string$language=null,
publicreadonlybool$exclude=false,
) {}

publicfunctionscopes():array
{
return[$this->language];
}

publicfunctionexclude():bool
{
return$this->exclude;
}
}

#[Labeled]
classApp
{
#[Label(name:'Installation')]
#[Label(name:'Instalación',language:'es')]
publicstring$install;

#[Label(name:'Setup')]
#[Label(name:'Configurar',language:'es')]
#[Label(name:'Einrichten',language:'de')]
publicstring$setup;

#[Label(name:'Einloggen',language:'de')]
#[Label(language:'fr',exclude:true)]
publicstring$login;

publicstring$customization;
}

TheLabeledattribute on the class is nothing we haven't seen before. TheLabelattribute for properties is both excludable and supports scopes, although it exposes it with the namelanguage.

Calling the Analyzer as we've seen before will ignore the scoped versions, and result in an array ofLabels with names "Installation", "Setup", "Untitled", and "Untitled". However, it may also be invoked with a specific scope:

$labels=$analyzer->analyze(App::class,Labeled::class, scopes: ['es']);

Now,$labelswill contain an array ofLabels with names "Instalación", "Configurar", "Untitled", and "Untitled". On$stepThree,there is noesscoped version so it falls back to the default. Similarly, a scope ofdewill result in "Installation", "Einrichten", "Einloggen", and "Untitled" (as "Installation" is spelled the same in both English and German).

A scope offrwill result in the default (English) for each case, except for$stepThreewhich will be omitted entirely. Theexcludedirective is applicable only in that scope. The result will therefore be "Installation", "Setup", "Untitled".

(If you were doing this for real, it would make sense to derive a defaultnameoff of the property name itself viaFromReflectionPropertyrather than a hard-coded "Untitled." )

By contrast, ifLabeled::includePropertiesByDefault()returns false, then$customizationwill not be included in any scope.$loginwill be included indeonly, and in no other scope at all. That's because there is no default-scope option specified, and so in any scope other thandeno default will be created. A lookup for scopefrwill be empty.

A useful way to control what properties are included is to make the class-level attribute scope-aware as well, and controlincludePropertiesByDefault()via an argument. That way, for example,includePropertiesByDefault()can return true in the unscoped case, but false when a scope is explicitly specified; that way, properties will only be included in a scope if they explicitly opt-in to being in that scope, while in the unscoped case all properties are included.

Note that thescopes()method returns an array. That means an attribute being part of multiple scopes is fully supported. How you populate the return of that method (whether an array argument or something else) is up to you.

Additionally, scopes are looked up as an ORed array. That is, the following command:

$labels=$analyzer->analyze(SomeClass::class,AnAttribute::class, scopes: ['One','Two']);

will retrieve any attributes that returneitherOneorTwofrom theirscopes()method. If multiple attributes on the same component match that rule (say, one returns['One']and another returns['Two']), the lexically first will be used.

Transitivity

Transitivity applies only to attributes on properties, and only if the attribute in question can target both properties and classes. It is an alternate form of inheritance. Specifically, if a property is typed to a class or interface, and the attribute in question implementsTransitiveProperty,and the property does not have that attribute on it, then instead of looking up the inheritance tree the analyzer will first look at the class the property is typed for.

That's a lot of conditionals, so here's an example to make it clearer:

#[\Attribute(\Attribute::TARGET_PROPERTY)]
classMyClassimplementsParseProperties
{
publicreadonlyarray$properties;

publicfunctionsetProperties(array$properties):void
{
$this->properties=$properties;
}

publicfunctionincludePropertiesByDefault():bool{returntrue;}

publicfunctionpropertyAttribute():string{returnFancyName::class; }
}


#[\Attribute(\Attribute::TARGET_PROPERTY| \Attribute::TARGET_CLASS)]
classFancyNameimplementsTransitiveProperty
{
publicfunction__construct(publicreadonlystring$name='') {}
}

classStuff
{
#[FancyName('A happy little integer')]
protectedint$foo;

protectedstring$bar;

protectedPerson$personOne;

#[FancyName('Her Majesty Queen Elizabeth II')]
protectedPerson$personTwo;
}

#[FancyName('I am not an object, I am a free man!')]
classPerson
{
}

$attrib=$analyzer->analyze(Stuff::class,MyClass::class);

print$attrib->properties['foo']->name.PHP_EOL;// prints "A happy little integer"
print$attrib->properties['bar']->name.PHP_EOL;// prints ""
print$attrib->properties['personOne']->name.PHP_EOL;// prints "I am not an object, I am a free man!"
print$attrib->properties['personTwo']->name.PHP_EOL;// prints "Her Majesty Queen Elizabeth II"

Because$personTwohas aFancyNameattribute, it behaves as normal. However,$personOnedoes not, so it jumps over to thePersonclass to look for the attribute and finds it there.

If an attribute implements bothInheritableandTransitive,then first the class being analyzed will be checked, then its ancestor classes, then its implemented interfaces, then the transitive class for which it is typed, and then that class's ancestors until it finds an appropriate attribute.

Both main attributes and sub-attributes may be declaredTransitive.

Custom analysis

As a last resort, an attribute may also implement theCustomAnalysisinterface. If it does so, the analyzer itself will be passed to thecustomAnalysis()method of the attribute, which may then take whatever actions it wishes. This feature is intended as a last resort only, and it's possible to create unpleasant infinite loops if you are not careful. 99% of the time you should use some other, any other mechanism. But it's there if you need it.

Dependency Injection

The Analyzer is designed to be usable on its own without any setup. Making it available via a Dependency Injection Container is recommended. An appropriate cache wrapper should also be included in the DI configuration.

Function analysis

There is also support for retrieving attributes on functions, via a separate analyzer (that works essentially the same way). TheFuncAnalyzerclass implements theFunctionAnalyzerinterface.

useCrell\AttributeUtils\FuncAnalyzer;

#[MyFunc]
functionbeep(int$a) {}

$closure= #[MyClosure] fn(int$a) =>$a+1;

// For functions...
$analyzer=newFuncAnalyzer();
$funcDef=$analyzer->analyze('beep',MyFunc::class);

// For closures
$analyzer=newFuncAnalyzer();
$funcDef=$analyzer->analyze($closure,MyFunc::class);

Sub-attributes,ParseParameters,andFinalizableall work on functions exactly as they do on classes and methods, as do scopes. There is also a correspondingFromReflectionFunctioninterface for receiving theReflectionFunctionobject.

There are also cache wrappers available for the FuncAnalyzer as well. They work the same way as on the class analyzer.

# In-memory cache.
$analyzer=newMemoryCacheFunctionAnalyzer(newFuncAnalyzer());

# PSR-6 cache.
$anaylzer=newPsr6CacheFunctionAnalyzer(newFuncAnalyzer(),$somePsr6CachePoolObject);

# Both caches.
$analyzer=newMemoryCacheFunctionAnalyzer(
newPsr6CacheFunctionAnalyzer(newFuncAnalyzer(),$psr6CachePool)
);

As with the class analyzer, it's best to wire these up in your DI container.

The Reflect library

One of the many uses forAnalyzeris to extract reflection information from a class. Sometimes you only need some of it, but there's no reason you can't grab all of it. The result is an attribute that can carry all the same information as reflection, but can be cached if desired while reflection objects cannot be.

A complete set of such attributes is provided in theAttributes/Reflectdirectory. They cover all components of a class. As none of them have any arguments, there is no need to put them on any class. The default "empty" version of each will get used, which will then self-populate using theFromReflection*interfaces.

The net result is that a full reflection summary of any arbitrary class may be obtained by calling:

useCrell\AttributeUtls\Attributes\Reflect\ReflectClass;

$reflect=$analyzer->analyze($someClass,ReflectClass::class);

$reflectnow contains a complete copy of the class, properties, constants, methods, and parameters reflection information, in well-defined, easily cacheable objects. See each class's docblocks for a complete list of all available information.

To analyze an Enum, useReflectEnum::classinstead.

Even if you do not need to use the entire Reflect tree, it's worth studying as an example of how to really leverage the Analyzer. Additionally, if you are saving any reflection values as-is onto your attribute you are encouraged to use the same naming conventions as those classes, for consistency.

A number of traits are included as well that handle the common case of collecting all of a given class component. Feel free to use them in your own classes if you wish.

Advanced tricks

The following are a collection of advanced and fancy uses of the Analyzer, mostly to help demonstrate just how powerful it can be when used appropriately.

Multi-value attributes

As noted, the Analyzer supports only a single main attribute on each component. However, sub-attributes may be multi-value, and an omitted attribute can be filled in with a default "empty" attribute. That leads to the following way to simulate multi-value attributes. It works on any component, although for simplicity we'll show it on classes.

#[\Attribute(Attribute::TARGET_CLASS)]
classNamesimplementsHasSubAttributes,IteratorAggregate,ArrayAccess
{
protectedreadonlyarray$names;

publicfunctionsubAttributes():array
{
return[Alias::class =>'fromAliases'];
}

publicfunctionfromAliases(array$aliases):void
{
$this->names=$aliases;
}

publicfunctiongetIterator():\ArrayIterator
{
returnnewArrayIterator($this->names);
}

publicfunctionoffsetExists(mixed$offset):bool
{
returnarray_key_exists($offset,$this->names);
}

publicfunctionoffsetGet(mixed$offset):Alias
{
return$this->names[$offset];
}

publicfunctionoffsetSet(mixed$offset,mixed$value):void
{
thrownewInvalidArgumentException();
}

publicfunctionoffsetUnset(mixed$offset):void
{
thrownewInvalidArgumentException();
}
}

#[\Attribute(Attribute::TARGET_CLASS|Attribute::IS_REPEATABLE)]
classAliasimplementsMultivalue
{
publicfunction__construct(
publicreadonlystring$first,
publicreadonlystring$last,
) {}

publicfunctionfullName():string
{
return"$this->first$this->last";
}
}

#[Alias(first:'Bruce',last:'Wayne')]
#[Alias(first:'Bat',last:'Man')]
classHero
{
//...
}

$names=$analyzer->analyze(Hero::class,Names::class);

foreach($namesas$name) {
print$name->fullName().PHP_EOL;
}

// Output:
BruceWayne
BatMan

TheIteratorAggregateandArrayAccessinterfaces are optional; I include them here just to show that you can do it if you want. Here, theNamesattribute is never put on a class directly. However, by analyzing a class "with respect to"Names,you can collect all the multi-value sub-attributes that it has, giving the impression of a multi-value attribute.

Note thatAliasneeds to implementMultivalueso the analyzer knows to expect more than one of them.

Interface attributes

Normally, attributes do not inherit. That means an attribute on an interface has no bearing on classes that implement that interface. However, attributes may opt-in to inheriting via the Analzyer.

A good use for that is sub-attributes, which may also be specified as an interface. For example, consider this modified version of the example above:

#[\Attribute(\Attribute::TARGET_CLASS)]
classNamesimplementsHasSubAttributes,IteratorAggregate,ArrayAccess
{
protectedreadonlyarray$names;

publicfunctionsubAttributes():array
{
return[Name::class =>'fromNames'];
}

publicfunctionfromNames(array$names):void
{
$this->names=$names;
}

// The same ArrayAccess and IteratorAggregate code as above.
}

interfaceNameextendsMultivalue
{
publicfunctionfullName():string;
}

#[\Attribute(\Attribute::TARGET_CLASS)]
classRealNameimplementsName
{
publicfunction__construct(
publicreadonlystring$first,
publicreadonlystring$last,
) {}

publicfunctionfullName():string
{
return"$this->first$this->last";
}
}

#[\Attribute(\Attribute::TARGET_CLASS| \Attribute::IS_REPEATABLE)]
classAliasimplementsName
{
publicfunction__construct(publicreadonlystring$name) {}

publicfunctionfullName():string
{
return$this->name;
}
}

#[RealName(first:'Bruce',last:'Wayne')]
#[Alias('Batman')]
#[Alias('The Dark Knight')]
#[Alias('The Caped Crusader')]
classHero
{
//...
}

You can now mix and matchRealNameandAliason the same class. Only oneRealNameis allowed, but any number ofAliasattributes are allowed. All areNameaccording to theNamesmain attribute, and so all will get picked up and made available.

Note that the interface must be markedMultivalueso thatAnalyzerwill allow more than one attribute of that type. However, theRealNameattribute is not marked as repeatable, so PHP will prevent more than oneRealNamebeing used at once whileAliasmay be used any number of times.

One of many options

In a similar vein, it's possible to use sub-attributes to declare that a component may be marked with one of a few attributes, but only one of them.

interfaceDisplayType{}

#[\Attribute(\Attribute::TARGET_CLASS)]
classScreenimplementsDisplayType
{
publicfunction__construct(publicreadonlystring$color) {}
}

#[\Attribute(\Attribute::TARGET_CLASS)]
classAudioimplementsDisplayType
{
publicfunction__construct(publicreadonlyint$volume) {}
}

#[\Attribute(Attribute::TARGET_CLASS)]
classDisplayInfoimplementsHasSubAttributes
{
publicreadonly?DisplayType$type;

publicfunctionsubAttributes():array
{
return[DisplayType::class =>$this->fromDisplayType(...)];
}

publicfunctionfromDisplayType(?DisplayType$type):void
{
$this->type=$type;
}
}

#[Screen('#00AA00')]
classA{}

#[Audio(10)]
classB{}

classC{}

$displayInfoA=$analyzer->analzyer(A::class,DisplayInfo::class);
$displayInfoB=$analyzer->analzyer(B::class,DisplayInfo::class);
$displayInfoC=$analyzer->analzyer(C::class,DisplayInfo::class);

In this case, a class may be marked with eitherScreenorAudio,but not both. If both are specified, only the first one listed will be used; the others will be ignored.

In this example,$displayInfoA->typewill be an instance ofScreen,$displayInfoB->typewill be an instance ofAudio,and$displayInfoC->typewill benull.

Change log

Please seeCHANGELOGfor more information on what has changed recently.

Testing

$ composertest

Contributing

Please seeCONTRIBUTINGandCODE_OF_CONDUCTfor details.

Security

If you discover any security related issues, please use theGitHub security reporting formrather than the issue queue.

Credits

Initial development of this library was sponsored byTYPO3 GmbH.

License

The Lesser GPL version 3 or later. Please seeLicense Filefor more information.

About

Utilities to help ease parsing and manging of attributes

Resources

License

Code of conduct

Security policy

Stars

Watchers

Forks

Sponsor this project

Packages

No packages published

Languages