DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Implement a Telegram Bot API Client in Yii2: Dependency Injection, 429 Retries, and Logging

Integrating third-party HTTP services directly into controller actions or queue handlers without an abstraction layer creates fragile, unmaintainable codebases. When interacting with the Telegram Bot API in a Yii2 application, raw file_get_contents calls or scattered cURL snippets introduce critical security risks, untracked API exceptions, unhandled rate limits (HTTP 429), and hardcoded credentials.

In this tutorial, we will construct a production-ready TelegramClient component for Yii2. This architecture encapsulates standard cURL network requests, isolates sensitive tokens inside local parameters, integrates directly with the Yii2 Dependency Injection (DI) container, automatically handles rate-limiting backoffs, and logs API failures via Yii2's core logging infrastructure.

This guide focuses strictly on building the outbound HTTP transport layer for Telegram API endpoints. It does not cover database migrations, active record models, or webhook route setup.


Storing Credentials in params-local.php

Hardcoding bot credentials inside application components or commit history compromises security and violates environment isolation principles. Yii2 provides a split configuration model: base values reside in config/params.php, while environment-specific overrides live in config/params-local.php, which must be excluded from version control via .gitignore.

First, define a placeholder key in config/params.php:

<?php

return [
    'adminEmail' => 'admin@example.com',
    'telegramBotToken' => '',
];
Enter fullscreen mode Exit fullscreen mode

Next, insert your actual bot token obtained from @BotFather into config/params-local.php:

<?php

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

This ensures that development, staging, and production environments maintain distinct bot instances without modifying the main codebase.


Designing the TelegramClient Component

The custom component extends yii\base\Component to hook into Yii2 object initialization logic. It manages cURL resources, sets execution timeouts, inspects response payloads, and handles transient API failures.

When Telegram returns HTTP status code 429 Too Many Requests, the API response includes a JSON object with parameters.retry_after indicating how many seconds to wait before retrying. The component reads this property and executes a bounded retry loop before returning an error.

Create components/TelegramClient.php:

<?php

namespace app\components;

use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;
use yii\base\Exception;

class TelegramClient extends Component
{
    public string $botToken = '';
    public int $timeout = 10;
    public int $connectTimeout = 5;
    public int $maxRetries = 3;

    public function init(): void
    {
        parent::init();

        if (empty($this->botToken)) {
            throw new InvalidConfigException('The "botToken" property must be configured in TelegramClient.');
        }
    }

    /**
     * Executes a POST request to the Telegram Bot API.
     *
     * @param string $method API method (e.g., 'sendMessage', 'answerCallbackQuery')
     * @param array $payload Key-value payload parameters
     * @return array Decoded JSON response from Telegram
     * @throws Exception If cURL encounters a network error or retries are exhausted
     */
    public function sendRequest(string $method, array $payload = []): array
    {
        $url = sprintf('https://api.telegram.org/bot%s/%s', $this->botToken, $method);
        $attempts = 0;

        while ($attempts < $this->maxRetries) {
            $attempts++;
            $result = $this->executeCurl($url, $payload);

            if ($result['status'] === 200 && isset($result['response']['ok']) && $result['response']['ok'] === true) {
                return $result['response'];
            }

            // Check for Rate Limit (HTTP 429)
            if ($result['status'] === 429 || (isset($result['response']['error_code']) && $result['response']['error_code'] === 429)) {
                $retryAfter = $result['response']['parameters']['retry_after'] ?? 1;

                Yii::warning(
                    sprintf('Telegram Rate Limit reached on %s. Attempt %d/%d. Waiting %d seconds.', $method, $attempts, $this->maxRetries, $retryAfter),
                    __METHOD__
                );

                if ($attempts < $this->maxRetries) {
                    sleep((int) $retryAfter);
                    continue;
                }
            }

            // Log fatal error details if request failed completely
            Yii::error(
                sprintf(
                    "Telegram API Request Failed.
Method: %s
HTTP Status: %d
Payload: %s
Response: %s",
                    $method,
                    $result['status'],
                    json_encode($payload, JSON_UNESCAPED_UNICODE),
                    json_encode($result['response'], JSON_UNESCAPED_UNICODE)
                ),
                __METHOD__
            );

            return $result['response'] ?? ['ok' => false, 'error_code' => $result['status'], 'description' => 'Unknown HTTP Error'];
        }

        throw new Exception(sprintf('Telegram API call "%s" failed after %d attempts.', $method, $this->maxRetries));
    }

