DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Build a Resilient Telegram Bot Service Layer in Yii2

When building production-grade Telegram bots in Yii2, developers often make the mistake of writing ad-hoc cURL requests inside controllers or console commands. This approach leads to duplicated code, poor error visibility, and fragile integrations that break under network latency or API rate limits.

In this tutorial, we will build a robust, production-ready service layer for interacting with the Telegram Bot API within a Yii2 application. We will configure this service using Yii2's Dependency Injection (DI) container, store credentials securely in local configuration files, handle network failures, log API errors properly, and implement retry logic for HTTP 429 (Too Many Requests) responses.

We do not claim this is a complete framework wrapper or a replacement for complex SDKs. Instead, it is a clean, maintainable architectural pattern designed to replace ad-hoc HTTP calls with a resilient service component.

Secure Configuration with params-local.php

Hardcoding API credentials in your codebase is a major security risk and violates the principles of twelve-factor app design. In Yii2, the standard way to manage environment-specific configurations is through the params-local.php file, which should be added to your .gitignore file to prevent it from being committed to your version control system.

First, define your Telegram bot token in config/params-local.php:

<?php
// config/params-local.php

return [
    'telegram.botToken' => '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
];
Enter fullscreen mode Exit fullscreen mode

Next, configure Yii2's Dependency Injection (DI) container in your main application configuration file (config/web.php for web applications, and config/console.php for console commands). This ensures that whenever your application requests an instance of our TelegramClient, the DI container automatically instantiates it with the correct token from your parameters.

<?php
// config/web.php

$params = require __DIR__ . '/params.php';
if (file_exists(__DIR__ . '/params-local.php')) {
    $params = yii\helpers\ArrayHelper::merge($params, require __DIR__ . '/params-local.php');
}

$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    // ... other configurations ...
    'container' => [
        'definitions' => [
            app\components\TelegramClient::class => [
                'class' => app\components\TelegramClient::class,
                'botToken' => $params['telegram.botToken'] ?? null,
                'maxRetries' => 3,
                'timeout' => 10,
            ],
        ],
    ],
    'params' => $params,
];

return $config;
Enter fullscreen mode Exit fullscreen mode

Implementing the TelegramClient Component

The TelegramClient class must be resilient. Network timeouts, DNS resolution failures, and rate limits are common when communicating with external APIs. Our component will use PHP's cURL extension to execute requests, inspect HTTP status codes, validate JSON payloads, and handle HTTP 429 rate limits by parsing the Retry-After header and retrying the request after the specified delay.

Create the file components/TelegramClient.php:

<?php

namespace app\components;

use Yii;
use yii\base\BaseObject;
use yii\base\InvalidConfigException;

class TelegramClient extends BaseObject

{
    public ?string $botToken = null;
    public int $maxRetries = 3;
    public int $timeout = 10;

    public function init()
    {
        parent::init();
        if (empty($this->botToken)) {
            throw new InvalidConfigException('The "botToken" property must be configured.');
        }
    }

    /**
     * Sends a request to the Telegram Bot API.
     *
     * @param string $method The API method (e.g., "sendMessage")
     * @param array $params The parameters to send in the JSON body
     * @return array The parsed API response
     * @throws \RuntimeException If the request fails after maximum retries
     */
    public function sendRequest(string $method, array $params = []): array
    {
        $url = "https://api.telegram.org/bot{$this->botToken}/{$method}";
        $attempt = 0;

        while ($attempt < $this->maxRetries) {
            $attempt++;
            $ch = curl_init();

            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($params),
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => $this->timeout,
                CURLOPT_HEADER => true, // Required to parse response headers for Retry-After
            ]);

            $response = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
            $curlError = curl_error($ch);
            curl_close($ch);

            if ($response === false) {
                Yii::error("Telegram API cURL error: {$curlError}. Attempt {$attempt}/{$this->maxRetries}", __METHOD__);
                if ($attempt >= $this->maxRetries) {
                    throw new \RuntimeException("Telegram API connection failed: {$curlError}");
                }
                usleep(500000 * $attempt); // Exponential backoff before retry
                continue;
            }

            $headersStr = substr($response, 0, $headerSize);
            $bodyStr = substr($response, $headerSize);

            if ($httpCode === 429) {
                $retryAfter = $this->parseRetryAfter($headersStr);
                Yii::warning("Telegram API rate limited (429). Retrying after {$retryAfter} seconds. Attempt {$attempt}/{$this->maxRetries}", __METHOD__);
                sleep($retryAfter);
                continue;
            }

