← Zurück zum Blog

Discovery-Based Architecture in PHP

php laravel architektur

Entkoppeltes Matching mit PHP Attributen und Reflection, erklärt anhand von Webhook Event Handlers.

Einleitung

In diesem Beitrag möchte ich eine elegante Alternative vorstellen, wie man Klassen anhand eines Strings zuordnen kann – mit PHP Attributen und Reflection statt endloser if/match-Statements. In meinem Fall ist der String der Event-Name und die Klassen sind Event-Handler innerhalb einer Webhook-Client-Integration.

Es ist vollkommen in Ordnung, if/match-Statements zu verwenden, wenn die Nachteile nicht ins Gewicht fallen. Später gehen wir darauf noch genauer ein.

Kontext

Die Beispiele in diesem Artikel basieren auf Spaties laravel-webhook-client Package. Es verarbeitet eingehende Webhooks über queued Jobs, was uns zwei Stellen gibt, an denen wir Events Handlern zuordnen können: in einem Request Profiler (Entscheidung, ob der Webhook gespeichert wird) und in einem Processing Job (Entscheidung, wie er verarbeitet wird).

Gekoppelte Event-Registrierung

Hier beginnt es. Was ich oft sehe, ist die exzessive Nutzung von If-Statements, zum Beispiel bei der Entscheidung, welcher Event-Handler den Webhook-Call verarbeiten soll:

// ProcessWebhookJob – handle

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

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

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

// ...

Und die Liste der If-Statements wird immer länger. Andere bevorzugen ein Match-Statement, was meiner Meinung nach etwas übersichtlicher aussieht:

// ProcessWebhookJob – handle

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

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

Das Problem

In beiden Fällen gibt es einen großen Nachteil: eine sehr gekoppelte Registrierung an den Processing Job. Damit ein neues Webhook-Event funktioniert, muss man immer das if/match-Statement im Processing Job erweitern und zusätzlich den Event-Handler selbst erstellen. Das Risiko, die Registrierung im Processing Job zu vergessen, kann zu massiven Problemen führen – und die enge Kopplung erschwert Anpassungen am Namen oder Speicherort der Event-Handler.

Dieser Ansatz ist völlig in Ordnung, wenn man nur eine Handvoll Events zu verarbeiten hat.

Nur: Wenn man zusätzlich im Webhook-Profiler prüfen möchte, ob ein Event überhaupt einem Handler zugeordnet werden kann, müsste man das if/match-Statement irgendwie abstrahieren oder die Liste an zwei Stellen pflegen... was suboptimal ist.

Entkoppelte Event-Registrierung - Setup

Versuchen wir einen anderen Ansatz. Was wir erreichen wollen:

  1. Keine statische Liste von Events und Event-Handlern im Job
  2. Einen generellen Ansatz, um den passenden Event-Handler an verschiedenen Stellen zu finden

Der Processing Job soll also nach einem passenden Event-Handler für ein gegebenes Event (PHP Attribut) eines bestimmten Typs (Interface) an einem bestimmten Ort suchen.

1. Interface

Wie bei jeder guten Abstraktion beginnen wir mit einem Interface:

<?php

namespace App\Interfaces;

use Spatie\WebhookClient\Models\WebhookCall;

interface WebhookEventHandler
{
    /**
     * Verarbeitet den Webhook-Call.
     *
     * @param WebhookCall $call
     * @return void
     */
    public function handle(WebhookCall $call): void;
}

Dieses Interface hilft zu erkennen, ob eine gegebene Klasse in der Lage ist, Webhooks zu verarbeiten, und gibt uns eine Schnittstelle, über die wir wissen, welche Methode wir aus dem Processing Job aufrufen können.

2. Attribut

Als Zweites fügen wir ein PHP Attribut hinzu, das wir in der Event-Handler-Klasse verwenden können, um festzulegen, für welches Webhook-Event sie zuständig ist:

