phpbotgram

Serialization

The serializer turns Telegram type DTOs into snake_case-keyed wire payloads and back. It is the boundary between the strongly-typed PHP domain model and the dynamic-typed Bot API.

How it works

Dump and load

Serializer is reflection-driven. Serializer::dump($object) iterates the public properties of any BotContextController subclass (every TelegramObject and TelegramMethod ), converts each camelCase property name to snake_case, and emits a flat array<string, mixed> ready to JSON-encode or form-urlencode. Nested BotContextController instances recurse; lists and maps walk element-by-element. An Unspecified::instance() value signals "this field was never set" and is skipped — distinct from null , which is preserved so the wire can carry an explicit null.

Serializer::load($class, $rawArray, $bot) is the inverse. It reflects the target class's constructor, walks the parameter list, converts each parameter's PHP name to snake_case to look up the wire key, and coerces the value via the parameter's declared type. Nested TelegramObject parameters route through Serializer::load recursively. The $bot argument is threaded into every nested constructor's optional ?Bot $bot parameter so handler shortcuts ($message->answer(...) ) work on hydrated trees. The recursion walks union types correctly via PHP's ReflectionUnionType — a parameter typed Message|null decodes the wire payload's expected shape and accepts the null case if the wire delivers a null .

Generated Bot API collections also use constructor PHPDoc to preserve list item types that PHP cannot express at runtime. For example, a RichMessage constructor accepts array $blocks , while its PHPDoc narrows the value to list<RichBlock> . The loader reads that metadata so incoming rich messages hydrate each block as the matching generated RichBlock* subtype instead of leaving the list as raw arrays.

Rich text adds one more recursive shape. The Bot API lets rich-text fields be a plain string, a single RichText object, or a list that mixes strings and nested rich-text objects. The serializer preserves that union in both directions, which is why RichBlockParagraph::$text , RichTextBold::$text , and similar generated fields are typed as array|RichText|string . See Send rich messages for the handler-facing API.

Method DTOs and the ApiMethod / ReturnsType constants

Every generated method class carries two constants that drive the serialization pipeline. ApiMethod is the wire-level Bot API method name; ReturnsType is either a class-string<TelegramObject> , a scalar type name ('bool' , 'int' , 'string' ), or a composite sentinel ('list:<inner>' , 'union:<A>|<B>' ). The session reads both constants at call time so there is no per-method dispatch table.

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Client\BotDefault;
use Gruven\PhpBotGram\Methods\TelegramMethod;
use Gruven\PhpBotGram\Types\Message;

// Illustrative shape of a generated method DTO.
// Real class lives at src/Methods/SendMessage.php.
final class SendMessage extends TelegramMethod
{
    public const string ApiMethod = 'sendMessage';
    public const string ReturnsType = Message::class;

    public function __construct(
        public readonly int|string $chatId,
        public readonly string $text,
        public readonly null|BotDefault|string $parseMode = new BotDefault('parse_mode'),
        ?Bot $bot = null,
    ) {
        parent::__construct($bot);
    }
}

Wire-name overrides

Per-class wire-name overrides live in a class-level WireNames constant. Phase 2 codegen produces them when a Telegram field name doesn't camelCase cleanly to a valid PHP identifier — the upstream from reserved-word collision becomes $fromUser in PHP with const WireNames = ['fromUser' => 'from'] driving the serializer mapping.

The override mechanism keeps the default camelToSnake cheap and pushes special cases to the per-class metadata where they belong. The serializer reads the constant via reflection at class-load time; subsequent dumps/loads hit a cached copy. A concrete example from src/Types/CallbackQuery.php :

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Types\TelegramObject;
use Gruven\PhpBotGram\Types\User;

final class CallbackQuery extends TelegramObject
{
    /** @var array<string, string> */
    public const array WireNames = ['fromUser' => 'from'];

    public function __construct(
        public readonly string $id,
        public readonly User $fromUser,
        ?Bot $bot = null,
    ) {
        parent::__construct($bot);
    }
}

prepareValue / checkResponse round-trip

