Imagine running an automated PDF receipt generator or digital asset delivery service inside a Telegram bot built with PHP. Each time a user requests a 15 MB file, your backend opens a file handle, reads the file from disk, and transmits it to Telegram's sendDocument endpoint using multipart/form-data.
At low volume, this approach passes unnoticed. However, under standard production load—such as fifty concurrent users requesting assets during a marketing push—your PHP-FPM worker pool rapidly consumes available RAM. Reading multiple multi-megabyte payloads simultaneously spikes memory allocations, saturates outbound network interfaces, and eventually triggers HTTP 429 rate-limit responses or connection timeouts from the Telegram Bot API.
The Telegram Bot API provides three methods for transmitting media: sending raw binary streams via multipart forms, passing a publicly accessible HTTPS URL, or supplying an existing file_id string. Re-uploading raw binaries for identical files is inefficient and dangerous for server stability. Passing HTTP URLs avoids local upload overhead but introduces external latency and relies on Telegram successfully pulling data from your public web server within strict timeout limits.
To build a resilient media pipeline, you must handle raw uploads through streaming cURL descriptors, enforce exact payload boundary checks, and capture Telegram's returned file_id hashes into a local persistence layer. Reusing these identifiers converts multi-megabyte network transfers into sub-kilobyte string payloads, keeping server RAM flat and response times under 50 milliseconds.
Stream Multipart Media Uploads with Raw cURL and Error Handling
When transmitting local files to Telegram, never load the entire file into a PHP string variable using functions like file_get_contents(). Doing so doubles your script's memory footprint by allocating space for the variable in the PHP engine and again inside cURL's internal payload buffers. Instead, pass a native CURLFile object directly into the payload array. This allows cURL to stream the raw bytes straight from the filesystem disk descriptor.
Every API request must validate three distinct failure layers: transport-level failures (cURL errors), HTTP status codes returned by Telegram's edge servers, and application-level failures denoted by ok: false in the returned JSON body.
Here is a robust helper function for transmitting local media files safely using multipart/form-data:
<?php
declare(strict_types=1);
function sendTelegramDocumentMultipart(
string $botToken,
int|string $chatId,
string $filePath,
string $caption = ''
): array {
if (!file_exists($filePath) || !is_readable($filePath)) {
throw new \InvalidArgumentException("File not found or unreadable: {$filePath}");
}
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$postFilename = basename($filePath);
// Stream directly from disk without reading the file into PHP string memory
$cFile = new \CURLFile($filePath, $mimeType, $postFilename);
$payload = [
'chat_id' => (string)$chatId,
'document' => $cFile,
'parse_mode' => 'HTML',
];
if ($caption !== '') {
$payload['caption'] = htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://api.telegram.org/bot{$botToken}/sendDocument",
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60, // Allow sufficient time for multi-megabyte uploads
]);
$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlErrno !== 0) {
throw new \RuntimeException("cURL transport failure ({$curlErrno}): {$curlError}");
}
$decoded = json_decode((string)$rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \UnexpectedValueException("Invalid JSON response from Telegram (HTTP {$httpCode})");
}
if (!isset($decoded['ok']) || $decoded['ok'] !== true) {
$description = $decoded['description'] ?? 'Unknown API error';
$errorCode = $decoded['error_code'] ?? $httpCode;
throw new \DomainException("Telegram API Error {$errorCode}: {$description}");
}
return $decoded['result'];
}
This implementation prevents memory bloat by letting cURL stream the file, enforces explicit timeouts, escapes HTML captions securely, and unwraps Telegram's application response.
Select Between Direct URL Passing and Multipart Binaries
Telegram allows developers to pass a URL string (e.g., https://example.com/assets/manual.pdf) instead of raw binary data. When receiving a URL, Telegram's servers attempt to download the target file directly from your web server and cache it on their infrastructure.
While URL passing eliminates local binary upload overhead, it introduces specific limitations that cause subtle production bugs:
1. Size Limits: Standard bot API endpoints permit uploading files up to 50 MB using multipart HTTP requests. However, when passing a URL, Telegram limits the download to 20 MB max. 2. Download Timeouts: Telegram's web crawler will abort the request if your origin server takes longer than a few seconds to return the response headers and start streaming. 3. MIME and Header Strictness: If your web server fails to send a clear Content-Length header or uses non-standard HTTP redirection codes, Telegram rejects the payload with 400 Bad Request: wrong file identifier/HTTP URL specified.
The decision matrix for choosing between URL passing and raw multipart streaming comes down to file size and accessibility:
| Upload Method | Max File Size | Memory Footprint | Network Bandwidth | Dependency | | :--- | :--- | :--- | :--- | :--- | | URL Reference | 20 MB | Minimal | Very Low | Requires Public HTTPS URL | | Multipart Binary | 50 MB | Low (Streamed) | High | Local Filesystem Access | | file_id Reference | 2000 MB | Negligible | Negligible | Pre-cached Telegram File ID |
Before sending a document, evaluate the payload parameters programmatically:
<?php
declare(strict_types=1);
function dispatchTelegramDocument(
string $botToken,
int|string $chatId,
string $source,
string $caption = ''
): array {
// Case 1: Source is a remote URL
if (filter_var($source, FILTER_VALIDATE_URL) !== false) {
// Enforce scheme security
$scheme = parse_url($source, PHP_URL_SCHEME);
if ($scheme !== 'https') {
throw new \InvalidArgumentException("Telegram requires HTTPS URLs for remote uploads.");
}
return sendTelegramPayload($botToken, 'sendDocument', [
'chat_id' => (string)$chatId,
'document' => $source,
'caption' => htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'parse_mode' => 'HTML',
]);
}
// Case 2: Source is a local file path
if (file_exists($source)) {
$fileSize = filesize($source);
if ($fileSize === false || $fileSize > 50 * 1024 * 1024) {
throw new \InvalidArgumentException("Local file exceeds Telegram's 50MB Bot API limit.");
}
return sendTelegramDocumentMultipart($botToken, $chatId, $source, $caption);
}
// Case 3: Source is assumed to be an existing file_id string
return sendTelegramPayload($botToken, 'sendDocument', [
'chat_id' => (string)$chatId,
'document' => $source,
'caption' => htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'parse_mode' => 'HTML',
]);
}
function sendTelegramPayload(string $botToken, string $method, array $payload): array
{
$ch = curl_init("https://api.telegram.org/bot{$botToken}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode((string)$response, true);
if (!isset($decoded['ok']) || $decoded['ok'] !== true) {
$desc = $decoded['description'] ?? 'Unknown Error';
throw new \DomainException("Telegram API Error ({$httpCode}): {$desc}");
}
return $decoded['result'];
}
Cache and Reuse file_id to Skip Media Upload Overhead
When Telegram processes a document or photo, the API response includes a unique file_id inside the document or photo payload array. For photos, Telegram returns multiple size variants; the last array element represents the highest-resolution object.
A file_id is an opaque string that points to the file hosted directly on Telegram's Content Delivery Network (CDN). Re-sending a file using its file_id transfers no local file data. The request payload size drops from tens of megabytes to less than 500 bytes.
To take full advantage of this feature, calculate a unique signature (such as an sha256 hash of the binary file) and store the corresponding file_id in your database or cache key-value store.
Here is a complete, production-ready class that handles media caching, local file hashing, and automated fallback execution:
<?php
declare(strict_types=1);
final class TelegramMediaManager
{
private string $botToken;
private \PDO $db;
public function __construct(string $botToken, \PDO $db)
{
$this->botToken = $botToken;
$this->db = $db;
}
public function sendCachedDocument(
int|string $chatId,
string $localFilePath,
string $caption = ''
): array {
if (!file_exists($localFilePath)) {
throw new \InvalidArgumentException("File does not exist: {$localFilePath}");
}
$fileHash = hash_file('sha256', $localFilePath);
if ($fileHash === false) {
throw new \RuntimeException("Failed to generate hash for file: {$localFilePath}");
}
$cachedFileId = $this->getCachedFileId($fileHash);
if ($cachedFileId !== null) {
try {
// Attempt to send using cached file_id string
return sendTelegramPayload($this->botToken, 'sendDocument', [
'chat_id' => (string)$chatId,
'document' => $cachedFileId,
'caption' => htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'parse_mode' => 'HTML',
]);
} catch (\DomainException $e) {
// If the cached file_id is stale or invalidated by Telegram, clear cache and continue to re-upload
$this->invalidateCache($fileHash);
}
}
// Cache miss or stale file_id: Perform full multipart upload
$result = sendTelegramDocumentMultipart($this->botToken, $chatId, $localFilePath, $caption);
// Extract file_id from response structure
$newFileId = $result['document']['file_id'] ?? null;
if (is_string($newFileId) && $newFileId !== '') {
$this->storeFileId($fileHash, $newFileId);
}
return $result;
}
private function getCachedFileId(string $fileHash): ?string
{
$stmt = $this->db->prepare('SELECT file_id FROM telegram_media_cache WHERE file_hash = :hash LIMIT 1');
$stmt Gerard = $stmt->execute([':hash' => $fileHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return is_array($row) && isset($row['file_id']) ? (string)$row['file_id'] : null;
}
private function storeFileId(string $fileHash, string $fileId): void
{
$stmt = $this->db->prepare('
INSERT INTO telegram_media_cache (file_hash, file_id, created_at)
VALUES (:hash, :file_id, :created_at)
ON CONFLICT(file_hash) DO UPDATE SET file_id = EXCLUDED.file_id
');
$stmt->execute([
':hash' => $fileHash,
':file_id' => $fileId,
':created_at' => time(),
]);
}
private function invalidateCache(string $fileHash): void
{
$stmt = $this->db->prepare('DELETE FROM telegram_media_cache WHERE file_hash = :hash');
$stmt->execute([':hash' => $fileHash]);
}
}
The database schema required to back this caching mechanism in PostgreSQL or SQLite is minimal:
CREATE TABLE IF NOT EXISTS telegram_media_cache (
file_hash VARCHAR(64) PRIMARY KEY,
file_id VARCHAR(255) NOT NULL,
created_at INT NOT NULL
);
Production Notes and Edge Case Recovery
When running media uploads at scale, several edge cases will surface during execution:
### 1. file_id Scope and Invalidation A file_id is unique to the bot API account that uploaded it. You cannot upload a document with Bot A, extract the file_id, and send that same file_id using Bot B. Additionally, Telegram occasionally updates its internal encryption keys and file reference formats. When this happens, historic file_id strings may cause API requests to fail with 400 Bad Request: wrong remote file identifier. Your code must explicitly catch this error code, purge the cached row, and re-upload the original binary.
### 2. Timeouts and Network Tuning Network delays scale with file size. For standard webhook responses or inline query callbacks, you must return an HTTP 200 within 10 seconds to prevent Telegram from re-delivering webhooks. If you trigger an asset upload inline inside the webhook request lifecycle, a 40 MB multipart upload will easily breach this 10-second threshold. Offload binary uploads to an asynchronous queue system (e.g., RabbitMQ, Redis Streams, or Laravel Queues) so the webhook handler acknowledges the request instantly.
### 3. MIME Type Detection Safety Never trust client-provided file extensions when generating upload handles. Use native extension functions like mime_content_type() or the finfo extension to analyze the file magic bytes before dispatching. If you attempt to send an invalid image format to sendPhoto, Telegram will return 400 Bad Request: PHOTO_INVALID_DIMENSIONS or 400 Bad Request: IMAGE_PROCESS_FAILED.
If you need a dedicated team to engineer high-throughput bot backends or custom web app integrations, consult BotCreator — studio that ships Telegram bots / Mini Apps.