Fallback Image

Discovery-Based Architecture in PHP

Clean integration with PHP attribute and interface, explained using webhook event handlers.

The more complex a PHP application becomes, the more often the question arises: How do I route dynamic inputs (e.g., events, commands, types) to the right logic?

Common scenarios include:

  • Webhooks
  • Command dispatching
  • Event-driven systems
  • Plugin or module architectures

Most developers start out with if-/match-blocks. That works -  but it doesn’t scale well. In this article, I’ll show you how to build a flexible, decoupled structure using a PHP attribute and an interface - based on automatic discovery, demonstrated through the example of a webhook client integration.

💡 Heads up, it’s about to get techy!

Our PHP developer shows you how to handle webhook events in PHP in an elegant and scalable way – a very technical topic. Perfect for you if you want to free your event logic from rigid if-statements and explore smart discovery patterns.

The problem

To assign incoming webhook requests to the appropriate event handler based on the attached event, it’s easy to fall back on using if-statements:

A simpler version of this can, since PHP 8, also be implemented using a match statement:

But this quickly becomes messy:

  • New events bloat the code
  • Growing risk of overlooking events
  • Central logic becomes hard to maintain
  • Extensions by third parties are almost impossible

Discovery-based integration

Instead of linking events to event handlers via if-chains or long match statements, we want to:

1. Annotate handler classes with an attribute (e.g., #[Webhook('event.name')])

2. Implement a common interface

3. Recursively scan a defined directory or namespace

4. Automatically locate and invoke the appropriate handler

This pattern doesn’t just work for webhooks — it can also be applied to:

  • CLI commands
  • Domain events
  • Feature modules
  • Plugin systems

Step-by-step: Implementing the pattern

1. Define the attribute

This allows classes to be tagged with a specific event name. These classes are then responsible for handling that event.

2. Create a common interface

This ensures that all handlers share the same signature and can be invoked in a generic way.

3. Write an event-specific handler class

Now let’s look at the actual event handler class that processes a given event. Here’s an example of what such a class might look like:

Each class:

  • Focuses on a single event
  • Is easy to test
  • Can be developed and deployed independently
  • Can be pulled in from third-party packages (e.g., by extending the list of namespaces for automatic discovery — see the next chapter — or by having packages publish the classes into the existing namespace)
  • Avoids the risk of forgetting registration or wiring the event incorrectly

4. Implement the service

The core is a service that uses reflection to discover the appropriate classes:

5. Use in a job or controller

Whether only the first matching event handler or all discovered handlers should be executed is up to each developer to decide. In this example, all matching event handlers are executed.

The advantages

Modular: Each handler is a standalone class

Scalable: New events = new file, no changes needed to the global call

Testable: Handlers can be tested in isolation

Decoupled: No centralized routing logic

Dynamic: Events and handlers can be discovered at runtime

Reusability

You can apply this principle to any kind of dispatch logic:

  • CLI commands (e.g., #[Command('sync:users')])
  • Domain events (#[ListensTo(UserRegistered::class)])
  • Feature flags
  • Background jobs
  • Plugin routing

The rule of thumb is always the same:

Combine attributes + interface + discovery = maximum flexibility

Extensible

  • Caching for reflection results (getEventHandlers() → cache()->remember(...))
  • Support for wildcards (invoice.*)
  • Multiple handlers per event (broadcasting)
  • Invocation via __invoke() instead of handle()
  • Optionally: outsource the scanning process into an Artisan command that generates the mapping file during deployment (e.g., via CI/CD)
  • If you want to extend Laravel’s discovery, you could even turn it into a service provider pattern

Validation

In the case of the webhook event handler, I added a validation step using Laravel’s validation class. This can be executed before processing and helps decide whether the webhook request should be stored or handled at all.

The first step is to define a 'validate' method in the interface:

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

In the specific event handler, you additionally define which rules the contents of the payload must follow:

The 'validate' method can, as shown here, be used directly inside the handler method or wherever incoming webhook requests are processed — for example, like this:

This way, requests whose payloads could not be validated can already be filtered out before the webhook request is stored - as intended in the webhook profile of Spatie’s Laravel Webhook Client package.

Conclusion

The combination of PHP attributes, interfaces, and discovery via reflection offers an elegant alternative to traditional if-/match constructs. Especially in dynamic systems such as webhook integrations, plugins, or event handling, this pattern is:

  • clearly structured
  • easy to maintain
  • naturally open for extensions
  • High-frequency use cases in the millisecond range, where events need to be processed in real time and in large volumes. In such cases, reflection-based lookups (without caching) can become a bottleneck.
    Example: high-frequency telemetry data processing or trading engines.

  • Simple, small projects with only 2–3 event types. In these cases, the added complexity of attributes, interfaces, and discovery logic is unnecessary. A match statement is more than sufficient.

  • Projects without Composer autoload discipline or with an inconsistent directory structure. Since discovery often relies on PSR-4 autoloading, a clean namespace and class structure is a prerequisite for this pattern.

Further reading


Become part of our Laravel community

and discover our Meetups.

To the Meetup