phpbotgram

TelegramEventObserver
in package

FinalYes

Routing observer for a single Telegram update type — port of `aiogram.dispatcher.event.telegram.TelegramEventObserver`.

Each Router owns one of these per Bot API event key (message, callback_query, inline_query, …). It tracks two collections:

  • Handlers — registered callbacks, each wrapped in a HandlerObject that carries its filter pipeline and flags. Dispatched in registration order; the first handler whose filters all accept claims the event and its return value is the trigger's result.
  • Global filters — predicates applied before any handler dispatch. A single rejection (false / null / []) short-circuits the entire chain to RejectedSentinel — no handlers are tried. Used by scenes to scope every handler on the observer to the current scene state.

The register() signature is a deliberate deviation from upstream's Python form register(callback, *filters, flags=None). PHP cannot place a named optional parameter after a variadic, and we want the friendly call-site register($cb, [$f1, $f2], ['admin_only' => true]) to work without quirky null-padding. Filters become a list<Closure> and flags become an array<string, mixed>; both are optional and positional.

__invoke() keeps the decorator-factory ergonomics: passed a callback it registers eagerly, otherwise it returns a registration closure. PHP lacks Python decorator syntax, but this two-shape __invoke lets us support both $obs($cb, flags: [...]) and the factory form $register = $obs(filters: [...]); $register($cb);.

The flags-merge semantics match upstream flags = {**extract_flags_from_object(cb), **flags}: attribute / WeakMap-attached flags are merged in first, then the manual $flags argument overlays them last (manual wins on key collision).

Middleware integration — the observer owns two MiddlewareManager collections that compose around the dispatch primitive:

  • $outerMiddleware wraps the entire observer (global filter chain plus the handler iteration) AND, when the observer is owned by a Router, the sub-router walk too — wrapping lives in Router::propagateEvent (Fix I2). A bare $observer->trigger() call skips the outer wrap (matches upstream TelegramEventObserver.trigger).
  • $innerMiddleware wraps each individual handler invocation (HandlerObject::call). This is where per-handler concerns like throttling, auth gates, and cache hits attach.

The split mirrors upstream's outer_middlewares / middlewares lists on TelegramEventObserver at aiogram/dispatcher/event/telegram.py:23-31. The two managers are independent — registering on one does NOT register on the other — and the outer chain has access to the kwargs the global filters would see, while the inner chain runs only after the per-handler filter check accepts.

Table of Contents

Properties