BaseSession owns two adjacent hooks. prepareValue runs after Serializer::dump to detach InputFile instances from the form body (uploads go in multipart fields, references stay in the JSON) and to strip null values from the form encoding (form bodies don't represent null cleanly; the wire shape uses absence). checkResponse runs on the decoded response: it validates the ok: true field, throws the matching TelegramApiException subclass on ok: false , and routes the success payload through Serializer::load for the typed return. The two hooks bracket the network round-trip: dump → prepareValue → HTTP → checkResponse → load.

Custom JSON encoder/decoder injection

AmphpSession (and any BaseSession subclass) accepts optional $jsonLoads and $jsonDumps closures so you can swap in a faster library or add tracing without subclassing the session:

use Gruven\PhpBotGram\Bot;
use Gruven\PhpBotGram\Client\Session\AmphpSession;

$session = new AmphpSession(
    jsonLoads: fn(string $raw): mixed => json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR),
    jsonDumps: fn(mixed $value): string => (string) json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
);
$bot = new Bot(getenv('BOT_TOKEN'), session: $session);

The defaults shown above are what the session uses when no closures are supplied; drop in any callable with the same signatures.

DateTime fields and BotDefault

DateTime fields use a dedicated wrapper. The framework's Types\Custom\DateTime type carries a DateTimeImmutable plus its original Unix-timestamp representation; the serializer routes int → DateTime on load and DateTime → int on dump. This preserves precision (DateTimeImmutable keeps seconds + microseconds) while staying faithful to Telegram's wire format (Unix timestamp as integer). Handlers can use the DateTimeImmutable API without re-parsing the timestamp on every read.

The BotDefault mechanism lets a Bot carry default values that populate Unspecified fields on outbound methods. The serializer checks for default presence after Unspecified skip but before the wire emit — so a SendMessage left with parseMode: Unspecified::instance() picks up the bot's default parse mode if one was configured. The hook is in BotDefault; defaults are scoped per bot so multi-tenant deployments can configure each bot independently.

Trade-offs

Reflection-based serialization costs one ReflectionClass per shape per request — the serializer reflects on every dump /load rather than caching type metadata. The only memoised path is the camelCase→snake_case property-name conversion (Serializer::$camelToSnakeCache ), bounded by the number of distinct property names, not by request volume. We considered caching reflected shape metadata and decided the complexity wasn't worth it — the per-call reflection is cheap next to the network round-trip, and a stateless serializer is simpler to reason about.

Codegen-output serializers would be faster. Aiogram uses Pydantic, which builds a fast-path validator at class definition time. The PHP port deliberately chose reflection: emitting per-class serializers during Phase 2 would have doubled the codegen surface and required a second template engine. The reflection cost is real but small, and hot paths can always cache the meta themselves. The 10x or so speedup codegen serializers could deliver is dwarfed by the network round-trip on every Bot API call, so the trade pays itself in code clarity.

Unspecified::instance() is a sentinel, not null . The distinction matters because Telegram fields are tri-state: present-with-value, present-and-null (explicit nullability), absent. A naive if ($value !== null) would conflate the last two; the Unspecified sentinel preserves the difference. The cost is one extra concept to learn — handlers that build method DTOs almost always want to leave a field at Unspecified::instance() to mean "don't send". The default value for every method parameter is Unspecified::instance() , so the surprise is contained to "what is this sentinel doing here".

The InputFile detachment in prepareValue is form-only. JSON encoding (the default for Bot API methods that don't carry files) serialises InputFile references inline; the detachment kicks in only when the session falls back to multipart form-data because of an attached file. The branching lives in the session, not the serializer — keeping the serializer ignorant of transport details.

Wire-name overrides are a per-class constant rather than property attributes. We considered #[WireName('from')] on each property and chose the constant for codegen simplicity: emitting one constant per class is cheaper than emitting one attribute per property. The cost is the override table is class-level, not property-level — a class with multiple wire-name overrides has them all in one constant, which is fine for the small number of overrides Bot API actually requires.

See also

Search results