phpbotgram

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 finalDispatcher (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

$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', ...).

$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.

$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

$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
  1. 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.
  2. Re-parenting — once a router has a parent it stays put. The alternative (detach + re-attach) would silently leave the old parent's subRouters array holding a dangling reference.
  3. Cycles — A→B→C→A would make propagateEvent infinitely recurse. We walk our own ancestor chain (parentRouter upward) 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
Router

includeRouters()

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
static

preferWhenStateActive()

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
static

propagateEvent()

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:

  1. Inject event_router => $this into 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.
  2. Look up the observer; throw LogicException on an unknown update type. Upstream silently returns UNHANDLED for 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, …).
  3. 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 upstream Router.propagate_event (router.py:152-166) which wraps _wrapped (containing the sub-router walk inside _propagate_event) with observer.wrap_outer_middleware(...).
  4. Non-UNHANDLED return short-circuits and is returned verbatim — including null, false, TelegramMethod instances, 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 error channel (and the meta update type) regardless of handlers — they're internal, not wire types.
  • Honors $skipEvents for 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
bool

matchesScenePriorityState()

private matchesScenePriorityState(string $rawState) : bool
Parameters
$rawState : string
Return values
bool

propagateEventInternal()

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>
On this page

Search results