A high-performance, context-driven event routing engine.
Designed specifically for applications that ingest semi-structured payload streams-such as Telegram/Discord/Slack bots, incoming webhook pipelines, WebSocket microservices, and event-driven backends-it replaces brittle if/else and switch statements with an expressive, Laravel-inspired routing syntax over arbitrary associative arrays and JSON payloads.
With Reactor, you define declarative routes with pattern matching, dot-notation lookups, regex constraints, onion-layer middleware pipelines, hierarchical nested groups, and automatic reflection-based dependency injection.
- Use Cases and Practical Applications
- Installation and Requirements
- Core Architecture
- Context Deep Dive
- Condition Matching Engine
- Route Templates and Placeholders
- Parameter Constraints (where)
- Raw Regular Expressions (PCRE)
- Custom Pattern Matchers
- Handler Formats and Dependency Injection
- Middleware Pipeline
- Hierarchical Route Groups
- Execution Flow and Propagation Control
- Dispatch Modes: dispatch() vs run()
- Complete API Reference
- Running Tests
- License
- Telegram, Discord & Slack Bots: Cleanly route incoming messages, button callbacks (
callback_query.data), inline queries, and multi-step conversation states based on nested JSON keys without nestedif/switchblocks. - Webhook Ingestion Gateways: Ingest webhooks from Stripe, GitHub, Shopify, or PayPal, routing events directly to specialized controllers based on payload fields like
event.type,action, orstatus. - Event-Driven Architectures & CQRS: Dispatch internal domain events and async message-bus envelopes (RabbitMQ, Redis Streams, Kafka) by inspecting envelope metadata and payload shape.
- JSON-RPC & Microservice Routers: Route internal RPC requests based on body fields (
method,params.action,version) rather than relying on HTTP URIs. - State Machine & Workflow Routing: Inspect context state flags and execute transition handlers when matching specific state configurations.
Cleanly route incoming messages and button callbacks without nested if/switch blocks.
// Route a specific admin command in a supergroup
$dispatcher->on([
'message.chat.type' => 'supergroup',
'message.text' => '/ban {username}'
], function (string $username, Context $ctx) {
$chatId = $ctx->get('message.chat.id');
// Execute the business logic (no return needed)
TelegramApi::kickChatMember($chatId, $username);
Logger::info("User @{$username} was banned in chat {$chatId}");
});Ingest webhooks from third-party services and route them directly to specialized logic based on payload fields.
// Handle a successful payment event from a Stripe webhook
$dispatcher->on([
'type' => 'payment_intent.succeeded',
'data.object.status' => 'succeeded'
], function (Context $ctx) {
$customerId = $ctx->get('data.object.customer');
$amount = $ctx->get('data.object.amount');
// Update the database and fulfill the order
OrderService::markAsPaid($customerId, $amount);
EmailService::sendReceipt($customerId);
});Dispatch internal domain events and async message-bus envelopes (e.g., from RabbitMQ or Kafka) by inspecting envelope metadata.
// Route an incoming event from a message broker
$dispatcher->on([
'headers.event_name' => 'UserRegistered',
'headers.version' => 'v2'
], function (Context $ctx) {
$userId = $ctx->get('payload.user_id');
// Trigger background jobs or projections
WelcomeEmailJob::dispatch($userId);
Metrics::increment('user_registrations');
});Route internal RPC requests based on body fields rather than relying on HTTP URIs.
// Handle a specific JSON-RPC method call
$dispatcher->on([
'jsonrpc' => '2.0',
'method' => 'system.sync_data'
], function (Context $ctx) {
$requestId = $ctx->get('id');
$force = $ctx->isTrue('params.force_sync');
// Perform the requested operation and emit the response
$result = SyncManager::run($force);
RpcServer::sendResponse($requestId, $result);
});Inspect context state flags and execute transition handlers when matching specific configurations.
// Handle an entity state transition request
$dispatcher->on([
'entity_type' => 'order',
'current_state' => 'processing',
'event' => 'ship_items'
], function (Context $ctx) {
$orderId = $ctx->get('order_id');
// Transition the state and notify the customer
Database::table('orders')->where('id', $orderId)->update(['state' => 'shipped']);
PushNotification::send($orderId, 'Your order is on the way!');
});- PHP:
8.4or higher - Dependencies: collectable/collection
Install via Composer:
composer require reactor/reactorReactor is built around four decoupled, highly cohesive classes:
Reactor\Context: An immutable-friendly data container wrapping incoming event data. ExtendsCollectable\Collectionand adds strict comparison helpers.Reactor\Dispatcher: The engine and orchestrator. Stores registered listeners, manages global middleware, compiles route templates, coordinates class resolution, and executes the dispatch pipeline.Reactor\Listener: An individual route definition encapsulating its conditions, target handler, parameter constraints, priority, and assigned middleware.Reactor\Group: A grouping container providing shared attributes (conditions, middleware, priority) to registered listeners. Supports infinite nesting and resolves attributes lazily.
The Context object encapsulates the raw event payload. Because it inherits from Collectable\Collection, it includes a comprehensive set of array manipulation and lookup methods.
use Reactor\Context;
$ctx = new Context([
'update_id' => 881293,
'message' => [
'id' => 4001,
'from' => [
'id' => 101,
'username' => 'alex_dev',
'is_bot' => false,
],
'chat' => [
'type' => 'private',
'title' => null,
],
],
'meta' => [
'tags' => ['vip', 'tester'],
],
]);
// 1. Dot-notation lookups
$username = $ctx->get('message.from.username'); // "alex_dev"
$missing = $ctx->get('message.from.first_name', 'N/A'); // "N/A"
// 2. Existence verification
$exists = $ctx->has('message.from.id'); // true
$absent = $ctx->has('message.location'); // false
// 3. Slicing and extraction
$data = $ctx->only(['update_id', 'message.from.id']);
// ['update_id' => 881293, 'message.from.id' => 101]
$filtered = $ctx->except(['meta']);
// 4. Access all raw data
$rawArray = $ctx->all();Context provides dedicated methods for strict type comparisons:
// is(string $path, mixed $value): bool (Strict === check)
$ctx->is('message.chat.type', 'private'); // true
$ctx->is('message.from.id', '101'); // false (int 101 !== string '101')
// isTrue(string $path): bool (Strict === true check)
$ctx->isTrue('message.from.is_bot'); // false
$ctx->isTrue('non_existing_key'); // false
// isFalse(string $path): bool (Strict === false check)
$ctx->isFalse('message.from.is_bot'); // true
$ctx->isFalse('message.chat.title'); // false (null is not false)See more methods here -> Collectable\Collection
The Dispatcher checks incoming events against conditions attached to each listener. A condition can be an associative array, a list of arrays (disjunction), a Closure, or a template.
When you pass an associative array, every path must match the expected value (logical AND):
$dispatcher->on([
'type' => 'message',
'message.chat.type' => 'supergroup',
'user.is_admin' => true,
], function () {
return 'Admin message received in supergroup';
});When you provide an indexed array of condition sets, the listener matches if any one of the sets matches (logical OR):
$dispatcher->on([
['command' => '/start'],
['command' => '/help'],
['action' => 'show_welcome_screen'],
], function () {
return 'Displaying navigation menu';
});For dynamic conditions that cannot be expressed as static values, pass a Closure returning a boolean. The Closure receives the active Context:
$dispatcher->on(
fn(Context $ctx) => $ctx->is('type', 'transaction') && (float) $ctx->get('amount', 0) > 10000.0,
function (Context $ctx) {
return "🚨 Large transaction flagged: " . $ctx->get('amount');
}
);Closures can also be mixed directly inside OR condition lists:
$dispatcher->on([
['type' => 'immediate_action'],
fn(Context $ctx) => $ctx->get('user.reputation', 0) > 100,
], fn() => 'Access granted');Passing an empty array [] applies no constraints. The listener matches every event reaching it:
$dispatcher->on([], function (Context $ctx) {
return 'Default processor for any unconstrained event';
});If a dot-path does not exist in the context, Context::get() returns null. Therefore:
$dispatcher->on(['message.edit_date' => null], function () {
return 'This update was never edited (key is either absent or explicitly null).';
});Route templates extract parameters directly from string values in the context using {placeholder} notation. By default, a placeholder matches any continuous sequence of non-whitespace characters (\S+).
$dispatcher->on(['text' => 'order {item_id} {quantity}'], function (string $item_id, int $quantity) {
return "Ordering {$quantity} unit(s) of item {$item_id}";
});
$dispatcher->dispatch(['text' => 'order SKU-884 5']);
// Output: Ordering 5 unit(s) of item SKU-884Append a question mark {param?} to make a placeholder optional.
- With space:
"cmd {arg?}"automatically treats the leading space as optional. It matches"cmd"as well as"cmd test". - Without space:
"page{num?}"matches"page"as well as"page12".
$dispatcher->on(['text' => 'list {page?}'], function (int $page = 1) {
return "Viewing catalog page #{$page}";
});
$dispatcher->dispatch(['text' => 'list']); // Viewing catalog page #1
$dispatcher->dispatch(['text' => 'list 4']); // Viewing catalog page #4Templates also work on numeric context values (integers or floats). If your incoming JSON has an integer ID, a placeholder template will match and capture it cleanly:
$dispatcher->on(['user.id' => '{id}'], function (int $id) {
return "Captured user ID: {$id}";
});
$dispatcher->dispatch(['user' => ['id' => 9912]]);
// Output: Captured user ID: 9912By default, template literals are case-sensitive. You can enable case-insensitive matching by calling ->ignoreCase() on the listener:
$dispatcher->on(['text' => 'find {query}'], function (string $query) {
return "Searching for: {$query}";
})->ignoreCase();
$dispatcher->dispatch(['text' => 'FIND php']); // Matches!
$dispatcher->dispatch(['text' => 'find PHP']); // Matches!Note:
ignoreCase()affects{placeholder}templates. Plain string equality remains strict, and raw regexes use their own PCRE flags.
Placeholder names inside a single template string must be unique. Defining duplicates (e.g. {x} {x}) is invalid and throws an InvalidArgumentException:
// Throws InvalidArgumentException on dispatch:
$dispatcher->on(['cmd' => 'compare {id} {id}'], fn() => '...');You can constrain placeholders using regular expressions. If a placeholder fails its constraint, the route match fails and the dispatcher proceeds to subsequent listeners.
| Method | Pattern Applied | Description |
|---|---|---|
whereNumber(...$params) |
[0-9]+ |
Strictly ASCII digits (avoids non-ASCII Unicode digits) |
whereAlpha(...$params) |
[a-zA-Z]+ |
Strictly English alphabetic characters |
whereAlphaNumeric(...$params) |
[a-zA-Z0-9]+ |
Letters and digits only |
whereIn($param, array $values) |
(?:val1|val2) |
Must match one of the exact string values |
where(string|array $name, ?string $pattern = null) |
Custom regex | Custom pattern for one or more parameters |
$dispatcher->on(['cmd' => 'user {id} {tag} {code}'], $handler)
->whereNumber('id') // id must be 0-9
->whereAlpha('tag') // tag must be a-z/A-Z
->whereAlphaNumeric('code'); // code must be alphanumericPass custom regex strings without enclosing delimiters. Reactor automatically handles internal slashes so they never prematurely terminate compiled patterns:
$dispatcher->on(['path' => 'file/{filepath}'], $handler)
->where('filepath', '[a-zA-Z0-9_]+/[a-zA-Z0-9_]+'); // Safely matches "folder/file"You can also pass an associative array:
$listener->where([
'uuid' => '[0-9a-fA-F-]{36}',
'lang' => '[a-z]{2}',
]);whereIn() automatically escapes all values and builds an alternation regex:
$dispatcher->on(['text' => 'locale {lang}'], function (string $lang) {
return "Locale set to {$lang}";
})->whereIn('lang', ['en', 'ru', 'es', 'pt-br', 'custom/locale']);Edge case protection: If an empty array
whereIn('param', [])is passed, Reactor compiles it to(?!), which is guaranteed to never match any input.
When you need complete control over pattern matching, pass a raw regular expression directly as the condition value.
Reactor automatically recognizes raw regexes enclosed in any of these five standard delimiters: /, ~, #, %, or @:
// 1. Standard slash delimiter with case-insensitive modifier
$dispatcher->on(['command' => '/^\/stats$/i'], fn() => 'Displaying stats');
// 2. Tilde delimiter (ideal when pattern contains slashes)
$dispatcher->on(['url' => '~^/api/v1/users/(?P<id>\d+)$~'], function (int $id) {
return "API User #{$id}";
});
// 3. Hash delimiter
$dispatcher->on(['hash' => '#^[a-f0-9]{32}$#'], fn() => 'Valid MD5 hash');
// 4. Percent and At delimiters
$dispatcher->on(['token' => '%^[A-Z0-9]{16}$%'], fn() => 'Token matched');
$dispatcher->on(['slug' => '@^[a-z\-]+$@'], fn() => 'Slug matched');If the pattern body contains an escaped instance of its chosen delimiter, Reactor parses it correctly without confusing it with the closing delimiter:
$dispatcher->on(['url' => '/^a\/b$/'], fn() => 'Matched slash delimiter');
$dispatcher->on(['tag' => '~^x\~y$~'], fn() => 'Matched tilde delimiter');Strings that look like malformed delimiters (e.g. /// or ##) are treated as literal strings, preventing unwanted regex compilation errors.
- Named Captures: If named capture groups (
(?P<name>...)or(?<name>...)) are present, they are extracted and mapped to handler parameters matching their names. Unnamed captures in the same expression are ignored. - Positional Captures: If only unnamed capture groups are present, they are injected into handler parameters by position (
$1,$2, etc.). - Optional Captures: If an optional capture group does not match (e.g.
(?P<ext>\.[a-z]+)?),nullis injected.
// Positional capture injection
$dispatcher->on(['tag' => '/^#([a-zA-Z]+)-([0-9]+)$/'], function (string $category, int $id) {
return "Tag: {$category}, ID: {$id}";
});
// Named capture injection (order in signature does not matter)
$dispatcher->on(['ref' => '/^ref_(?P<campaign>\w+)_(?P<affiliate>\d+)$/'], function (int $affiliate, string $campaign) {
return "Affiliate {$affiliate} on campaign {$campaign}";
});You can extend Reactor's matching engine by registering custom matchers with $dispatcher->matcher().
The matcher callback receives ($pattern, $value, $patterns, &$args):
- Return
true: Condition is satisfied. Any key-value pairs written into&$argsare injected as handler arguments. - Return
false: Condition failed. Listener is rejected. - Return
null: Pattern is not handled by this matcher; Reactor passes it to subsequent matchers or the default engine.
// 1. Add support for "starts_with:" prefix
$dispatcher->matcher(function ($pattern, $value, $patterns, &$args) {
if (is_string($pattern) && str_starts_with($pattern, 'starts_with:')) {
$prefix = substr($pattern, 12);
if (is_string($value) && str_starts_with($value, $prefix)) {
$args['matched_prefix'] = $prefix;
$args['matched_string'] = $value;
return true;
}
return false;
}
return null; // Defer to regular engine
});
// 2. Add support for "*" wildcards
$dispatcher->matcher(function ($pattern, $value) {
if ($pattern === '*') {
return $value !== null;
}
return null;
});
// Usage
$dispatcher->on(['text' => 'starts_with:/admin_'], function (string $matched_string) {
return "Admin command detected: {$matched_string}";
});Reactor supports all modern PHP callable formats:
// 1. Closures
$dispatcher->on(['action' => 'ping'], fn() => 'pong');
// 2. Class and method tuple
$dispatcher->on(['cmd' => 'user {id}'], [UserController::class, 'show']);
// 3. Laravel-style string syntax
$dispatcher->on(['cmd' => 'user {id}'], 'UserController@show');
// 4. Invokable class (must implement __invoke)
$dispatcher->on(['action' => 'process'], ProcessOrderHandler::class);
// 5. Existing object instance and method
$dispatcher->on(['action' => 'sync'], [$syncService, 'run']);
// 6. Static class method
$dispatcher->on(['action' => 'version'], [SystemInfo::class, 'version']);Handler parameters are resolved via PHP Reflection:
ContextInjection: Any parameter type-hinted asContext, its parentCollectable\Collection, or a child class receives the currentContextinstance. It can be placed at any position in the parameter list.- Named Argument Mapping: Placeholders and named regex groups are matched directly to parameter names (e.g.
{username}binds to$username). - Positional Arguments: If no named parameters match, captured parameters are mapped by position.
- Default Values & Nullability: Missing arguments fall back to their PHP default value. If untyped or nullable without a default,
nullis injected. - Variadic Arguments in Handlers: Variadic arguments (
...$rest) are ignored during injection without raising parameter-resolution exceptions.
$dispatcher->on(['cmd' => 'greet {name} {greeting?}'], function (Context $ctx, string $name, string $greeting = 'Hello') {
$senderId = $ctx->get('from.id');
return "{$greeting}, {$name}! (From sender #{$senderId})";
});Extracted strings are automatically cast to scalar types matching your parameter type-hints:
int: Numerical strings are cast to integers. Leading zeros are safely handled (e.g.,"007"becomes7,"000"becomes0).- Integer Overflow Protection: If a number exceeds
PHP_INT_MAX, Reactor will not silently corrupt or truncate the number; it safely passes the string or throws aTypeErrorif strict typing is required. float: Floating-point strings are converted tofloat.bool: String values"true","1"becometrue;"false","0"becomefalse.- Union Types: E.g.
int|floatresolves to the most accurate scalar representation. Ifstringormixedis part of the union (e.g.string|int), the value remains astringto prevent unwanted mutation.
By default, handler and middleware classes are created via new $class(). You can wire up your PSR-11 dependency injection container (PHP-DI, Laravel Container, Symfony DI) using resolver():
use Psr\Container\ContainerInterface;
/** @var ContainerInterface $container */
$container = $myApp->getContainer();
// Teach the dispatcher how to resolve classes using your container:
$dispatcher->resolver(fn(string $class) => $container->get($class));Now any class registered by name (UserController@show, [OrderController::class, 'cancel'], or middleware classes) will be resolved through your DI container with full autowiring.
Reactor implements an onion-architecture middleware pipeline. Middleware can inspect the payload, mutate the Context, verify permissions, rate-limit, or abort execution.
Incoming Context
│
▼
┌──────────────────────────────────────────────┐
│ Global Middleware │
│ ┌────────────────────────────────────────┐ │
│ │ Group Middleware (Parent -> Child) │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Listener Middleware │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ Target Handler │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Middleware must satisfy the signature function (Context $ctx, Closure $next, ...$args): mixed.
$dispatcher->middleware(function (Context $ctx, Closure $next) {
if ($ctx->get('blocked') === true) {
return 'Access blocked'; // Terminate without calling $next
}
return $next($ctx);
});class ThrottleMiddleware
{
public function handle(Context $ctx, Closure $next, int $limit = 5): mixed
{
$userId = (string) $ctx->get('user.id', 'guest');
if (RateLimiter::isExceeded($userId, $limit)) {
return "Too many requests. Limit: {$limit}";
}
return $next($ctx);
}
}class VerifySignature
{
public function __invoke(Context $ctx, Closure $next): mixed
{
if (!$ctx->has('signature')) {
return false; // Treat as unhandled, advance to next listener
}
return $next($ctx);
}
}Register aliases using $dispatcher->alias(). Parameters can be passed after a colon : separated by commas ,:
$dispatcher->alias('role', function (Context $ctx, Closure $next, string ...$roles) {
if (!in_array($ctx->get('user.role'), $roles, true)) {
return "Denied: Required role [" . implode(',', $roles) . "]";
}
return $next($ctx);
});
$dispatcher->alias('throttle', ThrottleMiddleware::class);
// Usage on listener:
$dispatcher->on(['cmd' => '/admin'], fn() => 'Admin Panel')
->middleware('role:admin,superadmin', 'throttle:10');Parameters passed in string notation are automatically cast to the types declared in the middleware signature using Reflection.
Unlike route handlers, middleware methods natively support variadic parameter arguments (string ...$roles). The types of the variadic parameter are used to coerce each separated string argument:
$dispatcher->alias('permissions', function (Context $ctx, Closure $next, int ...$permissionIds) {
// $permissionIds is automatically an array of integers: [1, 5, 12]
return $next($ctx);
});
$dispatcher->on(['cmd' => 'edit'], fn() => 'ok')
->middleware('permissions:1,5,12');// 1. Global: Wraps ALL dispatched events, including the Fallback handler
$dispatcher->middleware(GlobalLogger::class);
// 2. Group: Wraps all listeners registered inside this group
$dispatcher->group(['middleware' => 'auth'], function ($group) {
$group->on(['cmd' => 'me'], fn() => 'Profile');
});
// 3. Listener-specific:
$dispatcher->on(['cmd' => 'wipe'], fn() => 'Wiped')
->middleware('role:superadmin');Middleware can modify data or create a new Context instance and pass it down the pipeline:
$dispatcher->middleware(function (Context $ctx, Closure $next) {
$user = UserRepository::find($ctx->get('user_id'));
// Pass updated context down the pipeline
return $next(new Context([
...$ctx->all(),
'user' => $user,
]));
});Route groups bundle shared conditions, middleware, and base priority settings across multiple listeners.
You can create groups with an attribute array or using the callable-only shorthand:
// 1. With attribute array:
$dispatcher->group([
'conditions' => ['chat.type' => 'supergroup'],
'middleware' => ['auth', 'rate_limit:100'],
'priority' => 20,
], function (\Reactor\Group $group) {
$group->on(['text' => '/rules'], fn() => 'Group Rules');
});
// 2. Shorthand (callable only without attributes):
$group = $dispatcher->group(function (\Reactor\Group $group) {
$group->on(['action' => 'ping'], fn() => 'pong');
});Groups can be nested indefinitely. Attributes accumulate down the hierarchy:
$dispatcher->group(['conditions' => ['module' => 'billing']], function ($billing) {
// Module is billing AND action is invoice
$billing->on(['action' => 'invoice'], fn() => 'Invoices');
// Nested group: Module is billing AND role is accountant
$billing->group(['middleware' => 'role:accountant'], function ($accountant) {
$accountant->on(['action' => 'refund'], fn() => 'Process Refund');
});
});- Conditions: Parent and child conditions are combined with logical
AND. A nested route can only narrow its parent scope, never widen it. - Middleware: Parent group middleware executes before child group middleware.
- Priority: Nested listeners inherit their parent group's priority unless explicitly overridden.
You can fluently add additional condition scopes to a group at any time using $group->scope():
$group = $dispatcher->group(['priority' => 10]);
// Add array condition scope
$group->scope(['chat.type' => 'private']);
// Add closure condition scope
$group->scope(fn(Context $ctx) => $ctx->has('user.id'));Groups maintain live references to registered listeners. Adding middleware or adjusting priorities on a group after defining routes updates all registered listeners retroactively:
$group = $dispatcher->group(['priority' => 10]);
$group->on(['action' => 'audit'], fn() => 'Audit log');
// Middleware added after listener declaration is still executed!
$group->middleware(SecurityCheckMiddleware::class);If an exception is thrown inside a group callback, Reactor safely unwinds its internal group stack via try...finally. This guarantees that subsequent routes registered outside the group do not accidentally inherit the failed group's attributes:
try {
$dispatcher->group(['conditions' => ['broken' => true]], function () {
throw new RuntimeException('Config error');
});
} catch (RuntimeException) {
// Stack is cleanly unwound
}
// Registered at root level, NOT inside the broken group:
$dispatcher->on(['cmd' => 'health'], fn() => 'Healthy');Listeners are evaluated in descending order of priority (e.g. 100 runs before 10, which runs before 0). Listeners with equal priorities execute in the order they were registered.
// Run urgent maintenance check first
$dispatcher->on([], fn() => 'System undergoing maintenance')
->priority(1000);
// Default priority (0)
$dispatcher->on(['cmd' => 'ping'], fn() => 'pong');
// Negative priority (runs last)
$dispatcher->on([], fn() => 'Default catch-all')
->priority(-100);In Reactor, return values control whether the event is considered handled:
The Golden Rule:
return falsemeans: "The event is NOT handled - pass control to the next matching listener."- Any other value (
true,null,0,"",[], objects, or NO return statement at all) means: "The event is SUCCESSFULLY handled - stop the chain and return this result."
No! If your handler just performs an action (e.g., sends a message or writes to a database) without an explicit return statement:
$dispatcher->on(['cmd' => '/start'], function (Context $ctx) {
Telegram::sendMessage($ctx->get('chat.id'), 'Welcome!');
// No return statement -> PHP returns null automatically
});Because PHP implicitly returns null for void functions, the Dispatcher treats null as success, completes the dispatch cycle, and prevents fallback from executing.
// 1. Analytics Listener (priority 50)
$dispatcher->on(['type' => 'order'], function (Context $ctx) {
Metrics::increment('orders');
return false; // Tells dispatcher: "I logged this, keep looking for the real handler!"
})->priority(50);
// 2. Real Business Handler (priority 10)
$dispatcher->on(['type' => 'order'], function (Context $ctx) {
return OrderService::create($ctx->all());
})->priority(10);If you want a listener to reject an event or return false, but prohibit any further listeners or fallback from running, call ->stop() on the listener:
$dispatcher->on(['user.is_banned' => true], function () {
// User is banned: return false, but stop all further execution
return false;
})->stop();
// This listener will NEVER execute if user.is_banned is true:
$dispatcher->on(['user.is_banned' => true], fn() => 'Secret Admin Data');If no listeners match the event (or every matching listener returns false without stop()), the fallback handler is executed:
$dispatcher->fallback(function (Context $ctx) {
return "No listener handled update ID #" . $ctx->get('update_id', 'unknown');
});- The fallback handler supports all callable formats (
Closure,Controller@method, invokable classes). - Global middleware wraps the fallback handler. Group and listener-level middleware do not.
Pass context data directly to dispatch():
$dispatcher = new Dispatcher();
$dispatcher->on(['type' => 'alert'], fn() => 'Alert acknowledged');
$result = $dispatcher->dispatch($_POST);Bind the context during instantiation or bootstrap, and execute via run():
$dispatcher = new Dispatcher($_POST); // Context bound in constructor
$dispatcher->on(['type' => 'alert'], fn() => 'Alert acknowledged');
$result = $dispatcher->run();Inline Override: If you pass data to
dispatch($inlineData)on a dispatcher that already has bound context, the inline data is processed for that single call without overwriting the stored bound context.
You can retrieve the currently bound Context instance using $dispatcher->context():
$dispatcher = new Dispatcher(['app' => 'v1']);
$currentContext = $dispatcher->context(); // Instance of Reactor\ContextYou can instantiate Listener objects independently and register them via $dispatcher->add():
$listener = new \Reactor\Listener(['cmd' => 'status'], fn() => 'Online');
$listener->priority(10);
$dispatcher->add($listener);Extends Collectable\Collection.
| Method | Return Type | Description |
|---|---|---|
is(string $path, mixed $value) |
bool |
Strict equality check ($ctx->get($path) === $value). |
isTrue(string $path) |
bool |
Checks if path is strictly true (=== true). |
isFalse(string $path) |
bool |
Checks if path is strictly false (=== false). |
... |
... |
See more methods here -> Collectable\Collection |
| Method | Return Type | Description |
|---|---|---|
__construct(array|Context|null $context = null) |
void |
Creates dispatcher and optionally binds context. |
bind(array|Context $data) |
self |
Binds default context data. |
context() |
?Context |
Returns the currently bound context instance. |
resolver(callable $resolver) |
self |
Registers custom DI resolver fn(string $class): object. |
matcher(callable $matcher) |
self |
Registers custom pattern matcher fn($pat, $val, $pats, &$args): ?bool. |
alias(string $name, mixed $middleware) |
self |
Maps an alias string to a middleware class or closure. |
middleware(mixed ...$middleware) |
self |
Registers global middleware layers. |
on(mixed $conditions, mixed $handler) |
Listener |
Registers a listener (or forwards to active group). |
add(Listener $listener) |
Listener |
Adds an instantiated Listener object directly. |
group(array|callable $attributes, ?callable $callback = null) |
Group |
Creates an attribute group and executes callback. |
fallback(mixed $handler) |
self |
Sets fallback handler for unhandled events. |
dispatch(array|Context|null $data = null) |
mixed |
Runs the pipeline against the given context. |
run() |
mixed |
Alias for dispatch() using bound context. |
| Method | Return Type | Description |
|---|---|---|
priority(int $priority) |
self |
Sets listener priority (higher numbers execute first). |
middleware(mixed ...$middleware) |
self |
Attaches middleware to this specific listener. |
where(string|array $name, ?string $pattern = null) |
self |
Adds regex constraint(s) to placeholder(s). |
whereNumber(string ...$names) |
self |
Constrains placeholders to [0-9]+ (strictly ASCII). |
whereAlpha(string ...$names) |
self |
Constrains placeholders to [a-zA-Z]+. |
whereAlphaNumeric(string ...$names) |
self |
Constrains placeholders to [a-zA-Z0-9]+. |
whereIn(string $name, array $values) |
self |
Constrains placeholder to list of exact values. |
ignoreCase(bool $ignore = true) |
self |
Toggles case-insensitive matching for templates. |
stop(bool $stop = true) |
self |
Prevents further listeners from running even if false is returned. |
getPriority() |
int |
Returns effective priority (listener or group default). |
getMiddleware() |
array |
Returns merged middleware chain (group + own). |
getScope() |
array |
Returns parent group condition scopes. |
getPatterns() |
array |
Returns registered parameter constraint patterns. |
isIgnoreCase() |
bool |
Checks whether template matching is case-insensitive. |
isStopped() |
bool |
Checks whether propagation is stopped. |
| Method | Return Type | Description |
|---|---|---|
on(mixed $conditions, mixed $handler) |
Listener |
Registers a listener bound to this group's scope. |
middleware(mixed ...$middleware) |
self |
Appends middleware to all listeners in this group. |
priority(int $priority) |
self |
Sets default priority for listeners in this group. |
scope(array|Closure $conditions) |
self |
Adds a condition scope to the group (ANDed with listener rules). |
getMiddleware() |
array |
Returns effective middleware (parents + own). |
getPriority() |
?int |
Returns effective priority. |
getConditions() |
array |
Returns all condition scopes (parents + own). |
Reactor is thoroughly tested with Pest PHP. Run the test suite via Composer:
composer testReactor Dispatcher is open-sourced software licensed under the MIT License.