Router
in package
Per-update-type router — port of `aiogram.dispatcher.router.Router`.
A Router owns one TelegramEventObserver for every Telegram
update type (25 today: message, edited_message, …, managed_bot)
plus a separate errors observer for the error-propagation channel,
and two EventObserver instances (startup / shutdown) for
lifecycle hooks.
Routers compose into a tree via includeRouter(); propagateEvent()
dispatches an update to the local observer, and on UnhandledSentinel
fall-through, walks subRouters depth-first. Cycles, self-attachment,
and re-parenting are rejected at attach time with LogicException.
Why UPDATE_TYPES is a hand-maintained constant, not generated:
the list is derived from Types/Update.php (Phase 2 codegen output)
but Router does not import Update — that would force Router to know
the full type graph just to dispatch by string key. The constant is
tested against the schema-driven list in RouterTest, so a Phase 2
regeneration that adds/removes an update field surfaces here as a
test failure.
camelCase property aliases ($router->editedMessage etc.) are
initialized as direct references to the same TelegramEventObserver
instances stored in $observers['edited_message']. Spec § "Event
name conventions" mandates this dual API: snake_case wire names for
iteration, camelCase for ergonomic registration call sites.
Router is not declared final — Dispatcher (Task 3.10)
extends it to add polling/webhook entry points.
Table of Contents
Constants
- UPDATE_TYPES : array<string|int, mixed> = ['message', 'edited_message', 'channel_post', '...
- Wire-level Bot API update keys this router routes for.
- INTERNAL_UPDATE_TYPES : array<string|int, mixed> = ['update', 'error']
- Update types upstream excludes from `resolve_used_update_types` regardless of registered handlers. Upstream uses a frozenset literal; the port keeps it as a `list<string>` for `in_array` lookups.
Properties
- $businessConnection : TelegramEventObserver
- $businessMessage : TelegramEventObserver
- $callbackQuery : TelegramEventObserver
- $channelPost : TelegramEventObserver
- $chatBoost : TelegramEventObserver
- $chatJoinRequest : TelegramEventObserver
- $chatMember : TelegramEventObserver
- $chosenInlineResult : TelegramEventObserver
- $deletedBusinessMessages : TelegramEventObserver
- $editedBusinessMessage : TelegramEventObserver
- $editedChannelPost : TelegramEventObserver
- $editedMessage : TelegramEventObserver
- $errors : TelegramEventObserver
- Errors-channel observer — read via `$router->errors` (matches upstream's `self.errors = self.error = TelegramEventObserver(...)`).
- $guestMessage : TelegramEventObserver
- $inlineQuery : TelegramEventObserver
- $managedBot : TelegramEventObserver
- $message : TelegramEventObserver
- $messageReaction : TelegramEventObserver
- $messageReactionCount : TelegramEventObserver
- $myChatMember : TelegramEventObserver
- $name : string
- Debug-only identifier. Defaults to `spl_object_hash($this)` (PHP equivalent of upstream's `hex(id(self))`) when no explicit name is given. The hash is stable for the object's lifetime but **not** guaranteed unique across two unrelated instances of the same process after one has been garbage-collected — that's a Python parity quirk, not a bug.
- $observers : array<string, TelegramEventObserver>
- Wire-name keyed map of every Telegram observer this router owns (one per `UPDATE_TYPES` entry plus `'error'`). External iteration goes through this map; per-type ergonomics use the camelCase properties below, which are direct references to the same instances.
- $parentRouter : Router|null
- Parent in the router composition tree, set by `includeRouter()`.
- $poll : TelegramEventObserver
- $pollAnswer : TelegramEventObserver
- $preCheckoutQuery : TelegramEventObserver
- $purchasedPaidMedia : TelegramEventObserver
- $removedChatBoost : TelegramEventObserver
- $scenePriority : bool
- Scene routers should get the first chance to handle updates while an FSM state is active, so broad parent catch-all handlers do not starve active scenes. Normal routers keep the local-observer-first traversal.
- $scenePriorityState : string|null
- FSM state this scene-priority router owns. Null means the router is marked as priority but cannot be state-matched during the active-scene prepass.
- $shippingQuery : TelegramEventObserver
- $shutdown : EventObserver
- Mirror of `$startup` for graceful shutdown.
- $startup : EventObserver
- Lifecycle hook fan-out for polling/webhook startup. Handlers are registered via `$router->startup->register($cb)` and fire in registration order on `emitStartup()`. Receives the workflow_data kwarg bag merged with `router: $this`.
- $subRouters : array<int, Router>
- Children attached via `includeRouter()`, in registration order.
Methods
- __construct() : mixed
- emitShutdown() : void
- Symmetric counterpart of `emitStartup` for graceful teardown.
- emitStartup() : void
- Fire the startup lifecycle hook depth-first across the tree.
- includeRouter() : Router
- Attach a child router. Validates the operation against three mistakes that would corrupt the tree:
- includeRouters() : static
- Variadic convenience for attaching several children at once.
- preferWhenStateActive() : static
- Mark this router as a scene router for active-state priority dispatch.
- propagateEvent() : mixed
- Route an event through the local observer; on UNHANDLED fall through to sub-routers in registration order.
- resolveUsedUpdateTypes() : array<int, string>
- Collect the snake_case names of every update type with at least one registered handler anywhere in the tree rooted at `$this`.
- hasScenePriorityForStateInSubtree() : bool
- matchesScenePriorityState() : bool
- propagateEventInternal() : mixed
- propagateScenePrioritySubtree() : mixed
- Walk only scene-priority descendants matching the active FSM state.
Constants
UPDATE_TYPES
Wire-level Bot API update keys this router routes for.
public
array<string|int, mixed>
UPDATE_TYPES
= ['message', 'edited_message', 'channel_post', 'edited_channel_post', 'business_connection', 'business_message', 'edited_business_message', 'deleted_business_messages', 'guest_message', 'message_reaction', 'message_reaction_count', 'inline_query', 'chosen_inline_result', 'callback_query', 'shipping_query', 'pre_checkout_query', 'purchased_paid_media', 'poll', 'poll_answer', 'my_chat_member', 'chat_member', 'chat_join_request', 'chat_boost', 'removed_chat_boost', 'managed_bot']
Derived from regenerated src/Types/Update.php: each non-updateId,
non-bot constructor parameter on Update becomes a key here, with
camelCase converted to snake_case to match the wire payload. The
order mirrors Update's parameter order so iterations are
deterministic across PHP versions.
Sync invariant: whenever Phase 2 regen changes Update.php, this
list must be updated. RouterTest::testUpdateTypesConstantMatchesUpdateSchema
is the canary.
error is NOT in this list — it's a separate channel for the
ErrorsMiddleware and lives in $observers['error'] /
$this->errors. Update propagation never targets it; the only way
to reach it is via propagateEvent('error', ...) from the error
middleware.
INTERNAL_UPDATE_TYPES
Update types upstream excludes from `resolve_used_update_types` regardless of registered handlers. Upstream uses a frozenset literal; the port keeps it as a `list<string>` for `in_array` lookups.
private
array<string|int, mixed>
INTERNAL_UPDATE_TYPES
= ['update', 'error']
'update' mirrors upstream's INTERNAL_UPDATE_TYPES but is never an
observer key on Router — included for forward-compat with upstream.
Properties
$businessConnection read-only
public
TelegramEventObserver
$businessConnection
$businessMessage read-only
public
TelegramEventObserver
$businessMessage
$callbackQuery read-only
public
TelegramEventObserver
$callbackQuery
$channelPost read-only
public
TelegramEventObserver
$channelPost
$chatBoost read-only
public
TelegramEventObserver
$chatBoost
$chatJoinRequest read-only
public
TelegramEventObserver
$chatJoinRequest
$chatMember read-only
public
TelegramEventObserver
$chatMember
$chosenInlineResult read-only
public
TelegramEventObserver
$chosenInlineResult
$deletedBusinessMessages read-only
public
TelegramEventObserver
$deletedBusinessMessages
$editedBusinessMessage read-only
public
TelegramEventObserver
$editedBusinessMessage
$editedChannelPost read-only
public
TelegramEventObserver
$editedChannelPost
$editedMessage read-only
public
TelegramEventObserver
$editedMessage
$errors read-only
Errors-channel observer — read via `$router->errors` (matches upstream's `self.errors = self.error = TelegramEventObserver(...)`).
public
TelegramEventObserver
$errors
Not part of UPDATE_TYPES because there is no error wire payload;
ErrorsMiddleware synthesizes the event and invokes
propagateEvent('error', ...).
$guestMessage read-only
public
TelegramEventObserver
$guestMessage
$inlineQuery read-only
public
TelegramEventObserver
$inlineQuery
$managedBot read-only
public
TelegramEventObserver
$managedBot
$message read-only
public
TelegramEventObserver
$message
$messageReaction read-only
public
TelegramEventObserver
$messageReaction
$messageReactionCount read-only
public
TelegramEventObserver
$messageReactionCount
$myChatMember read-only
public
TelegramEventObserver
$myChatMember
$name read-only
Debug-only identifier. Defaults to `spl_object_hash($this)` (PHP equivalent of upstream's `hex(id(self))`) when no explicit name is given. The hash is stable for the object's lifetime but **not** guaranteed unique across two unrelated instances of the same process after one has been garbage-collected — that's a Python parity quirk, not a bug.
public
string
$name
$observers
Wire-name keyed map of every Telegram observer this router owns (one per `UPDATE_TYPES` entry plus `'error'`). External iteration goes through this map; per-type ergonomics use the camelCase properties below, which are direct references to the same instances.
public private(set)
array<string, TelegramEventObserver>
$observers
= []
$parentRouter
Parent in the router composition tree, set by `includeRouter()`.
public private(set)
Router|null
$parentRouter
= null
null until attached; immutable thereafter (re-parenting throws).
The set is on the child, not the parent: $parent->includeRouter($child)
writes $child->parentRouter = $parent. Mirrors upstream's
parent_router property setter semantics at router.py:217-246.
$poll read-only
public
TelegramEventObserver
$poll
$pollAnswer read-only
public
TelegramEventObserver
$pollAnswer
$preCheckoutQuery read-only
public
TelegramEventObserver
$preCheckoutQuery
$purchasedPaidMedia read-only
public
TelegramEventObserver
$purchasedPaidMedia
$removedChatBoost read-only
public
TelegramEventObserver
$removedChatBoost
$scenePriority
Scene routers should get the first chance to handle updates while an FSM state is active, so broad parent catch-all handlers do not starve active scenes. Normal routers keep the local-observer-first traversal.
public private(set)
bool
$scenePriority
= false
$scenePriorityState
FSM state this scene-priority router owns. Null means the router is marked as priority but cannot be state-matched during the active-scene prepass.
public private(set)
string|null
$scenePriorityState
= null
$shippingQuery read-only
public
TelegramEventObserver
$shippingQuery
$shutdown read-only
Mirror of `$startup` for graceful shutdown.
public
EventObserver
$shutdown
$startup read-only
Lifecycle hook fan-out for polling/webhook startup. Handlers are registered via `$router->startup->register($cb)` and fire in registration order on `emitStartup()`. Receives the workflow_data kwarg bag merged with `router: $this`.
public
EventObserver
$startup
$subRouters
Children attached via `includeRouter()`, in registration order.
public private(set)
array<int, Router>
$subRouters
= []
Used by propagateEvent for depth-first fall-through and by
emitStartup/emitShutdown for tree traversal.
Methods
__construct()
public
__construct([string|null $name = null ]) : mixed
Parameters
- $name : string|null = null
emitShutdown()
Symmetric counterpart of `emitStartup` for graceful teardown.
public
emitShutdown([array<string, mixed> $kwargs = [] ]) : void
Same traversal order (depth-first, registration order) and same
router => $this injection. Matches upstream emit_shutdown
(router.py:295).
Parameters
- $kwargs : array<string, mixed> = []
emitStartup()
Fire the startup lifecycle hook depth-first across the tree.
public
emitStartup([array<string, mixed> $kwargs = [] ]) : void
Injects router => $this into the kwargs bag so handlers can
declare Router $router and receive the emitting router at
each level — not the root. Matches upstream emit_startup
(router.py:282).
Forwarded kwargs include the workflow_data and the
bots[array_key_last] injection from the polling driver (spec
§ "Polling loop"). Lifecycle handlers are pub/sub: every handler
runs; the first throw aborts the rest (matches EventObserver).
Parameters
- $kwargs : array<string, mixed> = []
-
Workflow data + injected
bot.
includeRouter()
Attach a child router. Validates the operation against three mistakes that would corrupt the tree:
public
includeRouter(Router $router) : Router
- Self-attachment — a router cannot include itself. Upstream raises RuntimeError; we use LogicException because PHP doesn't have a Runtime/Logic distinction this fine, and "you wired your router tree wrong" is unambiguously a programming bug.
- Re-parenting — once a router has a parent it stays put.
The alternative (detach + re-attach) would silently leave the
old parent's
subRoutersarray holding a dangling reference. - Cycles — A→B→C→A would make
propagateEventinfinitely recurse. We walk our own ancestor chain (parentRouterupward) and confirm the candidate isn't already in it.
Returns the included router so callers can chain fluent
registrations: $root->includeRouter($child)->message->register(...).
Matches upstream include_router(...) -> Router.
Parameters
- $router : Router
Return values
RouterincludeRouters()
Variadic convenience for attaching several children at once.
public
includeRouters(Router ...$routers) : static
Each is validated independently; the first failure throws and
already-attached siblings stay attached (matches upstream's
for router in routers: self.include_router semantics — no
transaction).
Returns $this for fluent chaining at the parent (note: upstream's
include_routers returns None; the port returns the parent so
users can write (new Dispatcher())->includeRouters(...)->runPolling(...)).
Parameters
- $routers : Router
Return values
staticpreferWhenStateActive()
Mark this router as a scene router for active-state priority dispatch.
public
preferWhenStateActive([string|null $state = null ]) : static
Intended for Scene::asRouter(); exposed as a tiny fluent method so tests
and custom scene wiring can opt into the same traversal rule.
Parameters
- $state : string|null = null
Return values
staticpropagateEvent()
Route an event through the local observer; on UNHANDLED fall through to sub-routers in registration order.
public
propagateEvent(string $updateType, object $event[, array<string, mixed> $kwargs = [] ]) : mixed
Contract:
- Inject
event_router => $thisinto the kwargs bag so handlers and middlewares can see which router is currently dispatching (router.py:153). The inner-most claiming router is the value handlers see — each recursion overwrites the kwarg. - Look up the observer; throw
LogicExceptionon an unknown update type. Upstream silently returnsUNHANDLEDfor unknown keys; the port is strict because our observer map is schema-derived and a missing key is unambiguously a bug (typo'd literal, stale code after a Phase 2 regen, …). - Compose the local observer's outer middleware ONCE around an
inner closure that runs the local observer's raw
trigger()AND, on UNHANDLED, the depth-first sub-router walk. This is the Fix I2 shape — the parent observer's outer middleware covers sub-router handlers too. Mirrors upstreamRouter.propagate_event(router.py:152-166) which wraps_wrapped(containing the sub-router walk inside_propagate_event) withobserver.wrap_outer_middleware(...). - Non-UNHANDLED return short-circuits and is returned verbatim —
including
null,false,TelegramMethodinstances, etc.
Middleware integration: outer middleware on a router observer
wraps the entire local dispatch plus the sub-router walk. The
Dispatcher subclass wires UserContextMiddleware /
ErrorsMiddleware at the feedUpdate ingress layer (above
propagateEvent), so those middlewares run once per ingress
regardless of where the claiming handler lives. Per-observer outer
middleware registered via $observer->outerMiddleware(...) runs
once per propagateEvent call on the owning router (so a parent's
outer middleware wraps a child router's claiming handler too).
$event is typed object (not TelegramObject) because the same
propagation primitive carries synthetic dispatcher events such as
ErrorEvent, which deliberately do not extend TelegramObject.
Parameters
- $updateType : string
- $event : object
- $kwargs : array<string, mixed> = []
-
Dispatcher context bag (bot, event_context, …) merged into the handler invocation.
resolveUsedUpdateTypes()
Collect the snake_case names of every update type with at least one registered handler anywhere in the tree rooted at `$this`.
public
resolveUsedUpdateTypes([array<int, string> $skipEvents = [] ]) : array<int, string>
Used by the polling driver to compute allowed_updates for
getUpdates — Telegram only sends updates of types the bot
cares about, so this minimizes bandwidth. Matches upstream
resolve_used_update_types exactly:
- Excludes the
errorchannel (and the metaupdatetype) regardless of handlers — they're internal, not wire types. - Honors
$skipEventsfor caller-driven filtering on top of the internal exclusion. - Walks the full sub-router subtree (depth-first), de-duped via associative-array keys (PHP's set substitute).
Upstream returns a sorted list; the port returns the keys in
walk order so the result is deterministic per tree shape. Callers
that need sorting can sort($result) themselves.
Parameters
- $skipEvents : array<int, string> = []
-
Additional update types to omit.
Return values
array<int, string>hasScenePriorityForStateInSubtree()
private
hasScenePriorityForStateInSubtree(string $rawState) : bool
Parameters
- $rawState : string
Return values
boolmatchesScenePriorityState()
private
matchesScenePriorityState(string $rawState) : bool
Parameters
- $rawState : string
Return values
boolpropagateEventInternal()
private
propagateEventInternal(string $updateType, object $event[, array<string, mixed> $kwargs = [] ][, bool $skipScenePriority = false ]) : mixed
Parameters
- $updateType : string
- $event : object
- $kwargs : array<string, mixed> = []
- $skipScenePriority : bool = false
propagateScenePrioritySubtree()
Walk only scene-priority descendants matching the active FSM state.
private
static propagateScenePrioritySubtree(array<int, Router> $routers, string $rawState, string $updateType, object $event, array<string, mixed> $kwargs) : mixed
Matching child subtrees are entered through their normal propagation path exactly once. Inside that subtree, the same rule gives the matching scene router the first chance, then falls back to that subtree's local handlers without re-running outer middleware.
Parameters
- $routers : array<int, Router>
- $rawState : string
- $updateType : string
- $event : object
- $kwargs : array<string, mixed>