<?php

namespace App\Attributes;

use Attribute;

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

3. Service

Nun fügen wir einen Service hinzu, der uns hilft, die Event-Handler für ein gegebenes Event zu identifizieren. Hier passiert die ganze "Magie":

<?php

namespace App\Services;

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

class WebhookService
{
    /**
     * Findet passende Event-Handler anhand des gegebenen Event-Namens.
     *
     * @param string $event
     * @return array alle Event-Handler
     */
    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. Kann die Klasse Webhook-Events verarbeiten?
            $reflection = new ReflectionClass($className);
            if (!$reflection->implementsInterface(WebhookEventHandler::class)) {
                continue;
            }

            // 4. Passt sie zum gegebenen Event?
            $attributes = $reflection->getAttributes(Webhook::class);
            foreach ($attributes as $attribute) {
                $webhook = $attribute->newInstance();

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

        return $handlers;
    }
}

Was passiert hier? Die Methode definiert den Event-Namen als Parameter, damit wir den passenden Event-Handler dazu finden können.

  1. Wo befinden sich meine Event-Handler? — Im ersten Schritt definieren wir, wo die Event-Handler liegen. In meinem Fall nutze ich das Action Pattern und habe meine spezifischen Event-Handler unter dem App\Actions\Webhooks Namespace.

  2. Alle Event-Handler finden — Wir nutzen Laravels File Facade, um alle Klassen unter dem gegebenen Namespace zu finden.

  3. Kann die Klasse Webhook-Events verarbeiten? — Wir prüfen über PHPs ReflectionClass, ob die Klasse unser WebhookEventHandler Interface implementiert.

  4. Passt sie zum gegebenen Event? — Zu guter Letzt prüfen wir über die Reflection-Klasse, ob unser Webhook-Attribut in der Klasse definiert ist und ob es zu unserem Event passt.

Entkoppeltes Event-Handler-Matching – Integration

Jetzt können wir alle Teile zusammensetzen. Wie am Anfang beschrieben, haben wir zwei Stellen, an denen wir ein Event einem Event-Handler zuordnen wollen:

  1. Bei eingehenden Requests — um zu prüfen, ob überhaupt ein Event-Handler für das Event existiert
  2. Im Processing Job — um zu prüfen, welcher Event-Handler den gegebenen Webhook-Call verarbeiten soll

Request Profiler

Zunächst der eingehende Request. Wir müssen den Webhook-Profiler implementieren, der den Request prüft, und die Klasse in die Konfiguration eintragen:

<?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
    {
        // Prüfe auf Event im Payload
        $event = $request->input('event');
        if (!$event || !is_string($event)) {
            return false;
        }

        // Suche passende Event-Handler
        $handlers = $this->service->getEventHandlers($event);
        if (empty($handlers)) {
            return false;
        }

        return true;
    }
}

Hier können wir unseren erstellten WebhookService nutzen und dem übergeordneten Prozess mitteilen, den Request nicht als WebhookCall zu speichern, wenn wir keinen Event-Handler für das gegebene Event finden (return false).

Processing Job

Fast das Gleiche passiert im nächsten Schritt, wenn wir unseren verarbeitenden Webhook-Job erstellen: Wir suchen die Event-Handler mit dem gegebenen Event-Namen und geben die gefundenen Handler zurück. Wird keiner gefunden, können wir eine Exception werfen, da dies auf ein Problem mit dem Profiler hindeutet.

<?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);
        }
    }
}

Wie im Interface definiert, rufen wir die handle-Methode des Handlers auf und übergeben das WebhookCall-Model.

Event Handler

Zum Schluss schauen wir uns an, wie der Event-Handler aussieht:

<?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
    {
        // Daten aus dem Webhook-Call verarbeiten
    }
}

Hier sehen wir das PHP Attribut und unser Webhook Interface im Einsatz. In der handle-Methode kommt die Logik zur Verarbeitung der gespeicherten WebhookCall-Instanz.

