phpbotgram

Dispatcher extends Router
in package

Root router with polling/webhook entry points — port of `aiogram.dispatcher.dispatcher.Dispatcher`.

Extends Router with three responsibilities:

  1. Default middleware wiring. The constructor populates $dispatcherMiddlewares with UserContextMiddleware (first) and ErrorsMiddleware (second). feedUpdate composes the chain around its terminal propagateEvent call once per ingress, so a multi-router tree never sees these middlewares re-wrapped at each propagateEvent recursion. Order matters: the user-context middleware injects the event_context / event_from_user / event_chat / event_thread_id kwargs before ErrorsMiddleware runs, 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.

  2. Update ingress entry points. feedUpdate is the canonical synchronous dispatch; feedRawUpdate deserialises a wire-shaped payload via Serializer::load first; feedWebhookUpdate is the HTTP-webhook variant — it runs the dispatch chain inside a 55-second budget (WEBHOOK_TIMEOUT_SECONDS), surfaces an in-time TelegramMethod as the inline response, and routes a late-arriving method through silentCallRequest so the bot still issues the API call. The deadline is configurable per-instance via the constructor for tests that need a tight value.

  3. Webhook fall-through silentCallRequest. Public instance method (deviation from upstream's @classmethod for testability — see RecordingDispatcher in 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.update observer and routes inside _listen_update. The port stores the dispatcher-level chain on a private $dispatcherMiddlewares list and wraps it around propagateEvent inside feedUpdate, achieving the same wire shape (middleware wraps the whole tree exactly once) without an extra synthetic observer.
  • FSM auto-wiring. When $disableFsm is false (the default) the constructor builds a FsmContextMiddleware from the supplied (or defaulted) $storage / $fsmStrategy / $eventsIsolation parameters and registers it as an outer middleware on every Telegram observer except error. Pass disableFsm: true to skip this wiring entirely (useful for bots that have no state-gated handlers).
  • Bot::setCurrent instead of with bot.context():. PHP's FiberLocal (via Revolt\EventLoop\FiberLocal) is the closest analogue to Python's contextvars. 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

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

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

$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):

  1. $this->workflowData — dispatcher-scoped defaults
  2. $kwargs — caller-supplied per-call overrides
  3. injected event_update (always the resolved Update) and bot (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
throws
UpdateTypeLookupException

when the Update has no recognised event slot.

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 a TelegramMethod, return it so the caller (the webhook HTTP adapter) can encode it as the response body. Any other return value (string, sentinel, null) collapses to null — 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's warnings.warn(..., RuntimeWarning) at dispatcher.py:462-468), attach a continuation that routes any eventual TelegramMethod through silentCallRequest (so the side effect still reaches Telegram via a normal API call), and return null immediately 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\awaitFirst against an async(fn() => $this->feedUpdate(...)) task and an async(delay) timer. Upstream uses loop.call_later + loop.create_future to 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\TimeoutCancellation here: 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 eventual TelegramMethod. 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
$bot : Bot
$update : array<string, mixed>|Update

Already-deserialised Update or a wire-shaped (snake_case) associative array.

$kwargs : array<string, mixed> = []

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

listenUpdates()

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 exact retryAfter seconds the API advertised, then retry without consulting the backoff. This is the explicit flood-wait contract — backoff growth would be wrong.
  • RestartingTelegram / TelegramNetworkException: route through Backoff::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. Each feedUpdate runs inline; the next Update is consumed only after the previous handler returns.
  • handleAsTasks === int n => concurrent with LocalSemaphore of size n. Each Update spawns a fiber that acquires the semaphore, runs feedUpdate, 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
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>

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:

  1. The $runningLock mutex serialises the isPolling check / set so a second concurrent startPolling call sees the flag and raises LogicException. Mirrors upstream async with self._running_lock: at dispatcher.py:558.
  2. emitStartup and emitShutdown are called once each, around the fan-out, with bots => $bots injected per spec § "Polling loop". The shutdown closes every bot's session as a final cleanup step.
  3. 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
throws
LogicException

if polling is already in progress on this Dispatcher.

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 startPolling has been seen on this Dispatcher ($hasEverStarted === false). We raise RuntimeException("Polling is not started") — parity with upstream's if not self._running_lock.locked(): raise RuntimeError("Polling is not started").
  • Active polling: $stopSignal is present. We complete it (if not already complete — multi-stop is idempotent) and then await $drainedSignal outside 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's if not self._stop_signal or not self._stopped_signal: return at dispatcher.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
throws
RuntimeException

when no startPolling has ever been called on this Dispatcher.

storage()

Return the FSM storage instance.

public storage() : BaseStorage

Mirrors upstream Dispatcher.storage property (dispatcher.py:108).

Tags
throws
BadMethodCallException

When FSM is disabled (disableFsm: true).

Return values
BaseStorage

installSignalHandlers()

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.

Return values
array<int, string>
On this page

Search results