← Back to Blog

Discovery-Based Architecture in PHP

php laravel architecture

Decoupled matching via PHP Attributes and Reflection, explained with webhook event handlers.

Introduction

In this post I want to discuss a nice alternative for matching classes with a given string by using PHP Attributes and Reflection instead of the extensive use of if/match-statements. In my case the string is the event name and the classes are event handlers inside a webhook client integration.

It is completely fine to use if/match statements, if the drawbacks don't concern you. Later we will discuss that further.

Context

The examples in this article are based on Spatie's laravel-webhook-client package. It processes incoming webhooks via queued jobs, giving us two places to match events to handlers: in a request profiler (deciding whether to store the webhook) and in a processing job (deciding how to handle it).

Coupled Event Registration

This is where it all begins. What I often see here is the extensive use of if-statements, for example when deciding which event handler should now process the webhook call:

// ProcessWebhookJob – handle

$event = $this->webhookCall->payload['event'];

if ($event === 'invoice.paid') {
    app(PaidInvoice::class)->handle($this->webhookCall);
    return;
}

if ($event === '...') {
    // ...
    return;
}

// ...

And the list of if-statements goes on and on. Others prefer a match statement, which IMHO makes it look a little more organized:

// ProcessWebhookJob – handle

$event = $this->webhookCall->payload['event'];

match($event) {
    'invoice.paid' => app(PaidInvoice::class)->handle($this->webhookCall),
    // ...
};

The Problem

But in either way there is a major drawback to this approach: a very coupled registration to the processing job. And what I mean by that is, for having a new webhook event to work, you always need to extend the if/match statement in the processing job, as well as adding the event handler itself. The risk of missing the registration inside the processing job could lead to massive problems and such coupling makes it hard to adapt to modifications on the event handler's name or location.

You are totally fine by using this approach, if you have just a couple of events to handle – no pun intended.

Just keep in mind that when you want to check if a given event matches any event handler in the Webhook Profile you would need to abstract that if/match-statement somehow or organize this list in two places... which is suboptimal.

Decoupled Event Registration - Setup

Now let's try a different approach. What we want to achieve:

  1. No static list of events matching event handlers in the job
  2. A general approach to get the matching event handler in different places

So we want the processing job to search for any matching event handler for a given event (PHP Attribute) from a given type (Interface) in a given location.

1. Interface

Like any good abstraction, we start with an interface:

<?php

namespace App\Interfaces;

use Spatie\WebhookClient\Models\WebhookCall;

interface WebhookEventHandler
{
    /**
     * Handles the webhook call.
     *
     * @param WebhookCall $call
     * @return void
     */
    public function handle(WebhookCall $call): void;
}

This interface helps to identify if a given class is capable of handling webhooks and gives us an interface for knowing what method we can call from inside the processing job.

2. Attribute

Secondly, we add a PHP Attribute that we can use in the event handler class to define what webhook event it is responsible for:

<?php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
class Webhook
{
    public function __construct(
        public string $event
    ) {}
}

3. Service

Now we add a Service that helps us to identify the event handlers for any given event. And this is where all the "magic" happens:

<?php

namespace App\Services;

use App\Attributes\Webhook;
use App\Interfaces\WebhookEventHandler;
use Illuminate\Support\Facades\File;
use ReflectionClass;

class WebhookService
{
    /**
     * Finds matching event handlers based on the given event name.
     *
     * @param string $event
     * @return array all event handlers
     */
    public function getEventHandlers(string $event): array
    {
        $basePath = app_path('Actions/Webhooks');
        $namespace = 'App\\Actions\\Webhooks';
        $handlers = [];

        foreach (File::allFiles($basePath) as $file) {
            $relativePath = str_replace(
                [$basePath . '/', '.php'], '', $file->getPathname()
            );
            $className = $namespace . '\\' . str_replace('/', '\\', $relativePath);

            if (!class_exists($className)) {
                continue;
            }

            // 3. Is the given class capable of handling the webhook events?
            $reflection = new ReflectionClass($className);
            if (!$reflection->implementsInterface(WebhookEventHandler::class)) {
                continue;
            }

            // 4. Does it handle the given event?
            $attributes = $reflection->getAttributes(Webhook::class);
            foreach ($attributes as $attribute) {
                $webhook = $attribute->newInstance();

                if ($webhook->event === $event) {
                    $handlers[] = app($className);
                }
            }
        }

        return $handlers;
    }
}