Workflow

Alle Teile sind erklärt. Jetzt schauen wir uns an, wie sie zusammenspielen:

  1. Ein eingehender Webhook-Request kommt rein
  2. Der Profiler prüft über unseren WebhookService, ob er passende Event-Handler finden kann
  3. Wenn ja, wird der Request als WebhookCall gespeichert
  4. Und dann von unserem Webhook Processor Job verarbeitet
  5. Der Job nutzt unseren WebhookService erneut, um den passenden Event-Handler zu finden und die handle-Methode mit dem WebhookCall aufzurufen

Wir können jetzt neue Webhook Event Handler in unserem Namespace anlegen und das Webhook-Attribut hinzufügen, damit sie vom Processing Job gefunden und genutzt werden können.

Bonus: Validierung

Im Fall der Webhook Event Handler habe ich zusätzlich eine Validierung anhand Laravels Validator hinzugefügt, die vor der Verarbeitung aufgerufen werden kann und mitentscheidet, ob ein Webhook-Request gespeichert bzw. verarbeitet werden soll.

Zuerst wird dem Interface eine validate-Methode hinzugefügt:

// app/Interfaces/WebhookEventHandler.php

public function validate(array $data): array;

Statt das Interface direkt in jedem spezifischen Event-Handler zu implementieren, erbt jeder Handler von einer abstrakten Basisklasse, die die Validation-Methode enthält:

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();
    }
}

Im spezifischen Event-Handler wird zusätzlich definiert, welche Regeln der Inhalt des Payloads einhalten muss:

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'],
        ];
    }
}

Die Validate-Methode kann, wie hier gezeigt, direkt in der Handle-Methode verwendet werden oder im Request-Profiler, um Requests schon vor dem Speichern zu filtern:

$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(),
        ]);
    }
}

So können Requests, deren Inhalt nicht gegen den passenden Event-Handler validiert werden konnte, schon vor dem Speichern des Webhook-Calls herausgefiltert werden.

Wann nicht empfehlenswert

  • High-Frequency-Usecases im Millisekundenbereich, bei denen Events in Echtzeit und in großer Menge verarbeitet werden müssen — Die Reflection-basierten Lookups (ohne Caching) können zu Engpässen führen. Beispiel: Hochfrequente Telemetrie-Datenverarbeitung oder Trading-Engines.
  • Performance-kritische Schleifen, z. B. wenn innerhalb einer Schleife viele Eventtypen dynamisch aufgelöst werden müssten — Hier ist Reflection zu zeit- und ressourcenintensiv und könnte durch vorherige statische Mappings ersetzt werden.
  • Einfache, kleine Projekte, bei denen nur 2–3 Eventtypen existieren — Die zusätzliche Komplexität durch Attribute, Interfaces und Discovery-Logik ist unnötig. Ein match-Statement ist hier vollkommen ausreichend.
  • Projekte ohne Composer-Autoload-Disziplin oder mit inkonsistenter Verzeichnisstruktur — Da die Discovery oft auf dem PSR-4-Autoload basiert, ist ein sauberer Namespace- und Klassenaufbau Voraussetzung für das Pattern.

TL;DR

Statt wachsende if/match-Blöcke zu pflegen, um Events an Handler weiterzuleiten, nutze PHP Attribute, um Handler-Klassen mit den Events zu markieren, die sie verarbeiten, ein Interface, um einen gemeinsamen Vertrag zu garantieren, und einen Service, der per Reflection passende Handler zur Laufzeit automatisch erkennt. Das Ergebnis: Jeder neue Event-Handler ist einfach eine neue Klasse — kein Registrierungsschritt nötig, keine zentrale Routing-Datei zu pflegen, und die gleiche Discovery-Logik kann im Request-Profiler, Processing Job oder überall sonst wiederverwendet werden.