Build a Reliable Local Telegram Bot Loop in PHP with getUpdates

When developing a Telegram bot locally, setting up a public HTTPS webhook with tools like Ngrok can add unnecessary network latency and configuration overhead. Long polling via the getUpdates method is the standard alternative for local environments. It allows your local PHP process to pull updates directly from Telegram's servers.

However, writing a naive while(true) loop can lead to high CPU usage, duplicate message processing, or silent failures when the network drops. This guide covers how to build a resilient, CLI-based long polling daemon in PHP that handles offsets, timeouts, and network errors gracefully.

We do not claim this daemon is a replacement for production webhook architectures under high concurrent load, but it provides a robust, production-like environment for local development and low-throughput background workers.

The Long Polling Daemon

Create a file named bot.php. This script runs as a persistent CLI process. It uses a non-zero timeout parameter to instruct Telegram to hold the connection open until an update arrives, reducing unnecessary HTTP requests.

<?php
// bot.php

declare(strict_types=1);

$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
fwrite(STDERR, "Error: TELEGRAM_BOT_TOKEN environment variable is not set.\n");
exit(1);
}

$offset = 0;
$limit = 100;
$timeout = 30; // Long polling timeout in seconds

echo "Starting Telegram Bot long polling loop...\n";

// Handle graceful shutdown on Unix systems
$running = true;
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
pcntl_signal(SIGINT, function () use (&$running) {
echo "\nGracefully shutting down...\n";
$running = false;
});
}

while ($running) {
$url = sprintf('https://api.telegram.org/bot%s/getUpdates', $token);
$payload = [
'offset' => $offset,
'limit' => $limit,
'timeout' => $timeout,
];

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => $timeout + 5, // Must be higher than Telegram's timeout
CURLOPT_CONNECTTIMEOUT => 10,
]);

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

if ($response === false) {
fwrite(STDERR, "cURL Error: {$curlError}. Retrying in 5 seconds...\n");
sleep(5);
continue;
}

if ($httpCode !== 200) {
fwrite(STDERR, "HTTP Error: Received status code {$httpCode}. Retrying in 5 seconds...\n");
sleep(5);
continue;
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, "JSON Decode Error: " . json_last_error_msg() . ". Retrying in 5 seconds...\n");
sleep(5);
continue;
}

if (!isset($data['ok']) || !$data['ok']) {
$description = $data['description'] ?? 'Unknown error';
fwrite(STDERR, "Telegram API Error: {$description}. Retrying in 5 seconds...\n");
sleep(5);
continue;
}

$updates = $data['result'] ?? [];
foreach ($updates as $update) {
$updateId = $update['update_id'];

try {
processUpdate($update);
} catch (Throwable $e) {
fwrite(STDERR, "Error processing update {$updateId}: " . $e->getMessage() . "\n");
}

// Increment offset to acknowledge this update and all prior updates
$offset = $updateId + 1;
}

// Yield CPU cycles if no updates were returned
if (empty($updates)) {
usleep(100000); // 100ms
}
}

function processUpdate(array $update): void

{
if (isset($update['message'])) {
$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if ($chatId && $text !== '') {
echo "Received message from Chat ID {$chatId}: {$text}\n";
// Implement your routing and business logic here
}
}
}

To run this script locally, export your bot token and execute the file from your terminal:

export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
php bot.php

Key Implementation Details

#### 1. cURL Timeout Configuration When using long polling, the cURL execution timeout (CURLOPT_TIMEOUT) must always be strictly greater than the timeout parameter sent in the getUpdates payload. If Telegram is instructed to hold the connection for 30 seconds, but cURL is configured with a 30-second timeout, cURL may terminate the connection prematurely, resulting in false-positive network errors and incomplete payloads.

#### 2. Preventing Duplicate Processing (Idempotency) While updating the offset parameter acts as an acknowledgment cursor, network drops can still cause duplicate deliveries. If your script successfully processes an update but the connection drops before the next getUpdates request registers the new offset, Telegram will redeliver the same update.

To ensure strict idempotency in production-like scenarios, you should store processed update_ids in a fast, key-value store like Redis or a relational database with a unique constraint. Before executing any side effects (such as processing a payment or writing to a database), verify that the update_id has not been processed:

// Example idempotency check using a PDO database connection
$stmt = $pdo->prepare("INSERT INTO processed_updates (update_id) VALUES (:id)");
try {
$stmt->execute([':id' => $updateId]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') { // Unique constraint violation
echo "Update {$updateId} already processed. Skipping.\n";
return;
}
throw $e;
}

#### 3. Deep Linking and Callback Queries If your bot uses deep linking (t.me/Bot?start=payload), Telegram sends this as a standard /start payload message. Your router must parse this text, extract the payload, and map it to your application state.

When handling inline keyboards, keep in mind that the callback_data field has a strict limit of 64 bytes. If you need to pass complex state, generate a unique identifier (e.g., using bin2hex(random_bytes(7))), store the state in your database, and pass only the identifier in the callback data. Always call answerCallbackQuery immediately after processing a callback to clear the loading state on the user's client.

#### 4. Output Escaping When sending text responses back to the user using parse_mode=HTML, always escape your variables using htmlspecialchars($text, ENT_QUOTES, 'UTF-8') to prevent malformed XML errors from crashing your API requests.

For further reading on Telegram API specifications, refer to https://botservice.biz/telegram-bot-api.

BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.