Dispatcher
extends Router
in package
Root router with polling/webhook entry points — port of `aiogram.dispatcher.dispatcher.Dispatcher`.
Extends Router with three responsibilities:
-
Default middleware wiring. The constructor populates
$dispatcherMiddlewareswithUserContextMiddleware(first) andErrorsMiddleware(second).feedUpdatecomposes the chain around its terminalpropagateEventcall once per ingress, so a multi-router tree never sees these middlewares re-wrapped at eachpropagateEventrecursion. Order matters: the user-context middleware injects theevent_context/event_from_user/event_chat/event_thread_idkwargs beforeErrorsMiddlewareruns, so an error handler that catches a handler exception sees the same context shape any other observer would.The error observer is left untouched at construction — observers own their per-event inner / outer chains; the dispatcher-level chain runs above them at the ingress.
-
Update ingress entry points.
feedUpdateis the canonical synchronous dispatch;feedRawUpdatedeserialises a wire-shaped payload viaSerializer::loadfirst;feedWebhookUpdateis the HTTP-webhook variant — it runs the dispatch chain inside a 55-second budget (WEBHOOK_TIMEOUT_SECONDS), surfaces an in-timeTelegramMethodas the inline response, and routes a late-arriving method throughsilentCallRequestso the bot still issues the API call. The deadline is configurable per-instance via the constructor for tests that need a tight value. -
Webhook fall-through
silentCallRequest. Public instance method (deviation from upstream's@classmethodfor testability — seeRecordingDispatcherin tests/Support). Default behaviour is$bot($method); subclasses override to capture invocations or suppress side effects under test.
Spec deviations from upstream:
- No synthetic 'update' observer. Upstream attaches every middleware
to a single
self.updateobserver and routes inside_listen_update. The port stores the dispatcher-level chain on a private$dispatcherMiddlewareslist and wraps it aroundpropagateEventinsidefeedUpdate, achieving the same wire shape (middleware wraps the whole tree exactly once) without an extra synthetic observer. - FSM auto-wiring. When
$disableFsmisfalse(the default) the constructor builds aFsmContextMiddlewarefrom the supplied (or defaulted)$storage/$fsmStrategy/$eventsIsolationparameters and registers it as an outer middleware on every Telegram observer excepterror. PassdisableFsm: trueto skip this wiring entirely (useful for bots that have no state-gated handlers). Bot::setCurrentinstead ofwith bot.context():. PHP's FiberLocal (viaRevolt\EventLoop\FiberLocal) is the closest analogue to Python'scontextvars. The try/finally guard ensures the binding is unset even when the dispatch raises.
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.
- WEBHOOK_TIMEOUT_SECONDS : float = 55.0
- Webhook response deadline in seconds. Mirrors upstream's `feed_webhook_update(_timeout=55, ...)` default at `dispatcher.py:440`. Telegram closes the webhook connection at 60 seconds, so 55s gives ~5s of headroom for the HTTP write-back.
- SCHEMA_FIELD_FOR_TYPE : array<string|int, mixed> = ['message' => 'message', 'edited_message' => 'e...
- Maps wire-name `update_type` keys to the camelCase PHP property name on `Update`. Derived from `Types/Update.php` (Phase 2 codegen output); kept in sync with `Router::UPDATE_TYPES`.
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(...)`).
- $fsm : FsmContextMiddleware|null
- The FSM context middleware auto-wired at construction time.
- $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.
- $workflowData : array<string, mixed>
- Workflow-scoped context shared across handlers. Mirrors upstream's `self.workflow_data: dict[str, Any]` (`dispatcher.py:99`). Every `feedUpdate` call merges this into the handler kwargs alongside the per-call `$kwargs`; per-call kwargs win on key collision.
- $dispatcherMiddlewares : array<int, BaseMiddleware>
- Dispatcher-level middleware chain wrapped around `propagateEvent` inside `feedUpdate` once per ingress. Mirrors upstream's `self.update.outer_middleware([...])` at `dispatcher.py:80-84` which composes the same chain around a single synthetic 'update' observer.
- $drainedSignal : DeferredFuture<string|int, null>|null
- Drain barrier completed once the polling awaiter's finally block has fully unwound (emitShutdown + bot session close + final state reset).
- $handleUpdateTasks : array<int, Future<string|int, void>>
- In-flight handler-dispatch fibers, keyed by `spl_object_id($future)`.
- $hasEverStarted : bool
- Sticky "polling has been started at least once" flag set inside `startPolling` (after the single-instance guard accepts) and never cleared. Lets `stopPolling` distinguish "never started → throw" from "already cleanly stopped → no-op". Mirrors upstream's reliance on `_stop_signal` and `_stopped_signal` staying non-None after the first start (`dispatcher.py:559-562`'s `if self._stop_signal is None` guard — the slots only get None'd once, at construction).
- $insidePollingFiber : FiberLocal<string|int, bool>
- Fiber-local "we're inside a polling fiber" flag. Set to `true` at the entry of `pollingFor` and inside each per-update async closure in concurrent mode; cleared via try/finally on exit. `stopPolling` inspects the flag: when `true`, the drain await is **skipped** — awaiting the drain from inside the polling fiber would deadlock because the drain only completes when the polling fiber exits.
- $isPolling : bool
- Boolean toggled inside the `$runningLock` critical section. `true` between `startPolling` entry (after the guard accepts) and the finally-block exit. `stopPolling` reads it via the mutex to decide whether the call is meaningful — calls when no polling is in flight are silently no-op (matches upstream `_signal_stop_polling`'s "if not locked return" guard at `dispatcher.py:512-513`).
- $runningLock : LocalMutex
- Single-instance guard for the polling driver. Acquired in `startPolling` before the `$isPolling` flag is set so a second concurrent invocation on the same Dispatcher can detect and reject. Mirrors upstream `self._running_lock: asyncio.Lock` (`dispatcher.py:100`).
- $stopSignal : DeferredFuture<string|int, null>|null
- Shared cancellation signal across every per-bot polling fiber. `null` when no polling is in flight; replaced with a fresh DeferredFuture on each `startPolling` call (so a Dispatcher can be re-started after a graceful shutdown). Resolved by `stopPolling` and by the SIGTERM / SIGINT signal handlers.
- $webhookTimeoutSeconds : float
- Per-instance webhook deadline in seconds. Defaults to `self::WEBHOOK_TIMEOUT_SECONDS` (55.0) when the constructor argument is omitted. Tests tighten this to e.g. 0.05s so the slow-handler branch can be exercised without sleeping for nearly a minute.
Methods
- __construct() : mixed
- emitShutdown() : void
- Symmetric counterpart of `emitStartup` for graceful teardown.
- emitStartup() : void
- Fire the startup lifecycle hook depth-first across the tree.
- feedRawUpdate() : mixed
- Convenience: deserialise a raw payload (typically the JSON-decoded webhook body or a `getUpdates` array element) to an `Update`, then delegate to `feedUpdate`.
- feedUpdate() : mixed
- Top-level synchronous dispatch entry. Resolves the wire update_type from the `Update`, reads the child event slot, binds the bot via `Bot::setCurrent` (FiberLocal), composes the dispatcher-level middleware chain around `propagateEvent`, and dispatches with the merged kwargs bag.
- feedWebhookUpdate() : mixed
- Webhook variant — runs the dispatcher chain inside the 55-second webhook deadline (configurable per Dispatcher via the constructor) and surfaces either a `TelegramMethod` (inline response) or `null` (empty response). Port of `aiogram.Dispatcher.feed_webhook_update` (`dispatcher.py:436-493`).
- 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.
- listenUpdates() : Generator<mixed, void>
- Endless updates reader — port of `aiogram._listen_updates` (`dispatcher.py:198-253`).
- pollingFor() : void
- Internal polling driver for a single bot — port of `aiogram._polling` (`dispatcher.py:354-418`). Consumes `listenUpdates` and dispatches each Update via `feedUpdate`, honoring `handleAsTasks` for concurrent fan-out.
- 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`.
- runPolling() : void
- Synchronous polling driver — awaits the Future returned by `startPolling` and installs SIGTERM / SIGINT handlers that resolve the shared stop signal. Mirrors upstream `run_polling` (`dispatcher.py:632-684`).
- silentCallRequest() : mixed
- Webhook fall-through: dispatch a method via `$bot($method)` when the inline-response window has closed. Invoked by `feedWebhookUpdate`'s map continuation when the dispatch chain finishes *after* the 55-second deadline and the eventual result is a `TelegramMethod`. Also invoked from `pollingFor` when a handler returns a `TelegramMethod` (the polling-side analogue of the webhook inline response).
- startPolling() : Future<string|int, void>
- Spawn polling for one or more bots. Returns a Future that resolves when every per-bot polling fiber has finished — i.e. after `stopPolling()` (or a SIGTERM/SIGINT) has fired and the loops have drained their current round.
- stopPolling() : void
- Signal the polling loop to stop, then BLOCK the caller until the polling fibers have actually drained (emitShutdown finished, every bot session closed, internal state reset). Safe to call from any fiber or from a signal handler.
- storage() : BaseStorage
- Return the FSM storage instance.
- installSignalHandlers() : array<int, string>
- Register SIGTERM + SIGINT handlers that resolve `$stopSignal`. Returns the event-loop callback ids so `runPolling` can cancel them on exit (otherwise the loop holds a reference that prevents shutdown of the fresh driver in tests).
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.
WEBHOOK_TIMEOUT_SECONDS
Webhook response deadline in seconds. Mirrors upstream's `feed_webhook_update(_timeout=55, ...)` default at `dispatcher.py:440`. Telegram closes the webhook connection at 60 seconds, so 55s gives ~5s of headroom for the HTTP write-back.
public
float
WEBHOOK_TIMEOUT_SECONDS
= 55.0
Exposed as a public const so subclasses, tests, and webhook
adapters can read the canonical value without instantiating the
Dispatcher. The per-instance constructor argument overrides this
for tests that need a tight deadline; production code should use
the default.
SCHEMA_FIELD_FOR_TYPE
Maps wire-name `update_type` keys to the camelCase PHP property name on `Update`. Derived from `Types/Update.php` (Phase 2 codegen output); kept in sync with `Router::UPDATE_TYPES`.
private
array<string|int, mixed>
SCHEMA_FIELD_FOR_TYPE
= ['message' => 'message', 'edited_message' => 'editedMessage', 'channel_post' => 'channelPost', 'edited_channel_post' => 'editedChannelPost', 'business_connection' => 'businessConnection', 'business_message' => 'businessMessage', 'edited_business_message' => 'editedBusinessMessage', 'deleted_business_messages' => 'deletedBusinessMessages', 'guest_message' => 'guestMessage', 'message_reaction' => 'messageReaction', 'message_reaction_count' => 'messageReactionCount', 'inline_query' => 'inlineQuery', 'chosen_inline_result' => 'chosenInlineResult', 'callback_query' => 'callbackQuery', 'shipping_query' => 'shippingQuery', 'pre_checkout_query' => 'preCheckoutQuery', 'purchased_paid_media' => 'purchasedPaidMedia', 'poll' => 'poll', 'poll_answer' => 'pollAnswer', 'my_chat_member' => 'myChatMember', 'chat_member' => 'chatMember', 'chat_join_request' => 'chatJoinRequest', 'chat_boost' => 'chatBoost', 'removed_chat_boost' => 'removedChatBoost', 'managed_bot' => 'managedBot']
Why the duplicate map: Router::UPDATE_TYPES lists the wire names for
iteration / allowed_updates resolution. The dispatcher additionally
needs to read the resolved event off the Update instance by
property name, which is camelCase in PHP (snake_case on the wire). A
single lookup table here is cheaper than running NameMapper::camelize
per dispatch — and any drift from the Update schema is caught by
DispatcherTest::testInheritsObserverMapShapeFromRouter together with
RouterTest::testUpdateTypesConstantMatchesUpdateSchema.
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', ...).
$fsm read-only
The FSM context middleware auto-wired at construction time.
public
FsmContextMiddleware|null
$fsm
Non-null when FSM is enabled ($disableFsm = false); null when FSM
is disabled. Exposed so callers can call $dispatcher->fsm->close()
directly (or read FSM options in tests). Mirrors upstream's
self.fsm: FSMContextMiddleware property at dispatcher.py:105.
Use $dispatcher->storage() as a shorthand for the storage accessor.
$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.
$workflowData
Workflow-scoped context shared across handlers. Mirrors upstream's `self.workflow_data: dict[str, Any]` (`dispatcher.py:99`). Every `feedUpdate` call merges this into the handler kwargs alongside the per-call `$kwargs`; per-call kwargs win on key collision.
public
array<string, mixed>
$workflowData
= []
Mutable so callers can write dispatcher.workflowData['db'] = $pdo
during setup. Spec § "Injected dispatcher kwargs" pins the contract.
$dispatcherMiddlewares
Dispatcher-level middleware chain wrapped around `propagateEvent` inside `feedUpdate` once per ingress. Mirrors upstream's `self.update.outer_middleware([...])` at `dispatcher.py:80-84` which composes the same chain around a single synthetic 'update' observer.
private
array<int, BaseMiddleware>
$dispatcherMiddlewares
The port wires the chain here (instead of on every observer) to fix
the C1 regression: with per-observer wiring, Router::propagateEvent
recursing through sub-routers wrapped each trigger() call with the
same middleware AGAIN — UserContextMiddleware::resolveContext()
fired twice for a 2-router tree and ErrorsMiddleware caught each
handler exception twice.
Order: UserContextMiddleware first so subsequent links see the
canonical event_context keys populated; ErrorsMiddleware second
so its catch wraps user-context resolution.
$drainedSignal
Drain barrier completed once the polling awaiter's finally block has fully unwound (emitShutdown + bot session close + final state reset).
private
DeferredFuture<string|int, null>|null
$drainedSignal
= null
stopPolling() awaits this future OUTSIDE the runningLock so callers
observe a fully-drained dispatcher on return.
Mirrors upstream self._stopped_signal: Event at dispatcher.py:102;
stop_polling calls await self._stopped_signal.wait() at
dispatcher.py:509 after setting _stop_signal. The port uses a
DeferredFuture because amphp v3 has no "Event"-equivalent primitive
and the carried value is unused — only the completed status matters.
Lifecycle: created together with $stopSignal at startPolling entry;
completed inside the awaiter's finally block right after the lock-
protected state reset (so by the time getFuture()->await() resolves
in another fiber, $isPolling is false and the session is closed).
$handleUpdateTasks
In-flight handler-dispatch fibers, keyed by `spl_object_id($future)`.
private
array<int, Future<string|int, void>>
$handleUpdateTasks
= []
Populated by pollingFor when handleAsTasks requests concurrent
dispatch; each entry self-cleans via Future::finally. Mirrors
upstream self._handle_update_tasks: set[asyncio.Task]
(dispatcher.py:103).
$hasEverStarted
Sticky "polling has been started at least once" flag set inside `startPolling` (after the single-instance guard accepts) and never cleared. Lets `stopPolling` distinguish "never started → throw" from "already cleanly stopped → no-op". Mirrors upstream's reliance on `_stop_signal` and `_stopped_signal` staying non-None after the first start (`dispatcher.py:559-562`'s `if self._stop_signal is None` guard — the slots only get None'd once, at construction).
private
bool
$hasEverStarted
= false
$insidePollingFiber read-only
Fiber-local "we're inside a polling fiber" flag. Set to `true` at the entry of `pollingFor` and inside each per-update async closure in concurrent mode; cleared via try/finally on exit. `stopPolling` inspects the flag: when `true`, the drain await is **skipped** — awaiting the drain from inside the polling fiber would deadlock because the drain only completes when the polling fiber exits.
private
FiberLocal<string|int, bool>
$insidePollingFiber
Without this signal, callers from a handler (which runs inside the polling fiber) would have to either:
- Skip stopPolling entirely (breaks tests that need to terminate the loop from a handler).
- Spawn
async(static fn => $dispatcher->stopPolling())AND ensure the polling fiber yields before its next batch fetch (real-world polling yields naturally on the HTTP transport; mocks don't).
Upstream's aiogram sidesteps this differently: it uses
asyncio.wait(return_when=FIRST_COMPLETED) on the polling tasks
together with the stop signal, then cancels the pending polling
tasks. The cancellation interrupts the handler's awaited
stop_polling(). The amphp v3 port doesn't have native Future
cancellation, so the FiberLocal pattern is the closest equivalent.
Initialised once per Dispatcher instance; the bool default models
"no value set yet" (≡ false at the call site).
$isPolling
Boolean toggled inside the `$runningLock` critical section. `true` between `startPolling` entry (after the guard accepts) and the finally-block exit. `stopPolling` reads it via the mutex to decide whether the call is meaningful — calls when no polling is in flight are silently no-op (matches upstream `_signal_stop_polling`'s "if not locked return" guard at `dispatcher.py:512-513`).
private
bool
$isPolling
= false
$runningLock read-only
Single-instance guard for the polling driver. Acquired in `startPolling` before the `$isPolling` flag is set so a second concurrent invocation on the same Dispatcher can detect and reject. Mirrors upstream `self._running_lock: asyncio.Lock` (`dispatcher.py:100`).
private
LocalMutex
$runningLock
Note: LocalMutex is single-fiber by nature — the mutex is only
meaningful inside an event loop. The mutex protects the
$isPolling / $stopSignal mutation site against another fiber
(or a signal handler that resumes a suspended fiber) racing to
read-modify-write the same fields.
$stopSignal
Shared cancellation signal across every per-bot polling fiber. `null` when no polling is in flight; replaced with a fresh DeferredFuture on each `startPolling` call (so a Dispatcher can be re-started after a graceful shutdown). Resolved by `stopPolling` and by the SIGTERM / SIGINT signal handlers.
private
DeferredFuture<string|int, null>|null
$stopSignal
= null
Type-narrowed to DeferredFuture<null> because the carried value is
unused — only the "completed" status matters.
$webhookTimeoutSeconds read-only
Per-instance webhook deadline in seconds. Defaults to `self::WEBHOOK_TIMEOUT_SECONDS` (55.0) when the constructor argument is omitted. Tests tighten this to e.g. 0.05s so the slow-handler branch can be exercised without sleeping for nearly a minute.
private
float
$webhookTimeoutSeconds
Stored as a positive float; the constructor accepts a nullable argument so callers that don't care can pass nothing instead of having to reference the constant. PHPStan reads the readonly modifier and enforces single-assignment in the ctor body.
Methods
__construct()
public
__construct([string|null $name = null ][, float|null $webhookTimeoutSeconds = null ][, BaseStorage|null $storage = null ][, FsmStrategy $fsmStrategy = FsmStrategy::UserInChat ][, BaseEventIsolation|null $eventsIsolation = null ][, bool $disableFsm = false ]) : mixed
Parameters
- $name : string|null = null
- $webhookTimeoutSeconds : float|null = null
- $storage : BaseStorage|null = null
- $fsmStrategy : FsmStrategy = FsmStrategy::UserInChat
- $eventsIsolation : BaseEventIsolation|null = null
- $disableFsm : bool = false
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.
feedRawUpdate()
Convenience: deserialise a raw payload (typically the JSON-decoded webhook body or a `getUpdates` array element) to an `Update`, then delegate to `feedUpdate`.
public
feedRawUpdate(Bot $bot, array<string, mixed> $rawUpdate[, array<string, mixed> $kwargs = [] ]) : mixed
Mirrors upstream feed_raw_update (dispatcher.py:186-195). The
Serializer::load call binds the bot context to the Update tree (every
nested TelegramObject sees $bot via its ?Bot $bot constructor
parameter), parity with upstream's Update.model_validate(..., context={"bot": bot}).
Parameters
- $bot : Bot
- $rawUpdate : array<string, mixed>
-
Wire-shaped (snake_case) payload.
- $kwargs : array<string, mixed> = []
-
Forwarded to feedUpdate.
feedUpdate()
Top-level synchronous dispatch entry. Resolves the wire update_type from the `Update`, reads the child event slot, binds the bot via `Bot::setCurrent` (FiberLocal), composes the dispatcher-level middleware chain around `propagateEvent`, and dispatches with the merged kwargs bag.
public
feedUpdate(Bot $bot, Update $update[, array<string, mixed> $kwargs = [] ]) : mixed
Kwargs precedence (last-wins on key collision):
$this->workflowData— dispatcher-scoped defaults$kwargs— caller-supplied per-call overrides- injected
event_update(always the resolved Update) andbot(always the bot argument). These two are dispatcher invariants and cannot be overridden by callers.
The Bot::setCurrent binding is wrapped in try/finally so the slot is
cleared even if the dispatch raises — without that guard a handler
exception would leave the binding pointing at the now-irrelevant bot
for the next dispatch on the same fiber.
Middleware wiring (C1 fix): the dispatcher-level chain
(UserContextMiddleware + ErrorsMiddleware) wraps the terminal
propagateEvent call exactly once. Prior to the fix the chain was
attached to every observer at construction, which meant
propagateEvent's sub-router recursion re-wrapped each child
observer's trigger() with the same chain — doubling
resolveContext() runs and duplicating error handling. Wrapping
once at the ingress mirrors upstream's self.update.wrap_outer_middleware
shape (dispatcher.py:164-172).
Parameters
- $bot : Bot
- $update : Update
- $kwargs : array<string, mixed> = []
-
Per-call context (state, fsm_storage, …).
Tags
feedWebhookUpdate()
Webhook variant — runs the dispatcher chain inside the 55-second webhook deadline (configurable per Dispatcher via the constructor) and surfaces either a `TelegramMethod` (inline response) or `null` (empty response). Port of `aiogram.Dispatcher.feed_webhook_update` (`dispatcher.py:436-493`).
public
feedWebhookUpdate(Bot $bot, array<string, mixed>|Update $update[, array<string, mixed> $kwargs = [] ]) : mixed
Two branches:
- In-time: the chain completes within
WEBHOOK_TIMEOUT_SECONDS. If the result is aTelegramMethod, return it so the caller (the webhook HTTP adapter) can encode it as the response body. Any other return value (string, sentinel, null) collapses tonull— the adapter then writes an empty JSON}. - Deadline expired: the chain has NOT completed by the time the
55-second timer fires. We emit
trigger_error("Detected slow response into webhook…", E_USER_WARNING)(parity with upstream'swarnings.warn(..., RuntimeWarning)atdispatcher.py:462-468), attach a continuation that routes any eventualTelegramMethodthroughsilentCallRequest(so the side effect still reaches Telegram via a normal API call), and returnnullimmediately so the webhook adapter doesn't keep the HTTP socket open past the deadline.
Update|array overload (Fix I3): the $update parameter accepts
either an already-deserialised Update instance or a wire-shaped
associative array (the typical HTTP body decoded via
json_decode($body, true)). The array form hydrates via
Serializer::load(Update::class, ...) before the dispatch runs —
mirrors upstream's dispatcher.py:443-444 overload.
Implementation notes:
- The race is implemented via
Amp\Future\awaitFirstagainst anasync(fn() => $this->feedUpdate(...))task and anasync(delay)timer. Upstream usesloop.call_later+loop.create_futureto model the same primitive; awaitFirst is the canonical amphp v3 equivalent and reads cleaner than spinning a manual DeferredFuture. - We deliberately do NOT use
Amp\TimeoutCancellationhere: cancellation would interrupt the dispatch fiber, but the spec requires the dispatch to continue running in the background so the fall-through continuation can route any eventualTelegramMethod. The race-with-a-timer pattern preserves the in-flight fiber. - The timer task uses
ignore()so a never-awaited timeout future (the happy path where the dispatch wins) doesn't surface an "unhandled future" warning at GC time. - The dispatch task's
map()callback is attached after the race resolves. amphp guarantees the callback fires on completion even if the future is already complete — but in the timeout branch the dispatch is still in flight at attachment time. The callback runs on the same event loop driver that's hosting the dispatch fiber, so no cross-thread synchronisation is needed.
Parameters
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
staticlistenUpdates()
Endless updates reader — port of `aiogram._listen_updates` (`dispatcher.py:198-253`).
public
listenUpdates(Bot $bot, PollingOptions $options) : Generator<mixed, void>
Implementation is a Generator (PHP generators are Fiber-safe under
Revolt). Each iteration calls getUpdates(timeout: pollingTimeout)
inside a try/catch. On success the backoff is reset, every returned
Update is yielded to the caller, and offset advances by
updateId + 1 (Telegram's confirm-by-incrementing-offset protocol).
Retry semantics on failure mirror upstream:
TelegramRetryAfter: sleep for the exactretryAfterseconds the API advertised, then retry without consulting the backoff. This is the explicit flood-wait contract — backoff growth would be wrong.RestartingTelegram/TelegramNetworkException: route throughBackoff::asleep()so concurrent bots don't retry in lockstep.- Any other Throwable is re-raised — it's a bug at the dispatch layer, not a transient API hiccup. Upstream catches everything; the port narrows the catch because a typed dispatch path is in scope here (TelegramApiException hierarchy), and ErrorsMiddleware will already have unwound user-level errors before they reach this loop.
Loop termination: between rounds we inspect $stopSignal->isComplete().
Inside a Telegram long-poll the loop is parked in the HTTP transport
(or in delay() during a retry sleep). The signal fires the fiber that
called stopPolling; the polling fiber notices on its next round.
Parameters
- $bot : Bot
- $options : PollingOptions
Return values
Generator<mixed, void>pollingFor()
Internal polling driver for a single bot — port of `aiogram._polling` (`dispatcher.py:354-418`). Consumes `listenUpdates` and dispatches each Update via `feedUpdate`, honoring `handleAsTasks` for concurrent fan-out.
public
pollingFor(Bot $bot, PollingOptions $options) : void
Three modes (collapsed from upstream's handle_as_tasks bool +
tasks_concurrency_limit int|None pair):
handleAsTasks === null=> serial. EachfeedUpdateruns inline; the next Update is consumed only after the previous handler returns.handleAsTasks === int n=> concurrent withLocalSemaphoreof size n. Each Update spawns a fiber that acquires the semaphore, runsfeedUpdate, and releases on completion.
Spawned fibers are tracked in $this->handleUpdateTasks keyed by
spl_object_id so a future Future::cancel pass on shutdown (added in
the spec but not exposed by amphp v3's Future directly) can reap
them. The keyed map is also opportunistically purged each round so a
long-running polling session doesn't accumulate completed-future
references.
Handler exceptions are not caught here — ErrorsMiddleware
(wired onto every observer at Dispatcher construction) already does
the catch and either invokes the error observer or re-raises. A
truly uncaught exception will surface to the awaiter of the spawned
fiber's Future or, in serial mode, terminate the polling loop. The
latter matches upstream's _process_update semantics, where the
Exception logger swallows everything inside the inner try.
Parameters
- $bot : Bot
- $options : PollingOptions
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
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>runPolling()
Synchronous polling driver — awaits the Future returned by `startPolling` and installs SIGTERM / SIGINT handlers that resolve the shared stop signal. Mirrors upstream `run_polling` (`dispatcher.py:632-684`).
public
runPolling(PollingOptions $options, Bot ...$bots) : void
Signal handling is best-effort: EventLoop::onSignal requires the
pcntl extension at minimum, and may throw
UnsupportedFeatureException on Windows or in PHP builds without
pcntl. We swallow the throw silently — parity with upstream's
with suppress(NotImplementedError): block.
Parameters
- $options : PollingOptions
- $bots : Bot
silentCallRequest()
Webhook fall-through: dispatch a method via `$bot($method)` when the inline-response window has closed. Invoked by `feedWebhookUpdate`'s map continuation when the dispatch chain finishes *after* the 55-second deadline and the eventual result is a `TelegramMethod`. Also invoked from `pollingFor` when a handler returns a `TelegramMethod` (the polling-side analogue of the webhook inline response).
public
silentCallRequest(Bot $bot, TelegramMethod<string|int, mixed> $method) : mixed
Public instance method (deviation from upstream's @classmethod)
so tests can override it via RecordingDispatcher to capture the
fall-through invocations without driving a real network call.
Upstream's unittest.mock.patch of a class method does not translate
cleanly to PHP. See spec § "Webhook response contract" for the
rationale.
Return type deviation from spec: the port returns mixed, not
void. Upstream's silent_call_request returns whatever
await bot(result) resolves to (the TelegramMethod's
ReturnsType), so a typed mixed is faithful to upstream — the
spec's void was incorrect. Subclasses such as RecordingDispatcher
lean on the mixed to return a sentinel (null) without driving the
real bot call.
TelegramApiException handling: a transient API failure from the
underlying call (chat gone, message already deleted, bot blocked,
etc.) MUST NOT kill the caller. In serial polling the next update
is unrelated and the loop should keep going; on the webhook
fall-through path the request lifecycle is already over and the
failure has nowhere to surface. We mirror upstream's
silent_call_request (aiogram/dispatcher/dispatcher.py:294-301)
which catches TelegramAPIError and logs — the port emits an
E_USER_WARNING (the project-wide RuntimeWarning analogue, see also
Fix C2 / Fix I1) and returns null. Any non-API throwable
(programming errors, fiber-level failures) still propagates so the
upstream layers / ErrorsMiddleware can react.
Parameters
- $bot : Bot
- $method : TelegramMethod<string|int, mixed>
Return values
mixed —Whatever $bot($method) resolves to (the method's
declared ReturnsType), or null when a
TelegramApiException was swallowed. Subclass
overrides may return any value, including null to
suppress side effects.
startPolling()
Spawn polling for one or more bots. Returns a Future that resolves when every per-bot polling fiber has finished — i.e. after `stopPolling()` (or a SIGTERM/SIGINT) has fired and the loops have drained their current round.
public
startPolling(PollingOptions $options, Bot ...$bots) : Future<string|int, void>
The spec mandates (PollingOptions $options, Bot ...$bots) order
because PHP forbids any parameter following a variadic — the mission
brief's (Bot $bot, ..., Bot ...$additionalBots) shape would also
trigger a "Variadic parameter must be the last parameter" parse error.
Concurrency contract:
- The
$runningLockmutex serialises theisPollingcheck / set so a second concurrentstartPollingcall sees the flag and raisesLogicException. Mirrors upstreamasync with self._running_lock:atdispatcher.py:558. emitStartupandemitShutdownare called once each, around the fan-out, withbots => $botsinjected per spec § "Polling loop". The shutdown closes every bot's session as a final cleanup step.- Per-bot polling fibers are spawned via
Amp\async; the returned Future awaits all of them (success or first error).
Parameters
- $options : PollingOptions
- $bots : Bot
Tags
Return values
Future<string|int, void>stopPolling()
Signal the polling loop to stop, then BLOCK the caller until the polling fibers have actually drained (emitShutdown finished, every bot session closed, internal state reset). Safe to call from any fiber or from a signal handler.
public
stopPolling() : void
Fix I3: contract mirrors upstream's stop_polling at
dispatcher.py:497-509:
- Never started: no
startPollinghas been seen on this Dispatcher ($hasEverStarted === false). We raiseRuntimeException("Polling is not started")— parity with upstream'sif not self._running_lock.locked(): raise RuntimeError("Polling is not started"). - Active polling:
$stopSignalis present. We complete it (if not already complete — multi-stop is idempotent) and then await$drainedSignaloutside the lock so the caller observes a fully-drained dispatcher on return. - Already cleanly stopped:
$hasEverStarted === true && $drainedSignal === null— the dispatcher ran a polling round to completion before. We return silently (no throw, no await), parity with upstream'sif not self._stop_signal or not self._stopped_signal: returnatdispatcher.py:506-507.
Inside the polling fiber: calling stopPolling from a handler
is supported via the $insidePollingFiber FiberLocal flag — the
drain await is skipped, so the call completes the stop signal
synchronously and returns immediately. The polling fiber's
post-yield check then unwinds the loop. Without the flag the call
would deadlock (the drain only completes when the polling fiber
exits, but the polling fiber would be parked here).
The mutex round-trip is the canonical way to read/mutate the
$stopSignal slot without a torn read between concurrent fibers
(e.g. a SIGTERM handler firing while a user-level stopPolling call
is mid-flight). The drain await happens OUTSIDE the lock so a stop
fiber doesn't deadlock against the awaiter's finally block (which
itself acquires the lock to reset state).
Tags
storage()
Return the FSM storage instance.
public
storage() : BaseStorage
Mirrors upstream Dispatcher.storage property (dispatcher.py:108).
Tags
Return values
BaseStorageinstallSignalHandlers()
Register SIGTERM + SIGINT handlers that resolve `$stopSignal`. Returns the event-loop callback ids so `runPolling` can cancel them on exit (otherwise the loop holds a reference that prevents shutdown of the fresh driver in tests).
private
installSignalHandlers() : array<int, string>
EventLoop::onSignal throws UnsupportedFeatureException when the
pcntl extension is unavailable (Windows, some minimal PHP CLIs).
That's exactly upstream's NotImplementedError swallow case at
dispatcher.py:572; we mirror by skipping silently.