When developing high-load Telegram-бот s in Yii2, the standard approach of processing updates "on the fly" quickly hits platform limitations. Telegram expects a fast HTTP 200 OK response from your server (preferably within 1–2 seconds). If your code starts executing "heavy" operations—requests to third-party APIs, image generation, or complex DB transactions—the timeout is exceeded. Telegram considers this a delivery failure and begins resending the same events (updates), which exponentially increases the load and leads to duplicate actions.
Architectural Pattern: Fast Ingestion and Background Processing
The only reliable way to design a webhook is to separate message ingestion from business logic processing. The schema looks like this:
- The controller receives a POST request from Telegram.
- The request signature (Secret Token) is verified to protect against spam.
- A duplicate check (idempotency) is performed using the unique
update_id. - The raw JSON is saved to a DB buffer table with a
pendingstatus. - A lightweight processing task is pushed to the queue (Yii Queue).
- The controller instantly returns an HTTP 200 OK response.
- A background worker asynchronously processes the task from the queue.
Step 1. Creating a Table for Incoming Updates
To ensure idempotency and logging of all incoming payloads, we will need a database table. The update_id field provided by Telegram is ideal as a primary key. This guarantees that at the DBMS level, we will never write the same event twice.
Let's create a Yii2 migration:
use yii\db\Migration;
class m240101_000000_create_telegram_update_table extends Migration
{
public function safeUp()
{
$this->createTable('{{%telegram_update}}', [
'id' => $this->bigInteger()->notNull(), // update_id от Telegram
'payload' => $this->text()->notNull(),
'status' => $this->string(32)->notNull()->defaultValue('pending'),
'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
]);
$this->addPrimaryKey('pk-telegram_update-id', '{{%telegram_update}}', 'id');
$this->createIndex('idx-telegram_update-status', '{{%telegram_update}}', 'status');
}
public function safeDown()
{
$this->dropTable('{{%telegram_update}}');
}
}Step 2. Bypassing CSRF and Validating X-Telegram-Bot-Api-Secret-Token
By default, Yii2 protects all POST requests using a CSRF mechanism. Requests from Telegram come from outside, so CSRF validation for the webhook must be disabled. This is done by declaring the property $enableCsrfValidation = false in the controller.
To protect the route from unauthorized requests, we must use the secret_token parameter when registering the webhook via the setWebhook method. Telegram will pass this token in the X-Telegram-Bot-Api-Secret-Token header. Our controller must compare it with the value from the configuration.
Let's implement the WebhookController:
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\models\TelegramUpdate;
use app\jobs\TelegramProcessJob;
class WebhookController extends Controller
{
// Отключаем встроенную CSRF-защиту Yii2
public $enableCsrfValidation = false;
public function actionIndex()
{
Yii::$app->response->format = Response::FORMAT_JSON;
// 1. Валидация секретного токена
$expectedToken = Yii::$app->params['telegram_webhook_secret_token'] ?? null;
$receivedToken = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');
if (empty($expectedToken) || $receivedToken !== $expectedToken) {
throw new BadRequestHttpException('Access denied. Invalid secret token.');
}
// 2. Чтение и парсинг тела запроса
$rawBody = Yii::$app->request->getRawBody();
$update = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
throw new BadRequestHttpException('Invalid JSON payload.');
}
$updateId = (int)$update['update_id'];
// 3. Обеспечение идемпотентности через транзакцию БД
$transaction = Yii::$app->db->beginTransaction();
try {
$exists = TelegramUpdate::find()->where(['id' => $updateId])->exists();
if ($exists) {
$transaction->rollBack();
// Возвращаем 200 OK, так как этот апдейт уже сохранен/обрабатывается
return ['status' => 'duplicate', 'update_id' => $updateId];
}
$dbUpdate = new TelegramUpdate();
$dbUpdate->id = $updateId;
$dbUpdate->payload = $rawBody;
$dbUpdate->status = 'pending';
if (!$dbUpdate->save()) {
throw new \Exception('Failed to save update to database.');
}
$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Webhook DB error: ' . $e->getMessage(), 'telegram');
// Возвращаем HTTP 200, чтобы избежать бесконечного спама повторами от Telegram
return ['status' => 'db_error', 'message' => $e->getMessage()];
}
// 4. Постановка задачи в очередь Yii Queue
Yii::$app->queue->push(new TelegramProcessJob([
'updateId' => $updateId,
]));
return ['status' => 'accepted', 'update_id' => $updateId];
}
}Step 3. Asynchronous Worker with Yii Queue
To work with queues in Yii2, the yiisoft/yii2-queue extension is typically used. Our job must fetch the raw payload from the database by the received updateId, execute the entire business logic chain, send a response to the user via Telegram Bot API, and update the record status to processed.
To send requests to Telegram, we use classic cURL. Note: we strictly check the HTTP response code, cURL errors, and the ok flag in the Telegram API JSON response. No unsafe file_get_contents.
namespace app\jobs;
use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
use app\models\TelegramUpdate;
class TelegramProcessJob extends BaseObject implements JobInterface
{
/** @var int */
public $updateId;
public function execute($queue)
{
$dbUpdate = TelegramUpdate::findOne($this->updateId);
if (!$dbUpdate || $dbUpdate->status !== 'pending') {
return;
}
$payload = json_decode($dbUpdate->payload, true);
if (!$payload) {
$dbUpdate->status = 'failed';
$dbUpdate->save(false);
return;
}
try {
$this->processPayload($payload);
$dbUpdate->status = 'processed';
$dbUpdate->save(false);
} catch (\Exception $e) {
Yii::error("Failed processing update {$this->updateId}: " . $e->getMessage(), 'telegram');
$dbUpdate->status = 'failed';
$dbUpdate->save(false);
// Выбрасываем исключение дальше, чтобы очередь могла повторить попытку позже
throw $e;
}
}
protected function processPayload(array $payload)
{
// Пример обработки текстового сообщения
if (isset($payload['message']['chat']['id']) && isset($payload['message']['text'])) {
$chatId = $payload['message']['chat']['id'];
$text = trim($payload['message']['text']);
if ($text === '/start') {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Добро пожаловать! Ваш запрос отправлен в обработку.",
]);
}
}
}
protected function sendTelegramRequest(string $method, array $params)
{
$token = Yii::$app->params['telegram_bot_token'] ?? null;
if (!$token) {
throw new \Exception('Telegram bot token is not configured.');
}
$url = "https://api.telegram.org/bot{$token}/{$method}";
$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 => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new \Exception("cURL Error: {$error}");
}
if ($httpCode !== 200) {
throw new \Exception("Telegram API returned HTTP Code {$httpCode}. Response: {$response}");
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Telegram response is not valid JSON.');
}
if (!($result['ok'] ?? false)) {
$description = $result['description'] ?? 'No description';
throw new \Exception("Telegram API error: {$description}");
}
return $result;
}
}Operational and Logging Recommendations
When using queues, it is important to properly set up monitoring. If a worker fails due to an error (for example, an external integration timeout), the task should return to the queue with a retry delay. Configure the attempt limit (ttr) in the console component configuration of Yii2 Queue to avoid infinite looping of broken tasks.
For high-load systems, it is recommended to periodically clean the telegram_update buffer table. Keeping records for the last 3–7 days is sufficient for incident investigation. Rotation can be performed via a Yii2 console command scheduled via cron once a day.
If you need help designing the architecture of high-load bots, the team of experts at BotCreator will help implement fault-tolerant solutions of any complexity.