What we build
A reusable TelegramClient component for a Yii2 application that wraps the Telegram Bot API over HTTPS. The goal is a service layer you can call from controllers, console commands, or queue workers without scattering HTTP code across the project. We keep the token out of source control, fail loudly on transport problems, and back off automatically when Telegram returns 429 Too Many Requests.
This article does not claim to cover every Bot API method. It focuses on the transport and integration layer: dependency injection in Yii2, configuration through params-local.php, cURL setup, error logging, and a minimal retry loop.
Project layout
Assume a standard Yii2 basic or advanced template. The component lives in app/components/TelegramClient.php and is wired through the application configuration.
app/
components/
TelegramClient.php
config/
web.php
params.php
params-local.php // not committed
The component
TelegramClient extends yii\base\Component. It exposes one public method, call($method, $params), plus a few tunables: timeout values, max retries, and the bot token. We register it as an application component, so the Yii DI container gives us a configured instance wherever we type-hint it.
<?php
namespace app\components;
use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
use yii\httpclient\Client as HttpClient; // optional, see notes below
class TelegramClient extends Component
{
public string $botToken = '';
public string $apiBase = 'https://api.telegram.org';
public int $timeout = 5;
public int $connectTimeout = 3;
public int $maxRetries = 3;
public function init(): void
{
parent::init();
if ($this->botToken === '') {
$token = getenv('TELEGRAM_BOT_TOKEN');
if (is_string($token) && $token !== '') {
$this->botToken = $token;
}
}
if ($this->botToken === '') {
throw new InvalidConfigException('TelegramClient: botToken is not configured.');
}
}
/**
* @param string $method Bot API method, e.g. 'sendMessage'
* @param array $params request body
* @return array decoded JSON response
* @throws \RuntimeException on transport failure after retries
*/
public function call(string $method, array $params = []): array
{
$url = $this->apiBase . '/bot' . $this->botToken . '/' . $method;
$attempt = 0;
$delaySeconds = 1;
while (true) {
$attempt++;
$response = $this->httpPostJson($url, $params);
if ($response['status'] === 200 && $response['json']['ok'] === true) {
return $response['json'];
}
// 429: respect Retry-After when present
if ($response['status'] === 429) {
$retryAfter = (int)($response['json']['parameters']['retry_after'] ?? $delaySeconds);
Yii::warning(
"Telegram 429 on {$method}, sleeping {$retryAfter}s (attempt {$attempt})",
__METHOD__
);
if ($attempt >= $this->maxRetries) {
break;
}
sleep(max(1, $retryAfter));
continue;
}
// 5xx: retry with linear backoff
if ($response['status'] >= 500 && $response['status'] < 600) {
Yii::warning(
"Telegram {$response['status']} on {$method} (attempt {$attempt})",
__METHOD__
);
if ($attempt >= $this->maxRetries) {
break;
}
sleep($delaySeconds);
$delaySeconds++;
continue;
}
// Hard failure: 4xx (except 429), parse errors, etc.
Yii::error([
'method' => $method,
'status' => $response['status'],
'body' => $response['raw'],
], __METHOD__);
throw new \RuntimeException("Telegram API call failed: {$method}");
}
Yii::error([
'method' => $method,
'status' => $response['status'] ?? 0,
'note' => 'retries exhausted',
], __METHOD__);
throw new \RuntimeException("Telegram API call failed after {$attempt} attempts: {$method}");
}
private function httpPostJson(string $url, array $payload): array
{
$ch = curl_init($url);
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$errstr = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false) {
Yii::error([
'curl_errno' => $errno,
'curl_error' => $errstr,
'url' => $url,
], __METHOD__);
return ['status' => 0, 'json' => ['ok' => false], 'raw' => ''];
}
$decoded = json_decode($body, true);
return [
'status' => $status,
'json' => is_array($decoded) ? $decoded : ['ok' => false],
'raw' => $body,
];
}
}
Two details worth pointing out.
First, we check json['ok'] === true. Telegram always returns 200 HTTP on successful calls and signals business-level failure with ok:false plus an error_code and description. We separate transport errors (status codes, cURL failures) from API errors (ok:false).
Second, 429 handling uses the retry_after field returned in parameters. Telegram sends this when global per-bot rate limits kick in. We also retry on 5xx with linear backoff. Everything else is logged and thrown.
Wiring it in Yii2
In config/web.php register the component. We let Yii build it through the DI container so any service that depends on TelegramClient gets the same configured instance.
'components' => [
'telegram' => [
'class' => \app\components\TelegramClient::class,
'botToken' => Yii::$app->params['telegramBotToken'] ?? '',
'timeout' => 5,
'connectTimeout' => 3,
'maxRetries' => 3,
],
// ...
],
config/params.php declares the key with a placeholder so the application still boots without the local file.
return [
'telegramBotToken' => '',
];
config/params-local.php is loaded on top of params.php and is the right place for secrets. Add it to .gitignore.
<?php
return [
'telegramBotToken' => '123456:AA...replace-me',
];
As a fallback, init() reads TELEGRAM_BOT_TOKEN from the environment. In production that value typically comes from your hosting provider's secret store, not from a file.
Using the component
From a controller:
public function actionPing($chatId)
{
$telegram = Yii::$app->telegram;
$telegram->call('sendMessage', [
'chat_id' => (int)$chatId,
'text' => 'pong',
]);
return 'ok';
}
From a console command that builds a lead before sending:
public function actionLead($chatId)
{
$id = bin2hex(random_bytes(7));
Yii::$app->db->createCommand()
->insert('lead', ['id' => $id, 'chat_id' => $chatId, 'created_at' => time()])
->execute();
Yii::$app->telegram->call('sendMessage', [
'chat_id' => $chatId,
'text' => "Lead #{$id} created",
]);
}
Insert the row first, then send. That way a Telegram outage cannot create phantom leads; if the message fails the caller can retry and find the existing record.
Error logging
Yii::warning and Yii::error write to the configured log target. For a bot that may run in long-lived workers, send warnings to a rotating file and errors to your monitoring backend. A minimal log component configuration:
'log' => [
'targets' => [
[
'class' => \yii\log\FileTarget::class,
'levels' => ['warning', 'error'],
'logFile' => '@runtime/logs/telegram.log',
'maxFileSize' => 10 * 1024 * 1024,
'maxLogFiles' => 5,
],
[
'class' => \yii\log\SyslogTarget::class,
'levels' => ['error'],
],
],
],
Pass an array as the second argument to Yii::error and Yii will log it as structured data, which is easier to grep than a flat string.
Production notes
-
Idempotency for webhooks. Webhook handlers must store
update_idand skip duplicates. A single "last processed id" cursor is unsafe under restarts; use a unique index onupdate_idin the database, or aSETNXin Redis. -
Webhook secret. When registering a webhook via
setWebhook, always passsecret_token. Verify it inside your controller before parsing the body. -
HTML escaping. If you set
parse_modeto HTML, run user-derived values throughhtmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Telegram is strict about unescaped<and>. -
Timeouts. Five seconds is generous for
sendMessage; tighten to two seconds for inline queries where the user is waiting. -
Retries vs. queues. The 429/5xx retries inside the component handle short blips. For long outages, push the call into a queue (Yii2 has
yii\queue\Queueinterfaces) and let a worker retry with exponential backoff and a dead-letter queue. -
httpclient vs. raw cURL. The component above uses raw cURL to keep zero extra dependencies. If you already depend on
yiisoft/yii2-httpclientyou can replacehttpPostJson()withnew Client(['transport' => 'yii\httpclient\CurlTransport'])->post($url, $payload)->send()and read->statusCodeand->data. The retry logic stays identical. -
Testing. Inject a fake
TelegramClientin tests; the public surface is justcall($method, $params), which is easy to stub.
This component is intentionally small: one public method, one HTTP path, no caching, no SDK. If a project needs answerCallbackQuery, editMessageText, or inline keyboards, they all flow through the same call() method with their respective parameter arrays, so adding them is a matter of documentation rather than code.
If you want a reference for the full set of Bot API methods and their parameters, the official Telegram documentation is the right starting point.
This service-layer pattern is the same one we use when shipping production bots. BotCreator is a studio that builds Telegram bots and Mini Apps end-to-end; the patterns above are the foundation we reuse across projects.
Top comments (0)