            $data = json_decode($bodyStr, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                Yii::error("Failed to parse Telegram API response JSON: " . json_last_error_msg() . " | Raw body: {$bodyStr}", __METHOD__);
                throw new \RuntimeException("Invalid JSON response from Telegram API");
            }

            if ($httpCode !== 200 || !($data['ok'] ?? false)) {
                $description = $data['description'] ?? 'Unknown error';
                $errorCode = $data['error_code'] ?? $httpCode;
                Yii::error("Telegram API error response: [{$errorCode}] {$description}", __METHOD__);
                return $data;
            }

            return $data;
        }

        throw new \RuntimeException("Telegram API request failed after {$this->maxRetries} attempts.");
    }

    /**
     * Parses the Retry-After header from the response headers.
     */
    private function parseRetryAfter(string $headers): int
    {
        if (preg_match('/Retry-After:\s*(\d+)/i', $headers, $matches)) {
            return (int)$matches[1];
        }
        return 2; // Default fallback delay in seconds
    }
}
Enter fullscreen mode Exit fullscreen mode

Injecting and Using the Service in a Controller

With our DI configuration in place, we can inject the TelegramClient directly into our controllers or console commands via constructor injection. This keeps our controllers clean, testable, and decoupled from the underlying HTTP client implementation.

When sending messages to Telegram using parse_mode = 'HTML', a common source of silent failures is unescaped HTML entities. Characters like <, >, and & will cause the Telegram API to reject the message with a 400 Bad Request error. We must use htmlspecialchars to sanitize our dynamic inputs before sending them.

Here is an example controller demonstrating constructor injection and safe message formatting:

<?php

namespace app\controllers;

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

class AlertController extends Controller
{
    private TelegramClient $telegram;

    // Yii2 DI container automatically resolves and injects the TelegramClient dependency
    public function __construct($id, $module, TelegramClient $telegram, $config = [])
    {
        $this->telegram = $telegram;
        parent::__construct($id, $module, $config);
    }

    public function actionSend(): Response
    {
        $this->response->format = Response::FORMAT_JSON;

        $chatId = Yii::$app->request->post('chat_id');
        $rawMessage = Yii::$app->request->post('message', '');

        if (empty($chatId)) {
            return [
                'success' => false,
                'error' => 'Missing chat_id parameter.',
            ];
        }

        // Safely escape HTML entities to prevent Telegram API parsing errors
        $safeMessage = htmlspecialchars($rawMessage, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $formattedText = "<b>System Alert:</b>\n" . $safeMessage;

        try {
            $response = $this->telegram->sendRequest('sendMessage', [
                'chat_id' => $chatId,
                'text' => $formattedText,
                'parse_mode' => 'HTML',
            ]);

            if ($response['ok'] ?? false) {
                return ['success' => true];
            }

            return [
                'success' => false,
                'error' => $response['description'] ?? 'Unknown Telegram API error.',
            ];
        } catch (\Exception $e) {
            Yii::error("Failed to send alert: " . $e->getMessage(), __METHOD__);
            return [
                'success' => false,
                'error' => 'An internal error occurred while sending the message.',
            ];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Considerations and Edge Cases

While the retry logic in our TelegramClient handles temporary rate limits, it is important to understand the constraints of the Telegram Bot API in a production environment:

  1. Webhook Timeouts: If you are calling TelegramClient inside a webhook handler, you must respond to Telegram's incoming request with an HTTP 200 OK within a few seconds. If your outbound API call gets delayed by rate limits or network latency, Telegram will assume your webhook timed out and will retry sending the update. To prevent this, offload outbound API calls to a background queue (such as yii2-queue) rather than executing them synchronously within the webhook request cycle.
  2. Idempotency: When processing incoming webhook updates, always store the processed update_id in a fast-access storage layer like Redis or a database table with a unique constraint. This prevents your application from executing duplicate actions if Telegram retries a webhook delivery.
  3. Global Rate Limits: Telegram limits bots to sending no more than 30 messages per second globally, and no more than 20 messages per minute within a specific group chat. While our 429 retry mechanism mitigates spikes, high-volume applications should implement an outbound message queue with rate-limiting throttling to prevent hitting these limits in the first place.

For more detailed information on managing bot integrations and handling complex payloads, you can read the official documentation at https://botservice.biz/telegram-bot-api.

If you need professional assistance building, scaling, or securing your Telegram integrations, contact BotCreator — studio that ships Telegram bots / Mini Apps.

Top comments (0)