PhpDev.App
olvlvl/composer-attribute-collector

olvlvl/composer-attribute-collector

Stars: 111

Forks: 3

Pull Requests: 13

Issues: 10

Watchers: 4

Last Updated: 2023-09-17 17:53:23

A convenient and near zero-cost way to retrieve targets of PHP 8 attributes

License: Other

Languages: PHP, Dockerfile, Makefile

composer-attribute-collector

Packagist Code Quality Code Coverage Downloads

composer-attribute-collector is a plugin for Composer. Its ambition is to provide a convenient and near zero-cost way to retrieve targets of PHP 8 attributes. In order to do that, after the autoloader has been dumped, the plugin collects attribute targets and generates a static file. Later, these targets can be retrieved through a convenient interface.

Features

  • Zero configuration
  • No reflection in the generated file
  • No impact on performance
  • No dependency (except Composer of course)
  • A single interface to get attribute targets: classes, methods, and properties
  • 3 types of cache speed up generation by limiting updates to changed files

Usage

The following example demonstrates how targets and their attributes can be retrieved:

<?php

use olvlvl\ComposerAttributeCollector\Attributes;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Routing\Annotation\Route;
use Doctrine\ORM\Mapping\Column;

require_once 'vendor/autoload.php';
require_once 'vendor/attributes.php'; // <-- the file created by the plugin

// Find the target classes of the AsMessageHandler attribute.
foreach (Attributes::findTargetClasses(AsMessageHandler::class) as $target) {
    // $target->attribute is an instance of the specified attribute
    // with the actual data.
    var_dump($target->attribute, $target->name);
}

// Find the target methods of the Route attribute.
foreach (Attributes::findTargetMethods(Route::class) as $target) {
    var_dump($target->attribute, $target->class, $target->name);
}

// Find the target properties of the Column attribute.
foreach (Attributes::findTargetProperties(Column::class) as $target) {
    var_dump($target->attribute, $target->class, $target->name);
}

// Filter target methods using a predicate.
// This is also available for classes and properties.
foreach (Attributes::filterTargetMethods(
    fn($attribute) => is_a($attribute, Route::class, true)
) as $target) {
    var_dump($target->attribute, $target->class, $target->name);
}

// Find class, method, and property attributes for the ArticleController class.
$attributes = Attributes::forClass(ArticleController::class);

var_dump($attributes->classAttributes);
var_dump($attributes->methodsAttributes);
var_dump($attributes->propertyAttributes);

Installation

composer require olvlvl/composer-attribute-collector

The plugin is currently experimental and its interface subject to change. Also, it only supports class and method targets. Please contribute if you're interested in shaping its future.

Note: The plugin creates a .composer-attribute-collector directory to store caches, you might want to add it to your .gitignore file.

Frequently Asked Questions

Do I need to generate an optimized autoloader?

You don't need to generate an optimized autoloader for this to work. The plugin uses code similar to Composer to find classes. Anything that works with Composer should work with the plugin.

Can I use the plugin during development?

Yes, you can use the plugin during development, but keep in mind the attributes file is only generated after the autoloader is dumped. If you modify attributes you'll have to run composer dump to refresh the attributes file.

As a workaround you could have watchers on the directories that contain classes with attributes to run XDEBUG_MODE=off composer dump when you make changes. PhpStorm offers file watchers. You could also use spatie/file-system-watcher, it only requires PHP.

Autoloading

You can require the attributes file as shown in the usage example, but it's preferable to use Composer's autoloading feature:

{
  "autoloading": {
    "files": [
      "vendor/attributes.php"
    ]
  }
}

Configuration

Ignoring paths (root-only)

composer-attribute-collector inspects files that participate in the autoload process. This can cause issues with files that have side effects. For instance, symfony/cache is known to cause issues, so we're excluding paths matching {vendor}/symfony/cache/Traits from inspection. Additional paths can be specified using the extra section of composer.json. The specified paths are relative to the composer.json file, and the {vendor} placeholder is replaced with the path to the vendor folder.

{
  "extra": {
    "composer-attribute-collector": {
      "ignore-paths": [
        "path/to/ignore"
      ]
    }
  }
}

For instance, if you are only concerned about the attributes of your own project, you could ignore the vendor directory:

{
  "extra": {
    "composer-attribute-collector": {
      "ignore-paths": [
        "vendor"
      ]
    }
  }
}

Test drive with a Symfony app

You can try the plugin with a fresh installation of Symfony.

Use the symfony command to create a new project. If you don't have it yet, you can download it.

symfony new --webapp my_project

Now get into that project and install the plugin. You'll be asked if you trust the plugin and wish to activate it. If you wish to continue, choose y.

cd my_project
composer require olvlvl/composer-attribute-collector

The plugin should have generated the file vendor/attributes.php. It should look something like this excerpt:

<?php

// attributes.php @generated by https://github.com/olvlvl/composer-attribute-collector

namespace olvlvl\ComposerAttributeCollector;

