phpbotgram

Routers

A router is a node in the dispatch tree — it owns one observer per Telegram update type, plus startup/shutdown hooks, and composes into parent/child trees that the dispatcher walks depth-first.

How it works

Observers and property access

Router owns a array<string, TelegramEventObserver> keyed by wire-name (message , callback_query , chat_member , …) plus a separate errors observer for the synthetic error-event channel. Each entry in UPDATE_TYPES corresponds to a property on Update.php generated by Phase 2; a regen that adds a new update kind surfaces here as a test failure in RouterTest::testUpdateTypesConstantMatchesUpdateSchema .

The router exposes both wire-name access ($router->observers['callback_query'] ) and camelCase property access ($router->callbackQuery ) — they point at the same instance, by design. The camelCase form is documented as the ergonomic public API; the wire-keyed map exists for iteration (e.g. attaching a middleware to every observer in a loop).

Registering handlers

Create a Router , register handlers on its observers, then attach it to the dispatcher using includeRouter() :

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Dispatcher\Dispatcher;
use Gruven\PhpBotGram\Dispatcher\PollingOptions;
use Gruven\PhpBotGram\Dispatcher\Router;
use Gruven\PhpBotGram\Types\Message;

$dispatcher = new Dispatcher();

$shopRouter = new Router('shop');
$shopRouter->message->register(static function (Message $event): void {
    $event->answer('Shop handler')->emit();
});

$dispatcher->includeRouter($shopRouter);
$dispatcher->runPolling(new PollingOptions(), new Bot(getenv('BOT_TOKEN') ?: '123:abc'));

includeRouter() returns the included router so handler registration can be chained fluently: $dispatcher->includeRouter($shopRouter)->message->register(...) .

Router composition

Routers compose via includeRouter() . The parent stores the child in $subRouters and the child stores its parent in $parentRouter . Cycle detection, self-attachment, and re-parenting are rejected at attach time with LogicException — once attached, a router cannot move. This matches aiogram's invariant that a router belongs to exactly one tree. The asymmetry of "child holds parent reference" makes TelegramEventObserver::resolveMiddlewares() straightforward: each observer walks $this->router->parentRouter chains to compose its inner-middleware stack without the framework having to maintain a secondary index.

Depth-first dispatch

Dispatch is a depth-first walk. Router::propagateEvent first tries its own observer's handlers. If every handler returns the UnhandledSentinel (or no handler is registered), it recurses into each child router in registration order. The first router whose tree claims the event short-circuits the walk.

The walk gives parents a chance to filter, transform, or short-circuit before children see the event — useful for cross-cutting concerns like auth gates or per-tenant scoping. The Router::propagateEvent method also owns the wrap-once invariant: the observer's outer middleware chain composes around the recursive walk exactly once, not once per child, so a deep router tree doesn't re-apply the same middleware at every level.

TelegramEventObserver is where the per-update-type contract lives: handlers, global filters, outer/inner middleware managers. resolveMiddlewares() walks the parent chain to compose the full inner-middleware stack — root router's middleware ends up outermost, leaf router's innermost, matching aiogram's chain_head walk. The composition is rebuilt per dispatch; there is no cached chain, so attaching a middleware mid-flight on a sub-router takes effect on the very next event without ceremony.

Lifecycle hooks

Lifecycle hooks live on the router via startup and shutdown — typed as EventObserver instances. Handlers registered there fire in registration order when the dispatcher boots or drains. Each hook receives the merged workflow_data plus the router itself, so a startup handler can read the router-tree shape it was attached to.

Trade-offs

Routers are not lazy. Every router constructs all 26 observers (25 update types + errors ) at instantiation, even if you only register a message handler. The cost is a few dozen object allocations per router, paid once at boot — negligible against the dispatch hot path but worth knowing if you build many short-lived routers (e.g. one router per request in some unusual testing pattern). For production shapes, where you boot a handful of routers once at startup, the overhead is invisible.

The depth-first walk runs in registration order. Re-ordering child routers changes which one claims an event when multiple could. There is no priority field; the first matching handler in the first matching router wins. If you find yourself wanting priority, you probably want to merge those routers into one with explicit filters. Aiogram has the same limitation and the same workaround — the upstream community discussed adding priority and decided against it because it makes the dispatch behaviour harder to reason about.

Router is not final . Dispatcher extends it to add polling entry points, and tests subclass it to add instrumentation. User code generally should not — composition via includeRouter is the expected extension point. We do not enforce final-ness because the test base subclass is the canonical seam; users who reach for inheritance instead of composition are choosing the harder path deliberately.

The 25-observer-per-router shape is the binding constraint on the framework's surface area. A bot that uses one update type still pays for 24 unused observers per router — but the alternative (lazy observer construction) would complicate resolveMiddlewares() and propagateEvent() for no real performance benefit. Object allocation is cheap; correctness trumps micro-optimisation.

See also

Search results