Local development for Telegram bots presents a common infrastructure hurdle: how to test incoming updates without configuring a public HTTPS domain or relying on local tunnel tools like ngrok. Telegram’s getUpdates endpoint is the natural solution. It allows your local PHP command-line interface (CLI) script to pull new messages directly from Telegram servers.
However, naive long-polling implementations in PHP quickly break down. If your script fetches updates without managing the offset parameter correctly, or if the underlying cURL socket times out before Telegram responds, your script enters a CPU-spinning loop, re-processes the exact same user messages thousands of times, or crashes outright. Worse, if your local script processes a database operation—such as logging a support ticket—and crashes before sending the next HTTP request, Telegram will re-deliver that update when the script restarts, resulting in duplicate transactions.
Here is how to build a resilient, long-polling engine in pure PHP that handles cURL timeouts, maintains strict update offsets, guarantees update idempotency across script crashes, and safely prepares your codebase for a seamless transition to webhooks in production.
The Broken Polling Loop and CPU Exhaustion
A common mistake when writing a local PHP polling script is wrapping file_get_contents or a basic cURL request inside an infinite loop without specifying an update offset or a server-side timeout.
Consider this broken pattern:
<?php
// broken_poll.php - DO NOT USE IN PRODUCTION
$token = getenv('BOT_TOKEN');
$url = "https://api.telegram.org/bot{$token}/getUpdates";
while (true) {
$response = file_get_contents($url);
$data = json_decode($response, true);
foreach ($data['result'] as $update) {
// Process message...
$chatId = $update['message']['chat']['id'];
file_get_contents("https://api.telegram.org/bot{$token}/sendMessage?chat_id={$chatId}&text=Received");
}
}
This code suffers from three critical flaws:
-
No Offset Tracking: Because no
offsetquery parameter is passed back to Telegram, every single iteration ofwhile(true)fetches the exact same list of stored updates. The user receives endless "Received" messages until your bot gets rate-limited or banned. -
Short Polling CPU Burn: Without a long-polling
timeoutparameter, Telegram returns an empty result set immediately if no updates are pending. The script executes hundreds of HTTP requests per second, exhausting local system resources and triggering HTTP 429 (Too Many Requests) errors. -
Unhandled Network Errors:
file_get_contentsthrows a PHP warning and returnsfalseon HTTP connection failures or temporary network drops, causingjson_decodeto fail and crash the execution.
To fix this, we must configure cURL with a dedicated socket timeout, pass an explicit offset parameter derived from the highest update_id seen so far, and validate every JSON response.
Building a Resilient cURL Long-Polling Client
When using Telegram's long-polling mechanism, you supply a timeout parameter (in seconds) in the query string. This tells Telegram to keep the HTTP connection open on their end until a new message arrives or the timeout expires.
Because Telegram holds the connection open, your client-side cURL socket must have a read timeout (CURLOPT_TIMEOUT) slightly higher than the Telegram server request timeout. If Telegram's timeout is set to 30 seconds, your cURL read timeout should be set to 35 seconds. This prevents cURL from aborting the connection right before Telegram responds with an empty set of updates.
Here is a complete, error-tolerant cURL implementation in PHP:
<?php
// polling_engine.php
declare(strict_types=1);
$botToken = getenv('BOT_TOKEN');
if (!$botToken) {
fwrite(STDERR, "BOT_TOKEN environment variable is missing.\n");
exit(1);
}
$offset = 0;
$pollTimeout = 30; // Seconds Telegram holds the connection
$curlTimeout = $pollTimeout + 5; // Extra buffer for socket connection
echo "Starting long-polling worker... Press Ctrl+C to stop.\n";
while (true) {
$params = [
'offset' => $offset,
'timeout' => $pollTimeout,
'allowed_updates' => json_encode(['message', 'callback_query'])
];
$ch = curl_init('https://api.telegram.org/bot' . $botToken . '/getUpdates?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $curlTimeout,
CURLOPT_SSL_VERIFYPEER => true,
]);
$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlErrno !== 0) {
fwrite(STDERR, "cURL error ({$curlErrno}). Retrying in 5 seconds...\n");
sleep(5);
continue;
}
if ($httpCode !== 200) {
fwrite(STDERR, "HTTP Error {$httpCode}. Retrying in 10 seconds...\n");
sleep(10);
continue;
}
$response = json_decode((string)$rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($response['ok']) || $response['ok'] !== true) {
fwrite(STDERR, "Invalid JSON response or Telegram API error.\n");
sleep(5);
continue;
}
$updates = $response['result'] ?? [];
foreach ($updates as $update) {
$updateId = (int)$update['update_id'];
// Advance offset to acknowledge this update and all updates before it
$offset = $updateId + 1;
echo "Processing update_id: {$updateId}\n";
// Business logic goes here...
}
}
By passing $offset = $updateId + 1, Telegram acknowledges that update $updateId has been successfully delivered and removes it from its internal server queue. The next call to getUpdates will only fetch updates with an ID greater than or equal to the new $offset.
Guaranteeing Idempotency Across Script Restarts
Advancing the $offset variable in PHP memory works fine while the loop runs continuously. However, local development involves constant code revisions, manual terminal restarts (Ctrl+C), or unexpected PHP errors.
If your script receives update 100500, performs a database side effect (e.g., creating a user booking), and is terminated before executing the next getUpdates cURL request, memory is cleared. When you restart the script, $offset resets to 0. Telegram will re-deliver update 100500, causing your code to duplicate the booking.
To ensure idempotency during local development, store processed update IDs in a local storage medium—such as an SQLite database—before executing any side effects.
<?php
// idempotent_worker.php
declare(strict_types=1);
$botToken = getenv('BOT_TOKEN');
$dbFile = __DIR__ . '/bot_state.sqlite';
$pdo = new PDO('sqlite:' . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create state and deduplication tables
$pdo->exec("
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS processed_updates (
update_id INTEGER PRIMARY KEY,
processed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
// Load persisted offset
$stmt = $pdo->prepare("SELECT value FROM system_state WHERE key = 'last_offset'");
$stmt->execute();
$offset = (int)($stmt->fetchColumn() ?: 0);
while (true) {
$params = [
'offset' => $offset,
'timeout' => 20,
];
$ch = curl_init('https://api.telegram.org/bot' . $botToken . '/getUpdates?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
]);
$rawResponse = curl_exec($ch);
curl_close($ch);
$response = json_decode((string)$rawResponse, true);
if (!isset($response['ok']) || $response['ok'] !== true) {
sleep(2);
continue;
}
foreach ($response['result'] as $update) {
$updateId = (int)$update['update_id'];
// Start transaction for atomic processing
$pdo->beginTransaction();
try {
// Check if update was already executed
$checkStmt = $pdo->prepare("SELECT 1 FROM processed_updates WHERE update_id = ?");
$checkStmt->execute([$updateId]);
if ($checkStmt->fetchColumn()) {
$pdo->rollBack();
$offset = $updateId + 1;
continue;
}
// Execute side effect
if (isset($update['message']['text'])) {
$chatId = $update['message']['chat']['id'];
$text = htmlspecialchars($update['message']['text'], ENT_QUOTES, 'UTF-8');
// Perform business logic here
}
// Record update execution
$logStmt = $pdo->prepare("INSERT INTO processed_updates (update_id) VALUES (?)");
$logStmt->execute([$updateId]);
// Update offset state in DB
$offset = $updateId + 1;
$stateStmt = $pdo->prepare("INSERT OR REPLACE INTO system_state (key, value) VALUES ('last_offset', ?)");
$stateStmt->execute([(string)$offset]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
fwrite(STDERR, "Processing failed for update {$updateId}: " . $e->getMessage() . "\n");
break; // Exit foreach loop to retry this update on next poll
}
}
}
If the PHP script crashes or is terminated mid-execution, the SQLite transaction rolls back. Upon script restart, the persisted $offset is read from the system_state table, ensuring exact single-execution guarantees even across forced restarts.
Production Notes and Webhook Strategy
While long polling is ideal for local development, production environments require structural changes:
-
Memory Leak Management in CLI Mode: PHP long-running CLI scripts can accumulate memory over thousands of loop cycles. Always clear large variables, invoke
gc_collect_cycles()explicitly at the end of every loop iteration, or configure your process controller (e.g., Supervisor) to restart the script periodically after a fixed number of processed requests. -
Graceful Signal Handling: Enable the
pcntlextension in CLI PHP to handle system signals. Listening forSIGINT(Ctrl+C) orSIGTERM(Supervisor stop command) allows the loop to finish processing the current update batch before exiting safely. -
Switching to Webhooks in Production: Long polling restricts your bot to a single thread or single-worker process. Concurrent updates from thousands of users cannot be handled efficiently in a simple PHP
while(true)loop. In production, configure a webhook URL viasetWebhook. Telegram will send HTTP POST requests directly to your web server (Nginx/Apache), leveraging standard HTTP worker pools for concurrent execution. -
Deleting Webhook Before Polling: Telegram prohibits calling
getUpdateswhile an active webhook is set. If you switch from production testing back to local long polling, always callhttps://api.telegram.org/bot<TOKEN>/deleteWebhookfirst; otherwise,getUpdatesreturns HTTP 409 Conflict.
When scaling beyond local development scripts to high-traffic production bots or custom Mini Apps, partner with an experienced development team like BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)