    private function executeCurl(string $url, array $payload): array
    {
        $ch = curl_init();

        $jsonPayload = json_encode($payload);

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonPayload,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Content-Length: ' . strlen($jsonPayload),
            ],
            CURLOPT_TIMEOUT => $this->timeout,
            CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);

        $rawResponse = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlErrno = curl_errno($ch);
        $curlError = curl_error($ch);

        curl_close($ch);

        if ($curlErrno !== 0) {
            Yii::error(sprintf('cURL execution error (%d): %s', $curlErrno, $curlError), __METHOD__);
            return [
                'status' => 0,
                'response' => ['ok' => false, 'description' => 'cURL Error: ' . $curlError],
            ];
        }

        $decoded = json_decode((string) $rawResponse, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            Yii::error(sprintf('Failed to parse Telegram JSON response: %s', json_last_error_msg()), __METHOD__);
            return [
                'status' => $httpCode,
                'response' => ['ok' => false, 'description' => 'Malformed JSON response'],
            ];
        }

        return [
            'status' => $httpCode,
            'response' => $decoded,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Registering the Service with Yii2 Dependency Injection

To avoid instantiating the class manually with new TelegramClient(), configure it inside the Yii2 container definitions. This decouples classes, allows mocking during unit tests, and centralizes component configuration.

Open config/web.php (and config/console.php if console workers interact with Telegram) and register the class under the container key:

$params = require __DIR__ . '/params.php';
if (file_exists(__DIR__ . '/params-local.php')) {
    $params = array_merge($params, require __DIR__ . '/params-local.php');
}

$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'components' => [
        // Standard components...
    ],
    'container' => [
        'singletons' => [
            \app\components\TelegramClient::class => [
                'class' => \app\components\TelegramClient::class,
                'botToken' => $params['telegramBotToken'],
                'timeout' => 12,
                'maxRetries' => 3,
            ],
        ],
    ],
    'params' => $params,
];

return $config;
Enter fullscreen mode Exit fullscreen mode

By defining TelegramClient inside singletons, Yii2 instantiates the object once per request life-cycle when requested through Constructor Injection or Yii::$container->get().


Utilizing TelegramClient in Controllers and Services

With Dependency Injection configured, you can inject TelegramClient directly into controllers, console commands, or queued job handlers.

When rendering user input inside message bodies formatted with HTML (parse_mode = 'HTML'), always run values through htmlspecialchars() to prevent syntax errors caused by unescaped <, >, or & characters.

Here is an example controller action handling outbound notifications:

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use app\components\TelegramClient;

class NotificationController extends Controller
{
    private TelegramClient $telegram;

    // Inject TelegramClient through Constructor Injection
    public function __construct($id, $module, TelegramClient $telegram, $config = [])
    {
        $this->telegram = $telegram;
        parent::__construct($id, $module, $config);
    }

    public function actionSendSystemAlert(string $chatId, string $userInputName): Response
    {
        // Sanitize untrusted input to avoid breaking Telegram HTML parser
        $safeName = htmlspecialchars($userInputName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

        $messageText = sprintf(
            "<b>System Notification</b>
User <i>%s</i> triggered a critical alert.",
            $safeName
        );

        $payload = [
            'chat_id' => $chatId,
            'text' => $messageText,
            'parse_mode' => 'HTML',
            'disable_web_page_preview' => true,
        ];

        $response = $this->telegram->sendRequest('sendMessage', $payload);

        if (isset($response['ok']) && $response['ok'] === true) {
            return $this->asJson([
                'success' => true,
                'message_id' => $response['result']['message_id'],
            ]);
        }

        return $this->asJson([
            'success' => false,
            'error' => $response['description'] ?? 'Failed to deliver Telegram message.',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Technical Edge Cases and Operational Notes

  1. Callback Queries: When handling interactive inline keyboards via webhooks, call answerCallbackQuery immediately to clear the loading state on the user's screen. If the process requires background tasks, invoke answerCallbackQuery before dispatching long-running jobs.
  2. Payload Size Limits: The callback_data attribute inside inline button objects cannot exceed 64 bytes. Do not place entire database entities or complex JSON inside callback_data. Use short, unique state identifiers—such as hex-encoded random strings or database primary keys—and resolve state on your backend.
  3. Queue Offloading for High Volume: While the internal loop handles transient 429 Too Many Requests status codes, synchronous web requests will block your web server workers during long backoffs. For bulk notifications, process messages asynchronously via yii2tech/queue or yiisoft/yii2-queue backed by Redis or RabbitMQ.
  4. Network Timeouts: Connection timeouts (CURLOPT_CONNECTTIMEOUT) should remain tight (e.g., 3–5 seconds), while execution timeouts (CURLOPT_TIMEOUT) should be set slightly higher than your expected latency, avoiding hung PHP processes during upstream Telegram outages.

Need custom architecture for complex webhooks, transactional messaging queues, or Mini App state synchronization? BotCreator builds production-ready Telegram integrations, dedicated bot clients, and backends for modern web applications.

Top comments (0)