With the introduction of PHP 8, attributes (also known as annotations) provide a way to add metadata to classes, methods, and properties in a structured way. This allows developers to define additional information about their code without needing to rely on comments or documentation.
Why attributes?
Reduced Boilerplate: Attributes eliminate repetitive code and simplify configurations.
Ease of Configuration: They embed metadata directly in code, making it easy to configure class, method and property behaviour.
Extensible: You can define custom attributes for related components.
Reflection Support: Attributes can be dynamically inspected at runtime.
Framework Integration: Modern frameworks like Laravel, Symfony, use attributes for easier configuration. For example, Laravel uses AsCommand Attribute to register commands. In other languages Attributes can be seen as Decorators.
In this two-part article, we will explore how to create a custom attribute for event listeners in Laravel, showcasing a practical implementation that leverages PHP attributes for registering Event listeners.
Note: Laravel already handles Listeners scanning and registration with appropriate event class by default. This is just showing another way it can be done via Attributes.
Setting Up a Custom Attribute
Creating the ListensTo Attribute Class
To begin, we will create a custom attribute class named ListensTo. This class will be used to define which events a particular listener class is interested in.
<?php
namespace App\Support\Attributes;
use Attribute;
use Illuminate\Support\Collection;
use InvalidArgumentException;
#[Attribute(Attribute::TARGET_CLASS)]
class ListensTo
{
/**
* @param class-string<string>|array<int, class-string<string>> $events
*/
public function __construct(private string|array $events)
{
if (is_string($this->events)){
$this->events = [$this->events];
}
$this->validateEvents();
}
private function validateEvents(): void
{
foreach ($this->events as $event) {
if (! class_exists($event)) {
throw new InvalidArgumentException("Could not find event class: {$event}");
}
}
}
public function getEvents(): Collection
{
return collect($this->events);
}
}
Explanation of the ListensTo Class
The ListensTo class:
Uses the #[Attribute(Attribute::TARGET_CLASS)] attribute to specify that it can only be applied to classes.
Accepts one or more event class names as a parameter.
Validates the provided event classes to ensure they exist, throwing an InvalidArgumentException if any class does not exist.
Provides a method getEvents() that returns the events as a collection, which will be used later to register the listeners.
Implementing a Listener Factory
Next, we will implement a ListenerFactory class responsible for scanning directories for listener classes and registering them with the Laravel event dispatcher.
Creating the Listener Factory
<?php
declare(strict_types=1);
namespace App\Support;
use App\Support\Attributes\ListensTo;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionAttribute;
use ReflectionClass;
use SplFileInfo;
final class ListenerFactory {
public function register(): void
{
$paths = config('app.listener.paths');
collect($paths)
->flatMap(fn (string $path) => $this->fetchPHPFilesInPath($path)
)->each(function (SplFileInfo $file) {
$listener = $this->getListenerPathWithoutExtension($file);
$this->ensureClassExists($listener);
$events = $this->getListenedEvents($listener);
if ($events->isEmpty()){
return;
}
Event::listen($events->toArray(), $listener);
});
}
/**
* Recursively fetches PHP files from the specified path.
*
* @param string $path
* @return Collection<int, SplFileInfo>
*/
private function fetchPHPFilesInPath(string $path): Collection
{
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
return collect($iterator)
->reject(fn (SplFileInfo $file) => $this->shouldSkipFile($file));
}
private function shouldSkipFile(SplFileInfo $file): bool
{
return $file->isDir()
|| $this->isInSkippedPaths($file->getPath())
|| ! $this->isFilePHP($file);
}
private function isInSkippedPaths(string $path): bool
{
return in_array($path, config('app.listener.skip_paths'), true);
}
private function isFilePHP(SplFileInfo $file): bool
{
return $file->getExtension() === 'php';
}
private function ensureClassExists(string $listener): void
{
if (! class_exists($listener)) {
throw new Exception("Could not resolve event listener class: {$listener}");
}
}
private function getListenedEvents(string $classPath): Collection
{
$reflector = new ReflectionClass($classPath);
/** @var $attribute ReflectionAttribute */
$attribute = collect($reflector->getAttributes(ListensTo::class))->first();
return $attribute?->newInstance()->getEvents();
}
private function getListenerPathWithoutExtension(SplFileInfo $file): string
{
return Str::of($file->getFilename())
->replace('.php', '')
->prepend($this->getNamespace($file))
->replace('/', '\\')
->toString();
}
private function getNamespace(SplFileInfo $file): string
{
return Str::of($file->getPath())
->after(base_path().'/')
->studly()
->replace('/', '\\')
->append('\\')
->toString();
}
}
Explanation of the ListenerFactory Class
The ListenerFactory class performs several tasks:
Fetching PHP Files: It scans configured paths recursively for PHP files, skipping directories, skipped paths and non-PHP files.
Ensuring Class Existence: It checks that the listener classes exist before attempting to register them.
Registering Events: It retrieves the events from the listener classes using reflection API and registers them with Laravel’s event dispatcher.
There’s definitely ways to improve this, one way would be adding some caching to reduce the number of times this runs when Laravel is booting up the service provider.
Path Configuration for ListenerFactory
In config/app.php, add paths that should be scanned for listeners. As you can see the logic in the ListenerFactory can scan these files no matter the paths provided.
/**
* Listener paths
*/
'listener' => [
'paths' => [
app_path('Listeners'),
base_path('src/Domain/Payment/Listeners'),
],
'skip_paths' => [
app_path('Listeners/Traits'),
]
],
Conclusion
In this first part, we covered how to create a custom attribute and a listener factory for registering event listeners in a Laravel application. This approach allows for cleaner and easy to configure event and listeners leveraging the power of PHP attributes.
In the next part, we will look into creating event listeners using the ListensTo attribute, integrating them into our application, and discussing the benefits of this method. Stay tuned!