Отправка фото и документов из PHP — типовая задача бота: прайс, чек, карточка товара, PDF-договор. Bot API даёт для этого sendPhoto и sendDocument. Ниже — рабочие функции на cURL с multipart, повторная отправка по file_id и лимиты размера, о которые бьются чаще всего.
Почему multipart, а не обычный POST
Текстовые методы вроде sendMessage спокойно уходят через application/x-www-form-urlencoded. Файлы Telegram принимает только как multipart/form-data: каждое поле — отдельная часть тела, файл — с Content-Disposition и бинарным содержимым.
В PHP для этого есть CURLFile. Передаёте массив в CURLOPT_POSTFIELDS — cURL сам соберёт multipart. Старый синтаксис с префиксом @ лучше не использовать.
Общая обёртка Bot API
Одна функция на все вызовы: токен из окружения, проверка HTTP-статуса и флага ok в JSON. Для файлов не вызывайте http_build_query — иначе CURLFile превратится в строку.
<?php
function tgRequest(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set');
}
$ch = curl_init('https://api.telegram.org/bot' . $token . '/' . $method);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params, // массив: cURL сам выберет multipart при CURLFile
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60, // загрузка файла может быть дольше sendMessage
]);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL error: ' . $err);
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Bad JSON from Telegram, HTTP ' . $status);
}
if (empty($decoded['ok'])) {
$desc = $decoded['description'] ?? 'unknown error';
throw new RuntimeException('Telegram error: ' . $desc);
}
return $decoded['result'];
}
sendPhoto: локальный файл
Лимит локального фото — 10 МБ. Telegram дополнительно сжимает изображение (ориентир до ~1280 px по длинной стороне). Если нужна исходная картинка без сжатия — шлите через sendDocument.
<?php
function tgSendPhoto(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Photo not found: ' . $path);
}
if (filesize($path) > 10 * 1024 * 1024) {
throw new InvalidArgumentException('Photo exceeds 10 MB limit');
}
$mime = mime_content_type($path) ?: 'image/jpeg';
$params = [
'chat_id' => $chatId,
'photo' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption; // до 1024 символов
}
return tgRequest('sendPhoto', $params);
}
CURLFile принимает путь, MIME и имя файла. MIME можно указать явно (image/jpeg, image/png, image/webp) — Telegram всё равно проверит содержимое, но валидация проходит спокойнее.
sendDocument: PDF, XLSX, ZIP
Тот же паттерн, лимит выше — 50 МБ для локальной загрузки. Подходит для прайсов, актов, архивов с вложениями.
<?php
function tgSendDocument(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Document not found: ' . $path);
}
if (filesize($path) > 50 * 1024 * 1024) {
throw new InvalidArgumentException('Document exceeds 50 MB limit');
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
$params = [
'chat_id' => $chatId,
'document' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendDocument', $params);
}
file_id: не загружать один файл дважды
В ответе sendPhoto / sendDocument есть массив photo (несколько размеров) или объект document. У каждого варианта есть file_id. Сохраните его в БД и дальше передавайте строкой — без multipart и без повторной загрузки байтов.
<?php
/** Достаём file_id самого крупного варианта фото из result sendPhoto. */
function extractPhotoFileId(array $message): string
{
$photos = $message['photo'] ?? [];
if ($photos === []) {
throw new RuntimeException('No photo in message');
}
$largest = end($photos);
return $largest['file_id'];
}
function extractDocumentFileId(array $message): string
{
$doc = $message['document'] ?? null;
if (!is_array($doc) || empty($doc['file_id'])) {
throw new RuntimeException('No document in message');
}
return $doc['file_id'];
}
/** Повторная отправка уже известного файла — обычный form POST, без CURLFile. */
function tgSendPhotoByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}
function tgSendDocumentByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'document' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendDocument', $params);
}
Практичный сценарий каталога: при создании карточки товара один раз вызываете tgSendPhoto (можно в служебный чат), сохраняете file_id, клиентам шлёте только идентификатор. Экономия трафика и времени ответа ощутима уже на десятках SKU.
Важно: file_id привязан к боту. Чужой бот тот же идентификатор не примет. Срок жизни официально не зафиксирован; на практике идентификаторы живут месяцами, но после массовой миграции бота имеет смысл перезалить критичные медиа.
URL вместо загрузки
Поля photo и document принимают и публичный HTTPS-URL. Telegram скачает файл сам. Удобно, если картинка уже в CDN/S3. Не сработает с localhost, basic-auth в URL и ссылками, недоступными с серверов Telegram.
<?php
function tgSendPhotoByUrl(int $chatId, string $url, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $url,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}
Лимиты и типичные ошибки
- sendPhoto — до 10 МБ (файл или URL).
- sendDocument — до 50 МБ (файл или URL).
- caption — до 1024 символов.
- Повтор по
file_idлимит размера загрузки не трогает: байты уже на стороне Telegram.
«File is too big» — проверьте filesize() до вызова. «wrong URL host» — публичный HTTPS без авторизации в URL. Пустой ответ / «failed to get HTTP URL content» — CDN отдаёт 403 ботам Telegram или режет User-Agent.
Перед отправкой сгенерированного PDF пишите его во временный файл (tempnam + запись), передавайте путь в CURLFile, после успеха удаляйте. Поток в памяти без файла cURL для Telegram обычно неудобен.
Что выбрать
- Статичные картинки каталога — один upload, дальше
file_id. - Динамические PDF/отчёты — multipart через
CURLFile. - Файлы уже в публичном CDN — URL.
Если собираете бота с медиакаталогом под ключ, на botservice.biz можно обсудить архитектуру хранения file_id и выдачу документов без лишних повторных загрузок.