So, what is happening here? The method defines the event name as parameter, so we can find the matching event handler to it.

  1. Where are my event handlers located? — In the first step we define where the event handlers are located. In my case I use the Action Pattern and have my specific event handlers under the App\Actions\Webhooks namespace.

  2. Find all event handlers — We use Laravel's File Facade for finding all classes under the given namespace.

  3. Is the given class capable of handling the webhook events? — Check if the class implements our WebhookEventHandler interface through PHP's ReflectionClass.

  4. Does it handle the given event? — Last but not least, we check via the reflection class if there is our Webhook Attribute defined in that class and if it matches our event.

Decoupled Event Handler Matching – Integration

Now we can put every piece together. Like we said in the beginning, we have two places where we want to match a given event to an event handler:

  1. On incoming requests — for checking if an event handler even exists for the given event
  2. In the processing job — for checking what event handler should handle the given webhook call

Request Profiler

The first thing is the incoming request. We need to implement the Webhook Profiler that processes the request and add the class to the config:

<?php

namespace App\Services;

use App\Interfaces\WebhookEventHandler;
use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookProfile\WebhookProfile as WebhookProfileInterface;

class WebhookProfile implements WebhookProfileInterface
{
    public function __construct(
        private WebhookService $service,
    ) {}

    public function shouldProcess(Request $request): bool
    {
        // check for event in payload
        $event = $request->input('event');
        if (!$event || !is_string($event)) {
            return false;
        }

        // search for matching event handlers
        $handlers = $this->service->getEventHandlers($event);
        if (empty($handlers)) {
            return false;
        }

        return true;
    }
}

In here we can use our created WebhookService and tell the overlaying process not to store the request as a WebhookCall if we don't find any event handler for the given event (return false).

Processing Job

Nearly the same happens now in the next step, when we create our processing Webhook Job: We search the event handlers with the given event name and return the found handlers. If none was found, we can throw an exception, since this indicates an issue with the Profiler.

<?php

namespace App\Jobs;

use App\Services\WebhookService;
use Exception;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;

class WebhookProcessor extends ProcessWebhookJob
{
    public function __construct(
        private WebhookService $service,
    ) {}

    public function handle(): void
    {
        $event = $this->webhookCall->payload['event'] ?? null;
        if (!$event || !is_string($event)) {
            throw new Exception("No event specified in the webhook payload.");
        }

        $handlers = $this->service->getEventHandlers($event);
        if (empty($handlers)) {
            throw new Exception("No handler found for event: {$event}");
        }

        foreach ($handlers as $handler) {
            $handler->handle($this->webhookCall);
        }
    }
}

As defined in the interface, we call the handler's handle method and transmit the WebhookCall model to it.

Event Handler

At the end, let's have a look at how the event handler looks like:

<?php

namespace App\Actions\Webhooks\Invoices;

use App\Interfaces\WebhookEventHandler;
use App\Attributes\Webhook;
use Spatie\WebhookClient\Models\WebhookCall;

#[Webhook('invoice.paid')]
class HandlePaidInvoice implements WebhookEventHandler
{
    public function handle(WebhookCall $call): void
    {
        // process the data inside the webhook call
    }
}

We see here the PHP Attribute and our Webhook Interface at work. In the handle method comes your logic for processing the instance of the stored WebhookCall.

Workflow

