Разработчики часто выбирают сторонние пакеты вроде nutgram/nutgram или irazasyed/telegram-bot-sdk для быстрой сборки ботов. Однако в высоконагруженных или долгоживущих проектах на Laravel готовые библиотеки добавляют лишний уровень абстракции, ухудшают гибкость при работе с Laravel Queue и осложняют точечное юнит-тестирование. Telegram Bot API — это лаконичный REST-подобный протокол с JSON-клиентами, поэтому написать собственный прозрачный слой интеграции оказывается проще и надежнее.
В этой статье мы построим интеграцию Telegram Bot API в архитектуру Laravel: напишем тонкий сервисный cURL-клиент, настроим эндпоинт Webhook с валидацией заголовочных ключей и гарантией идемпотентности, вынесем тяжелую бизнес-логику в фоновые задачи (Jobs) и напишем автоматические Feature-тесты.
Выбор архитектуры: тонкий cURL-клиент против heavy-пакетов
Использование сторонних SDK сопряжено с несколькими рисками в экосистеме Laravel:
- Лишний оверхед и утечки памяти: многие пакеты тащат собственные кастомные контейнеры или реестры роутов, которые могут течь при выполнении воркеров
php artisan queue:work. - Сложность кастомизации: если вам требуется специфическая обработка ошибок 429 (Too Many Requests), повторные запросы (retry logic) или логирование в отдельные каналы Monolog, подстраивать сторонний SDK становится сложнее, чем обернуть cURL.
- Проблемы с тестированием: мокать глубокие структуры классов пакетов в тестах значительно дольше, чем подменять один сервисный класс или использовать встроенный HTTP-феик.
Минимальная надежная архитектура состоит из трех элементов: контроллер вебхука (принимает запрос, проверяет подлинность, сохраняет update_id и быстро отвечает 200 OK), фоновая Job-задача (обрабатывает событие) и сервисный cURL-клиент для отправки ответов.
Роут вебхука, проверка Secret Token и защита от дублей update_id
Telegram Bot API при установке вебхука через метод setWebhook позволяет передать параметр secret_token. Telegram будет отправлять этот токен в каждом HTTP-запросе в заголовке X-Telegram-Bot-Api-Secret-Token. Валидация этого заголовка гарантирует, что запрос действительно пришел от серверов Telegram, а не от злоумышленника.
Второе важное требование — идемпотентность. Если ваш сервер не ответит кодом 200 OK в течение нескольких секунд (из-за сетевой задержки или высокой нагрузки), Telegram повторит отправку того же update_id. Чтобы избежать повторного создания заказов или дублирования сообщений, необходимо атомарно регистрировать полученные update_id в базе данных.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Jobs\ProcessTelegramUpdate;
class TelegramWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$secretToken = (string) config('services.telegram.webhook_secret');
$headerToken = (string) $request->header('X-Telegram-Bot-Api-Secret-Token');
if (empty($secretToken) || !hash_equals($secretToken, $headerToken)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$payload = $request->all();
$updateId = $payload['update_id'] ?? null;
if (!$updateId) {
return response()->json(['error' => 'Invalid update payload'], 400);
}
// Защита от повторной обработки дублирующихся update_id
$inserted = DB::table('telegram_updates')->insertOrIgnore([
'update_id' => $updateId,
'created_at' => now(),
]);
if (!$inserted) {
return response()->json(['status' => 'already_processed'], 200);
}
// Передаем обновление в очередь для мгновенного освобождения HTTP-потока
ProcessTelegramUpdate::dispatch($payload);
return response()->json(['status' => 'ok'], 200);
}
}
Обработка входящих обновлений в очереди Laravel Queue
Внутри задачи ProcessTelegramUpdate разбирается контекст входящего сообщения или нажатия кнопки. При обработке команд deep-linking (формата /start payload, например t.me/YourBot?start=ref123) параметр передается через пробел после команды /start.
При создании записей в БД (например, карточки лида или заявки) генерируйте уникальные безопасные идентификаторы с помощью bin2hex(random_bytes(7)). Также помните об ограничении размера поля callback_data у Inline-кнопок: оно не может превышать 64 байта, поэтому передавать сложные JSON-структуры в кнопки нельзя — передавайте только короткие ID.
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use App\Services\TelegramClient;
class ProcessTelegramUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public array $update
) {}
public function handle(TelegramClient $client): void
{
if (isset($this->update['message'])) {
$message = $this->update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = trim($message['text'] ?? '');
if (!$chatId) {
return;
}
// Обработка стартовой команды и deep-link
if (str_starts_with($text, '/start')) {
$parts = explode(' ', $text, 2);
$refCode = $parts[1] ?? null;
$leadId = bin2hex(random_bytes(7));
DB::table('leads')->insert([
'lead_id' => $leadId,
'telegram_id' => $message['from']['id'],
'ref_code' => $refCode,
'created_at' => now(),
]);
$client->sendMessage([
'chat_id' => $chatId,
'text' => "Заявка #{$leadId} принята! Мы скоро свяжемся с вами.",
'parse_mode' => 'HTML',
]);
}
}
}
}
Реализация сервиса TelegramClient на cURL
Для прямого взаимодействия с HTTP API Telegram напишем клиент TelegramClient. Нам не нужны сторонние пакеты: чистый cURL с валидацией HTTP-статуса, обработкой json_last_error() и проверкой атрибута ok === true гарантирует предсказуемое поведение и удобное логирование.
<?php
namespace App\Services;
use RuntimeException;
class TelegramClient
{
private string $token;
private string $apiUrl;
public function __construct()
{
$this->token = (string) config('services.telegram.bot_token');
$this->apiUrl = "https://api.telegram.org/bot{$this->token}/";
}
public function sendMessage(array $params): array
{
return $this->request('sendMessage', $params);
}
public function request(string $method, array $params = []): array
{
if (empty($this->token)) {
throw new RuntimeException('Telegram bot token is missing in configuration.');
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->apiUrl . $method,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new RuntimeException("cURL Error call Telegram API: {$curlError}");
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Telegram API invalid JSON: " . json_last_error_msg());
}
if ($httpCode !== 200 || !isset($data['ok']) || $data['ok'] !== true) {
$description = $data['description'] ?? 'Unknown error';
$errorCode = $data['error_code'] ?? $httpCode;
throw new RuntimeException("Telegram API Error [{$errorCode}]: {$description}");
}
return $data['result'];
}
}
Тестирование Webhook и логики через фейковые Update
Главный плюс такой архитектуры — легкая подменяемость и изоляция в автотестах Laravel. Вы можете протестировать обработку вебхука без обращения к серверам Telegram, фейкуя очередь задач Queue::fake() и проверяя записи в базе данных.
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessTelegramUpdate;
class TelegramWebhookTest extends TestCase
{
use RefreshDatabase;
public function test_webhook_accepts_valid_request_and_dispatches_job(): void
{
Queue::fake();
config(['services.telegram.webhook_secret' => 'test-secret-key']);
$payload = [
'update_id' => 88776655,
'message' => [
'message_id' => 101,
'from' => ['id' => 999111, 'is_bot' => false, 'first_name' => 'Ivan'],
'chat' => ['id' => 999111, 'type' => 'private'],
'text' => '/start partner_abc',
],
];
$response = $this->withHeaders([
'X-Telegram-Bot-Api-Secret-Token' => 'test-secret-key',
])->postJson('/api/telegram/webhook', $payload);
$response->assertStatus(200);
$response->assertJson(['status' => 'ok']);
$this->assertDatabaseHas('telegram_updates', [
'update_id' => 88776655,
]);
Queue::assertPushed(ProcessTelegramUpdate::class, function ($job) use ($payload) {
return $job->update['update_id'] === $payload['update_id'];
});
}
public function test_webhook_blocks_unauthorized_requests(): void
{
config(['services.telegram.webhook_secret' => 'correct-token']);
$response = $this->withHeaders([
'X-Telegram-Bot-Api-Secret-Token' => 'invalid-token',
])->postJson('/api/telegram/webhook', ['update_id' => 12345]);
$response->assertStatus(401);
}
}
Отказ от монструозных SDK в пользу собственного подконтрольного cURL-клиента и стандартных механизмов Laravel (Queue, DB, Feature Tests) дает максимальную стабильность, позволяет точечно обрабатывать лимиты Telegram Bot API и упрощает поддержку проекта на любом этапе.
Если вам нужна помощь в разработке отказоустойчивых веб-сервисов и ботов, команда BotCreator поможет реализовать проект любой сложности.