DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Build a Telegram Client Component for Yii2: DI, Token via params, 429 Retries, Error Logging

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
Enter fullscreen mode Exit fullscreen mode

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,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ],
    // ...
],
Enter fullscreen mode Exit fullscreen mode

config/params.php declares the key with a placeholder so the application still boots without the local file.

return [
    'telegramBotToken' => '',
];
Enter fullscreen mode Exit fullscreen mode

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',
];
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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",
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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'],
        ],
    ],
],
Enter fullscreen mode Exit fullscreen mode

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_id and skip duplicates. A single "last processed id" cursor is unsafe under restarts; use a unique index on update_id in the database, or a SETNX in Redis.
  • Webhook secret. When registering a webhook via setWebhook, always pass secret_token. Verify it inside your controller before parsing the body.
  • HTML escaping. If you set parse_mode to HTML, run user-derived values through htmlspecialchars($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\Queue interfaces) 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-httpclient you can replace httpPostJson() with new Client(['transport' => 'yii\httpclient\CurlTransport'])->post($url, $payload)->send() and read ->statusCode and ->data. The retry logic stays identical.
  • Testing. Inject a fake TelegramClient in tests; the public surface is just call($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)