Every piece is explained. Now have a look at how they work together:

  1. An incoming webhook request comes in
  2. The profiler checks via our WebhookService if it can find matching event handlers
  3. If so, the request is stored as a WebhookCall
  4. And then processed by our Webhook Processor Job
  5. The job uses our WebhookService again for finding the matching event handler and runs the handle method with the WebhookCall

We can now add new webhook event handlers in our namespace and add the Webhook Attribute so they can be found and used from the processing job.

Bonus: Validation

In the case of webhook event handlers, I also added validation using Laravel's Validator, which can be called before processing and helps decide whether a webhook request should be stored or handled at all.

First, add a validate method to the interface:

// app/Interfaces/WebhookEventHandler.php

public function validate(array $data): array;

Instead of implementing the interface directly in each specific event handler, every handler extends an abstract base class that includes the validation method:

namespace App\Actions\Webhooks;

use App\Interfaces\WebhookEventHandler as WebhookEventHandlerInterface;
use Illuminate\Support\Facades\Validator;

abstract class WebhookEventHandler implements WebhookEventHandlerInterface
{
    abstract public function handle(WebhookCall $call): void;

    abstract protected function rules(): array;

    public function validate(array $data): array
    {
        return Validator::make($data, $this->rules())->validate();
    }
}

In the specific event handler, you additionally define which rules the payload content must satisfy:

namespace App\Actions\Webhooks\Invoices;

use App\Actions\Webhooks\WebhookEventHandler;
use App\Attributes\Webhook;
use App\Models\Invoice;
use Spatie\WebhookClient\Models\WebhookCall;

#[Webhook('invoice.paid')]
class HandlePaidInvoiceEvent extends WebhookEventHandler
{
    public function handle(WebhookCall $call): void
    {
        $data = $this->validate($call->payload);

        Invoice::firstWhere('number', $data['invoice_number'])
            ->update(['paid' => true]);
    }

    protected function rules(): array
    {
        return [
            'invoice_number' => ['required', 'string', 'exists:invoices,number'],
        ];
    }
}

The validate method can be used directly inside the handle method as shown above, or in the request profiler to filter out requests before they are even stored:

$handlers = $this->service->getEventHandlers($event);

if (empty($handlers)) {
    Log::error('WebhookProcessor: No handler found for event.', [
        'event' => $event,
        'payload' => $request->all(),
    ]);
    return false;
}

foreach ($handlers as $handler) {
    try {
        $handler->validate($request->all());
    } catch (ValidationException $e) {
        Log::error('WebhookProcessor: Validation failed for handler.', [
            'handler' => get_class($handler),
            'event' => $event,
            'error' => $e->getMessage(),
            'payload' => $request->all(),
        ]);
    }
}

This way, requests whose content doesn't validate against the matching event handler can be filtered out before the webhook call is even stored.

  • High-frequency use cases in the millisecond range, where events need to be processed in real-time and in large volumes — Reflection-based lookups (without caching) can become bottlenecks. Example: high-frequency telemetry data processing or trading engines.
  • Performance-critical loops, e.g. when many event types need to be resolved dynamically within a loop — Reflection is too time- and resource-intensive here and could be replaced by pre-built static mappings.
  • Simple, small projects with only 2–3 event types — The additional complexity of Attributes, Interfaces, and Discovery logic is unnecessary. A match statement is perfectly sufficient.
  • Projects without Composer autoload discipline or with inconsistent directory structures — Since Discovery often relies on PSR-4 autoloading, a clean namespace and class structure is a prerequisite for this pattern.

TL;DR

Instead of maintaining growing if/match blocks to route events to handlers, use PHP Attributes to tag handler classes with the events they handle, an Interface to guarantee a common contract, and a Service that uses Reflection to auto-discover matching handlers at runtime. The result: each new event handler is just a new class — no registration step needed, no central routing file to maintain, and the same discovery logic can be reused across your request profiler, processing job, or anywhere else you need it.