$eventName  : string
$filters  : array<int, FilterObject>
Global filters applied before any handler dispatch — every filter must accept (truthy result, i.e. `!$result === false`) for the handler chain to be considered. Append via `filter()`.
$handlers  : array<int, HandlerObject>
Registered handlers in declaration order. Exposed read-only so the dispatcher / introspection code can iterate; mutation goes through `register()` / `clear()`.
$innerMiddleware  : MiddlewareManager
Inner middleware chain — wraps each individual handler invocation.
$outerMiddleware  : MiddlewareManager
Outer middleware chain — wraps the entire observer (global filters + handler iteration) AND, when this observer is owned by a `Router`, the `Router::propagateEvent` sub-router walk (Fix I2). Registered via `outerMiddleware()`. Note: the chain is applied by `Router::propagateEvent`, NOT by `trigger()` — `trigger()` is the raw dispatch primitive (upstream parity).
$router  : Router|null
Back-reference to the owning `Router`. `null` when the observer is constructed standalone (legitimate for unit tests that don't need chain-head inheritance). Populated by `Router::__construct` for every observer in the schema-derived map so `resolveMiddlewares()` can walk the ancestor chain via `Router::$parentRouter`.

Methods

__construct()  : mixed
__invoke()  : callable(Closure): Closure
Dual-shape decorator entrypoint mirroring upstream's `__call__`.
clear()  : void
Drop every registered handler and global filter. Intended for test isolation and router rebuilds — production routing never calls this.
filter()  : void
Append global filters that gate every handler on this observer.
innerMiddleware()  : BaseMiddleware
Append a middleware to the **inner** chain (wraps each handler call).
outerMiddleware()  : BaseMiddleware
Append a middleware to the **outer** chain (wraps the whole observer).
register()  : Closure
Register a handler with optional per-handler filters and flag metadata.
resolveMiddlewares()  : array<int, BaseMiddleware>
Resolve every inner-middleware instance that should wrap each handler invocation on this observer — self's own plus every ancestor router's matching observer's inner middleware. Mirrors upstream's `_resolve_middlewares` (`telegram.py:49-56`):
trigger()  : mixed
Raw dispatch entry: runs global filters then iterates handlers (with per-handler filter check and inner-middleware wrapping). Does NOT apply outer middleware — that responsibility lives in `Router::propagateEvent`, which wraps the full local-observer + sub- router dispatch with `outerMiddleware->wrap()` once per propagation.
triggerCore()  : mixed
The actual dispatch primitive (post-outer-middleware): runs global filters then iterates handlers with per-handler filter check and inner-middleware wrapping.

Properties

$filters

Global filters applied before any handler dispatch — every filter must accept (truthy result, i.e. `!$result === false`) for the handler chain to be considered. Append via `filter()`.

public private(set) array<int, FilterObject> $filters = []

$handlers

Registered handlers in declaration order. Exposed read-only so the dispatcher / introspection code can iterate; mutation goes through `register()` / `clear()`.

public private(set) array<int, HandlerObject> $handlers = []

$innerMiddleware read-only

Inner middleware chain — wraps each individual handler invocation.

public MiddlewareManager $innerMiddleware

Registered via innerMiddleware(). Per-handler concerns (throttling, cache lookup, auth gates) attach here so the rest of the observer (global filter chain, per-handler filter pipeline) runs first.

$outerMiddleware read-only

Outer middleware chain — wraps the entire observer (global filters + handler iteration) AND, when this observer is owned by a `Router`, the `Router::propagateEvent` sub-router walk (Fix I2). Registered via `outerMiddleware()`. Note: the chain is applied by `Router::propagateEvent`, NOT by `trigger()` — `trigger()` is the raw dispatch primitive (upstream parity).

public MiddlewareManager $outerMiddleware

$router read-only

Back-reference to the owning `Router`. `null` when the observer is constructed standalone (legitimate for unit tests that don't need chain-head inheritance). Populated by `Router::__construct` for every observer in the schema-derived map so `resolveMiddlewares()` can walk the ancestor chain via `Router::$parentRouter`.

public Router|null $router

Mirrors upstream's TelegramEventObserver(router=self, event_name=...) constructor (telegram.py:26-28). The reference is ?Router not Router because the observer is sometimes built before its router (PHP property initializers run before the __construct body, but the observer's own ctor needs the router reference up-front), and null is the contract-safe default for unit-test instantiation.

Methods

__construct()

public __construct(string $eventName[, Router|null $router = null ]) : mixed
Parameters
$eventName : string

Wire-level Bot API key this observer routes for (message, callback_query, …). The dispatcher uses this when injecting the event_name kwarg into middleware data.

$router : Router|null = null

Back-reference to the owning router. Populated by Router::__construct; left null for tests that drive the observer in isolation.

__invoke()

Dual-shape decorator entrypoint mirroring upstream's `__call__`.

public __invoke([Closure|null $callback = null ][, array<int, Closure$filters = [] ][, array<string, mixed> $flags = [] ]) : callable(Closure): Closure
  • Eager form: $observer($cb, filters: [...], flags: [...]) registers immediately and returns the original callback.
  • Factory form: $register = $observer(filters: [...], flags: [...]); returns a registration closure to be invoked later with the callback.

The factory form is the PHP idiom for the Python decorator @router.message(F::text->equals('hi'), flags=[...]) — we can't apply a decorator with the @ syntax, but we can produce the same closure factory.

Parameters
$callback : Closure|null = null
$filters : array<int, Closure> = []
$flags : array<string, mixed> = []
Return values
callable(Closure): Closure

In factory mode, a closure that accepts the callback and registers it. In eager mode, the original callback (already registered).

clear()

Drop every registered handler and global filter. Intended for test isolation and router rebuilds — production routing never calls this.

public clear() : void

filter()

Append global filters that gate every handler on this observer.

public filter(Closure ...$filters) : void

Each closure is wrapped in a FilterObject (reflection-cached kwarg binding). Multiple filter() calls accumulate — the filters run in insertion order during trigger(), and the first one to reject (any falsy return) short-circuits the dispatch to RejectedSentinel.

Parameters
$filters : Closure

outerMiddleware()

Append a middleware to the **outer** chain (wraps the whole observer).

public outerMiddleware(BaseMiddleware $middleware) : BaseMiddleware

Thin wrapper around $outerMiddleware->register() that exists so Dispatcher's setup code reads $observer->outerMiddleware(new …) — matching upstream's observer.outer_middleware(...) call site.

Parameters
$middleware : BaseMiddleware
Return values
BaseMiddleware

register()

Register a handler with optional per-handler filters and flag metadata.

public register(Closure $callback[, array<int, callable> $filters = [] ][, array<string, mixed> $flags = [] ]) : Closure

The Python upstream uses register(callback, *filters, flags=None); PHP cannot place named-optional parameters after a variadic, so we flatten filters into a list<Closure> and flags into a positional associative array. Both default to empty.

Flag-merge order matches upstream:

  1. Read #[Flag] attributes and imperative FlagDecorator::attach() attachments from the callback (Flags::extractFlags).
  2. Overlay the manual $flags argument on top — manual wins on key collision ({**attribute, **manual} semantics).

Returns the original callback unchanged so registration sites can keep a reference for re-use (matches EventObserver::register() ergonomics).

Filter entries may be any callable — bare Closures, first-class callable expressions (fn() => true), or invokable objects such as Filter subclasses (new Command('start'), new StateFilter(...), a user Filter instance). Invokable objects are wrapped with a Closure::fromCallable adapter so the FilterObject invariant (Closure $callback) stays intact. Mirrors aiogram's aiogram.dispatcher.event.handler.FilterObject which accepts any Callable[..., Any].

Parameters
$callback : Closure
$filters : array<int, callable> = []

Per-handler filter pipeline. An empty list means "always accept" — fall-through to the handler itself.

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

Manual flag overrides. Merged with attribute / WeakMap-attached flags via Flags::extractFlags.

Return values
Closure

resolveMiddlewares()

Resolve every inner-middleware instance that should wrap each handler invocation on this observer — self's own plus every ancestor router's matching observer's inner middleware. Mirrors upstream's `_resolve_middlewares` (`telegram.py:49-56`):

public resolveMiddlewares() : array<int, BaseMiddleware>

for router in reversed(tuple(self.router.chain_head)): observer = router.observers.get(self.event_name) if observer: middlewares.extend(observer.middleware) return middlewares

chain_head walks self → parent → root; reversed then yields root → … → self, so the outermost link of the composed chain is the root router's middleware. The PHP port iterates the same order: we walk from $this->router upward, push each ancestor's matching observer's middleware onto an in-progress list, then return that list reversed.

Returns an empty list when $this->router is null (standalone observers used in unit tests carry their own inner middleware via $this->innerMiddleware only).

Return values
array<int, BaseMiddleware>

Composed innermost-to-outermost; callers pass to MiddlewareManager::wrap semantics (first element wraps outermost).

trigger()

Raw dispatch entry: runs global filters then iterates handlers (with per-handler filter check and inner-middleware wrapping). Does NOT apply outer middleware — that responsibility lives in `Router::propagateEvent`, which wraps the full local-observer + sub- router dispatch with `outerMiddleware->wrap()` once per propagation.

public trigger(object $event[, array<string, mixed> $kwargs = [] ]) : mixed

Mirrors upstream's TelegramEventObserver.trigger (telegram.py:111-130), which is also outer-middleware-free; upstream's Router.propagate_event (router.py:152-166) composes observer.wrap_outer_middleware(_wrapped, ...) once around _propagate_event's full body — so the parent observer's outer middleware covers child router handlers too (Fix I2).

The split exists because outer middleware must NOT wrap individual observer triggers in a multi-router tree (that would skip sub-router handlers from the outer-middleware scope and re-wrap each ancestor trigger when sub-router recursion bubbles back up). Anything wanting to drive this observer through the outer chain should call Router::propagateEvent instead.

$event is typed object because dispatcher-synthetic events (notably ErrorEvent, which deliberately does not extend TelegramObject) flow through the same dispatch primitive. Handler-declared parameter types are checked by CallableObject's reflection adapter when binding the event kwarg.

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

Dispatcher context (bot, event_context, state, …) merged with filter-result injections before reaching each handler.

Return values
mixed

The first claiming handler's return value, or UnhandledSentinel::instance() if every handler passed, or RejectedSentinel::instance() if a global filter rejected.

triggerCore()

The actual dispatch primitive (post-outer-middleware): runs global filters then iterates handlers with per-handler filter check and inner-middleware wrapping.

private triggerCore(object $event, array<string, mixed> $kwargs) : mixed

Sequence — matches upstream TelegramEventObserver.trigger:

  1. Iterate global $filters. Each runs against $event + $kwargs. A falsy result aborts dispatch with RejectedSentinel; an array result is merged into $kwargs for downstream filter and handler consumption.
  2. Iterate $handlers in registration order. For each: a. Inject 'handler' => $handler into kwargs (the dispatcher contract — handlers can declare HandlerObject $handler). b. Run the handler's filter pipeline via HandlerObject::check. Failure: continue to the next handler. c. Success: invoke the handler via the inner-middleware chain. If the result is UnhandledSentinel the handler explicitly opts out and we continue; otherwise its return value is the trigger result.
  3. If no handler claimed the event, return UnhandledSentinel.

The event kwarg is injected before filters run so filter and handler signatures can declare the event as a named parameter. Deviation from upstream's handler.call(event, **kwargs): we pass event only as a kwarg, never positional. The PHP kwarg-binding model uses parameter names, and forwarding event positionally AND as a kwarg collides with PHP 8.1+ "Named parameter overwrites previous argument" guard.

Sentinel return values are intentionally distinct objects (compared by identity via ===) so a handler that legitimately returns null is not confused with "no handler ran".

Parameters
$event : object
$kwargs : array<string, mixed>
On this page

Search results