Attributes::with(fn () => new Collection(
    targetClasses: [
        \Symfony\Component\Console\Attribute\AsCommand::class => [
            [ ['lint:yaml', 'Lint a YAML file and outputs encountered errors'], \Symfony\Component\Yaml\Command\LintCommand::class ],
            [ ['server:dump', 'Start a dump server that collects and displays dumps in a single place'], \Symfony\Component\VarDumper\Command\ServerDumpCommand::class ],
            [ ['debug:validator', 'Display validation constraints for classes'], \Symfony\Component\Validator\Command\DebugCommand::class ],
            [ ['translation:pull', 'Pull translations from a given provider.'], \Symfony\Component\Translation\Command\TranslationPullCommand::class ],

We also have a repository to test the Symfony usecase.

Use cases

Get attributes without using reflection

The method forClass() returns the attributes attached to a class, without using reflection. This can improve the performance of your application if it relies on reflection on hot paths.

// Find attributes for the ArticleController class.
$attributes = Attributes::forClass(ArticleController::class);

var_dump($attributes->classAttributes);
var_dump($attributes->methodsAttributes);
var_dump($attributes->propertyAttributes);

A simpler way to configure your Dependency Injection Container

composer-attribute-collector can help simplify DIC (Dependency Injection Container) configuration. Long error-prone YAML can be completely replaced with attributes and a compiler pass to use them. You can still support both YAML and attributes, the "attribute" compiler pass would just configure the services and tag them automatically.

For example, the package ICanBoogie/MessageBus offers PHP 8 attributes as an alternative to YAML.

services:
  Acme\MenuService\Application\MessageBus\CreateMenuHandler:
    tags:
    - name: message_bus.handler
      message: Acme\MenuService\Application\MessageBus\CreateMenu
    - name: message_bus.permission
      permission: is_admin
    - name: message_bus.permission
      permission: can_write_menu

  Acme\MenuService\Application\MessageBus\DeleteMenuHandler:
    tags:
    - name: message_bus.handler
      message: Acme\MenuService\Application\MessageBus\DeleteMenu
    - name: message_bus.permission
      permission: is_admin
    - name: message_bus.permission
      permission: can_manage_menu

  Acme\MenuService\Presentation\Security\Voters\IsAdmin:
      tags:
      - name: message_bus.voter
        permission: is_admin

  Acme\MenuService\Presentation\Security\Voters\CanWriteMenu:
      tags:
      - name: message_bus.voter
        permission: can_write_menu

  Acme\MenuService\Presentation\Security\Voters\CanManageMenu:
      tags:
      - name: message_bus.voter
        permission: can_manage_menu
<?php

// ...

final class Permissions
{
    public const IS_ADMIN = 'is_admin';
    public const CAN_WRITE_MENU = 'can_write_menu';
    public const CAN_MANAGE_MENU = 'can_manage_menu';
}

// ...

use ICanBoogie\MessageBus\Attribute\Permission;

#[Permission(Permissions::IS_ADMIN)]
#[Permission(Permissions::CAN_WRITE_MENU)]
final class CreateMenu
{
    public function __construct(
        public readonly array $payload
    )// ...
}

// ...

use ICanBoogie\MessageBus\Attribute\Handler;

#[Handler]
final class CreateMenuHandler
{
    public function __invoke(CreateMenu $message)// ...
}

// ...

use ICanBoogie\MessageBus\Attribute\Vote;

#[Vote(Permissions::IS_ADMIN)]
final class IsAdmin implements Voter
{
    // ...
}

Configure components from attributes

Using attributes simplifies configuration, placing definition closer to the code, where it's used. ICanBoogie's router can be configured automatically from attributes. The following example demonstrates how the Route attribute can be used at the class level to define a prefix for the route attributes such as Get that are used to tag actions. Action identifiers can be inferred from the controller class and the method names e.g. skills:list.

<?php

// …

#[Route('/skills')]
final class SkillController extends ControllerAbstract
{
    #[Post]
    private function create(): void
    {
        // …
    }

    #[Get('.html')]
    private function list(): void
    {
        // …
    }

    #[Get('/summonable.html')]
    private function summonable(): void
    {
        // …
    }

    #[Get('/learnable.html')]
    private function learnable(): void
    {
        // …
    }

    #[Get('/:slug.html')]
    private function show(string $slug): void
    {
        // …
    }
}

Because the Get and Post attributes extend Route, all action methods can be retrieved with the filterTargetMethods() method.

/** @var TargetMethod<Route>[] $target_methods */
$target_methods = Attributes::filterTargetMethods(
    Attributes::predicateForAttributeInstanceOf(Route::class)
);

Now then, configuring the router looks as simple as this:

<?php

use ICanBoogie\Binding\Routing\ConfigBuilder;

/* @var ConfigBuilder $config */

$config->from_attributes();

Using Attributes

Filtering target methods

filterTargetMethods() can filter target methods using a predicate. This can be helpful when a number of attributes extend another one, and you are interested in collecting any instance of that attribute. The filerTargetClasses() and filterTargetProperties() methods provide similar feature for classes and properties.

Let's say we have a Route attribute extended by Get, Post, Put

<?php

use olvlvl\ComposerAttributeCollector\Attributes;

/** @var TargetMethod<Route>[] $target_methods */
$target_methods = [
    ...Attributes::findTargetMethods(Get::class),
    ...Attributes::findTargetMethods(Head::class),
    ...Attributes::findTargetMethods(Post::class),
    ...Attributes::findTargetMethods(Put::class),
    ...Attributes::findTargetMethods(Delete::class),
    ...Attributes::findTargetMethods(Connect::class),
    ...Attributes::findTargetMethods(Options::class),
    ...Attributes::findTargetMethods(Trace::class),
    ...Attributes::findTargetMethods(Patch::class),
    ...Attributes::findTargetMethods(Route::class),
];

// Can be replaced by:

/** @var TargetMethod<Route>[] $target_methods */
$target_methods = Attributes::filterTargetMethods(
    Attributes::predicateForAttributeInstanceOf(Route::class)
);

Continuous Integration

The project is continuously tested by GitHub actions.

Tests Static Analysis Code Style

Code of Conduct

This project adheres to a Contributor Code of Conduct. By participating in this project and its community, you are expected to uphold this code.

Contributing

Please see CONTRIBUTING for details.

License

olvlvl/composer-attribute-collector is released under the BSD-3-Clause.