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.