phpbotgram

Filters

A filter is a dispatch-time predicate that votes on whether a handler should run. Returns false to reject, true to accept, or an associative array to accept and merge kwargs into the handler call.

How it works

Writing a custom filter

Filter is the abstract base. Every built-in filter (Command, StateFilter, CallbackQueryFilter, ChatMemberUpdatedFilter, the F-DSL bridges, the logic combinators) inherits from it and implements __invoke(object $event, mixed ...$kwargs) .

The variadic $kwargs parameter is deliberate: it signals to CallableObject::prepareKwargs that the filter wants the entire dispatcher kwargs bag (bot , state , event_context , …), not just the keys it literally declares. Without the variadic the prepare step intersects the bag against the parameter names and drops everything else — which would silently break filters that need contextual data the dispatcher injected.

The following filter accepts messages containing the word "hello" and injects a greeting kwarg into the handler (adapted from examples/own_filter.php ):

use Gruven\PhpBotGram\Filters\Filter;
use Gruven\PhpBotGram\Types\Message;

final class ContainsHelloFilter extends Filter
{
    public function __invoke(object $event, mixed ...$kwargs): array|bool
    {
        if (!$event instanceof Message) {
            return false;
        }
        $text = strtolower($event->text ?? '');
        if (!str_contains($text, 'hello')) {
            return false;
        }
        return ['greeting' => 'Hello, friend!'];
    }
}

Registering a handler with filters

Pass filter instances to the filters: named argument of register . The handler only runs when every filter in the list accepts:

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Dispatcher\Dispatcher;
use Gruven\PhpBotGram\Types\Message;

$bot = new Bot('token');
$dispatcher = new Dispatcher();

$dispatcher->message->register(
    static function (Message $event, string $greeting): void {
        $event->answer($greeting)->emit();
    },
    filters: [new ContainsHelloFilter()],
);

$dispatcher->message->register(static function (Message $event): void {
    $event->answer("Try saying hello!")->emit();
});

The $greeting parameter arrives because ContainsHelloFilter returned ['greeting' => 'Hello, friend!'] . The dispatcher merges every filter's array return into the kwargs bag before calling the handler.

Combining filters

Filters compose with three static helpers.

Filter::all($f1, $f2) builds an AndFilter that cascades kwargs forward — if the first filter returns ['command' => $cmd] , the second filter sees it in $kwargs . Filter::any($f1, $f2) builds an OrFilter where the first accepting child wins, no cascade. Filter::invertOf($f) builds an InvertFilter that flips the accept/reject decision.

use const Gruven\PhpBotGram\F;
use Gruven\PhpBotGram\Filters\Filter;

// ContainsHelloFilter is the custom filter from above; combine it with
// any other Filter — here an inline F-DSL filter that matches "bye".
$saysBye = F->text->contains('bye')->asFilter();

$andFilter = Filter::all(new ContainsHelloFilter(), $saysBye);
$orFilter  = Filter::any(new ContainsHelloFilter(), $saysBye);
$notFilter = Filter::invertOf(new ContainsHelloFilter());

PHP cannot overload & /| /~ , so we ship method forms — the result composes identically to aiogram's f1 & f2 | ~f3 syntax. The static-helper form is more verbose than operator overloading but reads identically once you're used to it.

Return-value semantics

The return-value semantics mirror aiogram's HandlerObject.check : false rejects (later filters on the same handler are not consulted), true accepts with no kwargs, and an associative array accepts with merged kwargs. The Command filter returns ['command' => CommandObject(...)] so the handler can declare function (Message $event, CommandObject $command) and receive the parsed pieces. Other filters that need to expose extracted state (Regex captures, F-DSL selections) follow the same shape. The null return is treated as false for backward-compat with filters that explicitly return nothing on rejection.

Global vs per-handler filters

Filters run in two places. Global filters registered on the observer apply before any handler is considered — one rejection short-circuits the entire chain. Per-handler filters passed to register($cb, filters: [...]) apply for that handler only. Scenes use global filters to scope every handler on a scene's observer to the current FSM state; user code typically uses per-handler filters for command matching, callback prefix routing, etc. Global filters are appended via $observer->filter(...) — e.g. $dispatcher->message->filter(...) .

Exception-typed filters

The exception-typed filters (ExceptionTypeFilter, ExceptionMessageFilter) are designed for the errors observer. They inspect the synthetic ErrorEvent 's exception slot and accept or reject by type or by getMessage() regex. This is how a global error handler can discriminate between "log everything" and "alert on TelegramRetryAfter specifically".

Trade-offs

Filter results are not cached. A filter that hits Redis or runs a DB query runs on every event, every handler. For expensive checks, either attach the policy as a middleware (which runs once per observer) or move the heavy work behind an in-memory cache. The framework does not try to be clever — it would have to invalidate the cache on too many events. Caching the output of a filter against the input event would require the filter to be a pure function of the event alone, which is not the contract (filters often read dispatcher kwargs too).

Throws from a filter propagate. There is no automatic "a filter raised therefore reject" rescue. This is deliberate: a filter raising during a database lookup is a bug, not a vote, and swallowing it would mask the failure. If you want a graceful-fallback filter, catch inside the filter's __invoke and return false :

use Gruven\PhpBotGram\Filters\Filter;

final class SafeDbFilter extends Filter
{
    public function __invoke(object $event, mixed ...$kwargs): array|bool
    {
        try {
            // ...expensive lookup...
            return ['user' => null]; // resolved user object
        } catch (\Throwable) {
            return false; // reject gracefully rather than surface the error
        }
    }
}

The Command filter has one specific exception: failures inside $bot->getMe() (the username lookup for mention-matching) are absorbed so unit tests that don't seed a getMe response can still exercise the filter.

Filters are stateless by design. Aiogram's magic_filter is expression-tree, not callback-graph, and we keep that model — the F-DSL chain you build with F->message->text->equals('hi') resolves fresh against each event. Sharing state across calls (e.g. caching the last seen value) is intentionally hard. If you need that, you want a middleware, not a filter — middlewares get one instance per registration and can hold state on $this .

The variadic-kwargs convention is unusual but load-bearing. Removing it would mean every filter has to declare every kwarg it needs by name, and a new dispatcher-injected key would force every filter to update its signature. The variadic shape is invisible at the call site (handlers don't see it) and only matters when authoring new Filter subclasses.

See also

Search results