Automating PDF invoice generation, daily data exports, or user media delivery through Telegram bots in PHP looks simple on paper. However, naive implementations frequently crash in production. Developers often attempt to read whole files into string buffers using file_get_contents() or pass arbitrary remote URLs to Telegram's API without size validation. When your background worker attempts to send a 40MB export file on a server configured with a standard 128MB memory_limit, the script runs out of RAM, leaving the queue stalled and the user hanging without feedback.
Even when files do send successfully, re-uploading the exact same binary payload across hundreds of users burns server outbound bandwidth and triggers rate limits (429 Too Many Requests). A robust PHP integration must stream local files safely via cURL multipart form submissions, extract and persist Telegram's native file_id strings for instant future re-use, and intelligently determine when to pass a public URL instead of transferring local disk bytes.
Here is how to structure file handling in PHP to prevent memory exhaustions, handle network errors predictably, and eliminate redundant file uploads.
Uploading Local Files via cURL and CURLFile
When sending local files to Telegram's sendDocument or sendPhoto endpoints, your request header must use multipart/form-data. Legacy PHP code often relies on the deprecated @/path/to/file string syntax inside cURL parameter arrays. In modern PHP (7.4+ and 8.x), passing file paths preceded by @ is disabled by default and will fail or send plain text strings instead of binary payload headers.
You must explicitly wrap your absolute file path in a CURLFile object. The CURLFile class streams the binary data directly from the disk filesystem descriptor straight into the network socket during the HTTP POST request. This approach keeps the memory footprint locked to a few kilobytes, regardless of whether the document on disk is 500 KB or 45 MB.
Below is a complete, standalone script that validates disk readability, detects MIME types via finfo, handles HTTP and cURL errors, and validates Telegram's API response status:
<?php declare(strict_types=1); function sendTelegramDocument( string $botToken, int|string $chatId, string $filePath, string $caption = '' ): array { if (!is_file($filePath) || !is_readable($filePath)) { throw new InvalidArgumentException("File not found or unreadable: {$filePath}"); } // Verify mime type before upload $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $filePath); finfo_close($finfo); $postFields = [ 'chat_id' => $chatId, 'caption' => $caption, 'parse_mode' => 'HTML', // CURLFile handles streaming without loading file contents into PHP memory 'document' => new CURLFile($filePath, $mimeType ?: 'application/octet-stream', basename($filePath)) ]; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => "https://api.telegram.org/bot{$botToken}/sendDocument", CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postFields, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 120, // Allow up to 2 minutes for heavy file transfers CURLOPT_SSL_VERIFYPEER => true, ]); $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 execution failed ({$curlErrno}): {$curlError}"); } if ($httpCode !== 200) { throw new RuntimeException("Telegram API returned non-200 HTTP code: {$httpCode}. Response: {$rawResponse}"); } $response = json_decode($rawResponse, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new RuntimeException("Failed to parse Telegram JSON response: " . json_last_error_msg()); } if (!isset($response['ok']) || $response['ok'] !== true) { $description = $response['description'] ?? 'Unknown error'; $errorCode = $response['error_code'] ?? 0; throw new RuntimeException("Telegram API error ({$errorCode}): {$description}"); } return $response['result']; } // Usage Example try { $token = getenv('BOT_TOKEN'); $result = sendTelegramDocument($token, 123456789, '/var/app/exports/report_2026_03.pdf', 'Monthly <b>Report</b>'); $fileId = $result['document']['file_id']; echo "Upload success! Saved file_id: " . $fileId; } catch (Throwable $e) { error_log("Failed to send document: " . $e->getMessage()); } Skipping structural response validation is a common vulnerability. Even if an HTTP connection succeeds with status code 200, Telegram may return a payload where 'ok' => false if parameters like chat_id are invalid or if the caption exceeds maximum length limits. Always verify $response['ok'] === true before assuming the upload succeeded.
Caching and Reusing file_id to Save Bandwidth
When Telegram processes a media upload, its servers store the asset in their internal CDN infrastructure and issue a unique file_id in the API response. This string identifier acts as an immutable reference pointer to the cached file on Telegram's servers.
Re-uploading a 20MB user manual every time a user requests it via a bot command wastes bandwidth, CPU cycles, and disk I/O. Instead, compute a checksum (like SHA-256) of your local file before sending. Save the returned file_id alongside the hash in your database. On subsequent send requests, pass the plain file_id string instead of a CURLFile instance.
When sending a string file_id, Telegram sends the cached file instantly without accepting any file payload from your server. The transfer switches from a 20MB file upload taking seconds to a 2KB JSON request taking under 50 milliseconds.
Here is a complete workflow pattern implementing file hashing, database lookup, sending via file_id, and fallback to upload if the file_id becomes invalid:
<?php declare(strict_types=1); class TelegramMediaManager { public function __construct( private string $botToken, private PDO $db ) {} public function sendCachedDocument(int|string $chatId, string $filePath, string $caption = ''): array { if (!file_exists($filePath)) { throw new InvalidArgumentException("File missing: {$filePath}"); } $sha256 = hash_file('sha256', $filePath); $fileId = $this->fetchCachedFileId($sha256); if ($fileId !== null) { try { // Attempt sending using existing file_id return $this->sendPayload([ 'chat_id' => $chatId, 'document' => $fileId, 'caption' => $caption, 'parse_mode' => 'HTML' ]); } catch (RuntimeException $e) { // If Telegram rejected the file_id (e.g. invalid or revoked), log and fall back error_log("Cached file_id rejected for {$filePath}. Re-uploading. Error: " . $e->getMessage()); $this->invalidateCache($sha256); } } // Fallback: Perform complete binary upload $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $filePath); finfo_close($finfo); $payload = [ 'chat_id' => $chatId, 'document' => new CURLFile($filePath, $mimeType ?: 'application/octet-stream', basename($filePath)), 'caption' => $caption, 'parse_mode' => 'HTML' ]; $result = $this->sendPayload($payload); if (isset($result['document']['file_id'])) { $this->saveFileIdCache($sha256, $result['document']['file_id']); } return $result; } private function sendPayload(array $fields): array { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => "https://api.telegram.org/bot{$this->botToken}/sendDocument", CURLOPT_POST => true, CURLOPT_POSTFIELDS => $fields, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]); $raw = curl_exec($ch); $error = curl_error($ch); curl_close($ch); if ($error) { throw new RuntimeException("cURL Request Error: {$error}"); } $res = json_decode((string)$raw, true); if (!($res['ok'] ?? false)) { throw new RuntimeException($res['description'] ?? 'API Call Failed'); } return $res['result']; } private function fetchCachedFileId(string $hash): ?string { $stmt = $this->db->prepare("SELECT file_id FROM media_cache WHERE file_hash = :hash LIMIT 1"); $stmt->execute(['hash' => $hash]); $col = $stmt->fetchColumn(); return $col ? (string)$col : null; } private function saveFileIdCache(string $hash, string $fileId): void { $stmt = $this->db->prepare( "INSERT INTO media_cache (file_hash, file_id, created_at) VALUES (:hash, :file_id, NOW()) ON DUPLICATE KEY UPDATE file_id = :file_id" ); $stmt->execute(['hash' => $hash, 'file_id' => $fileId]); } private function invalidateCache(string $hash): void { $stmt = $this->db->prepare("DELETE FROM media_cache WHERE file_hash = :hash"); $stmt->execute(['hash' => $hash]); } } Note that sendPhoto responses differ structurally from sendDocument. When uploading a photo via sendPhoto, Telegram processes the image into multiple resolutions and returns an array of photo objects inside $result['photo']. To cache the highest-resolution photo file_id, pick the last element in that array: end($result['photo'])['file_id'].
Passing Direct URLs vs Local Binary Streams
Instead of uploading local disk binaries or passing a file_id, Telegram allows you to pass a direct public HTTP/HTTPS URL as the string value for your media parameter (e.g., 'photo' => 'https://example.com/images/banner.jpg').
When a public URL is supplied, your server transfers zero file data to Telegram. Instead, Telegram's servers asynchronously download the file directly from that remote URL, process it, and deliver it to the chat. However, relying on URLs introduces specific restrictions and potential points of failure that must be evaluated:
- Strict File Size Limits: Telegram's server-side downloader imposes tighter file size caps on remote URLs. Photos downloaded via URL are limited to 10MB, and standard documents downloaded via URL are limited to 20MB. If you upload directly via multipart form POST, standard bots can upload documents up to 50MB (or 2GB if running a custom local Bot API Server).
- Network Reachability & Firewall Blocks: Telegram's fetch worker must be able to reach your web server. If your host blocks foreign IPs, enforces rate limiting, sits behind strict Cloudflare Bot Management rules, or uses a self-signed TLS certificate, Telegram will fail with
Bad Request: wrong file identifier/HTTP URL specified. - MIME and Content-Type Headers: Telegram verifies the HTTP
Content-Typeheader served by the remote URL. If your web app outputs media dynamically without sending correct content headers, Telegram will reject the file request.
Here is a decision dispatcher function demonstrating when to pass a string URL, a cached file_id, or a binary CURLFile stream based on input characteristics:
<?php declare(strict_types=1); function buildTelegramMediaPayload(string $target, string $caption = ''): array { $payload = [ 'caption' => $caption, 'parse_mode' => 'HTML' ]; // 1. Is it a cached file_id string? if (preg_match('/^[A-Za-z0-9_-]{20,100}$/', $target)) { $payload['document'] = $target; return $payload; } // 2. Is it a remote public HTTP/HTTPS URL? if (filter_var($target, FILTER_VALIDATE_URL) !== false) { $scheme = parse_url($target, PHP_URL_SCHEME); if (in_array($scheme, ['http', 'https'], true)) { $payload['document'] = $target; return $payload; } } // 3. Fallback: Treat as local filesystem file path if (file_exists($target)) { $size = filesize($target); if ($size > 50 * 1024 * 1024) { throw new OutOfBoundsException("File exceeds standard Bot API upload limit of 50MB (Size: {$size} bytes)"); } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $target) ?: 'application/octet-stream'; finfo_close($finfo); $payload['document'] = new CURLFile($target, $mime, basename($target)); return $payload; } throw new InvalidArgumentException("Unable to process media target: {$target}"); } Production Notes
- cURL Timeouts: When uploading 30MB to 50MB files on slower outbound network connections, default cURL timeouts (like 30 seconds) will prematurely cut off transfers. Set
CURLOPT_CONNECTTIMEOUTto10seconds andCURLOPT_TIMEOUTto120or higher to allow uploads to complete reliably. - Avoiding In-Memory Buffers: Never read media into variables with
file_get_contents()orfopen()string reads to manually construct multipart forms. Rely strictly on nativeCURLFilestreaming to keep PHP memory consumption stable under concurrency. - Bot API Limits: Native Bot API uploads are capped at 20MB for photos and 50MB for general files. If your application routinely distributes massive media (up to 2GB), you must deploy an instance of the open-source
telegram-bot-apilocal server and direct your cURL requests to that endpoint instead ofapi.telegram.org. - Replay & Concurrency Prevention: Ensure background job queues processing file uploads are idempotent. If a network blip occurs during
curl_exec(), check whether Telegram processed the message before blindly retrying, preventing duplicate file delivery to the end user.
Building resilient Telegram integrations in PHP requires moving beyond quick prototypes and properly accounting for memory bounds, response checks, and storage optimization. If you are launching complex Telegram platforms or need dedicated engineering help, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.
