Developers often choose third-party packages like nutgram/nutgram or irazasyed/telegram-bot-sdk for rapid bot development. However, in high-load or long-lived Laravel projects, off-the-shelf libraries add an unnecessary layer of abstraction, reduce flexibility when working with Laravel Queue, and complicate granular unit testing. The Telegram Bot API is a concise REST-like protocol with JSON clients, making it simpler and more reliable to write your own transparent integration layer.
In this article, we will build a Telegram Bot API integration into Laravel architecture: write a thin service cURL client, configure a Webhook endpoint with header key validation and idempotency guarantees, move heavy business logic into background Jobs, and write automated Feature tests.
Architectural Choice: Thin cURL Client vs. Heavy Packages
Using third-party SDKs comes with several risks in the Laravel ecosystem:
- Extra overhead and memory leaks: Many packages pull in their own custom containers or route registries, which can leak memory during
php artisan queue:workworker execution. - Customization complexity: If you need specific handling for 429 (Too Many Requests) errors, retry logic, or logging to separate Monolog channels, tweaking a third-party SDK is harder than wrapping cURL.
- Testing challenges: Mocking deep class structures of external packages in tests takes significantly longer than mocking a single service class or using the built-in HTTP fake.
A minimal reliable architecture consists of three components: a webhook controller (receives the request, verifies authenticity, saves update_id, and quickly responds with 200 OK), a background Job task (processes the event), and a service cURL client for sending responses.
Webhook Route, Secret Token Verification, and Protection Against Duplicate update_id
When setting up a webhook using the setWebhook method, the Telegram Bot API allows passing a secret_token parameter. Telegram will send this token in every HTTP request inside the X-Telegram-Bot-Api-Secret-Token header. Validating this header ensures that the request actually originated from Telegram servers rather than a malicious actor.
The second important requirement is idempotency. If your server fails to respond with a 200 OK status code within a few seconds (due to network latency or high load), Telegram will retry sending the same update_id. To prevent duplicate order creation or repeated messages, you must atomically register received update_ids in the database.
<?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);
}
}
Processing Incoming Updates in Laravel Queue
Inside the ProcessTelegramUpdate task, the context of an incoming message or button press is parsed. When handling deep-linking commands (such as /start payload, e.g., t.me/YourBot?start=ref123), the parameter is passed separated by a space after the /start command.
When creating database records (for example, lead cards or applications), generate unique secure identifiers using bin2hex(random_bytes(7)). Also, keep in mind the size limit of the callback_data field for Inline buttons: it cannot exceed 64 bytes, so you cannot pass complex JSON structures in buttons — send short IDs instead.
<?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',
]);
}
}
}
}
Implementing the TelegramClient Service with cURL
To interact directly with the Telegram HTTP API, let's write a TelegramClient client. We don't need third-party packages: plain cURL with HTTP status validation, json_last_error() handling, and an ok === true attribute check ensures predictable behavior and convenient logging.
<?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'];
}
}
Testing Webhooks and Logic via Fake Updates
The main advantage of this architecture is easy replaceability and isolation in Laravel automated tests. You can test webhook processing without reaching Telegram servers by mocking the task queue with Queue::fake() and checking database records.
<?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);
}
}
Ditching monstrous SDKs in favor of a self-controlled cURL client and standard Laravel mechanisms (Queue, DB, Feature Tests) provides maximum stability, allows fine-grained handling of Telegram Bot API rate limits, and simplifies project maintenance at any stage.
If you need assistance developing fault-tolerant web services and bots, the BotCreator team will help bring a project of any complexity to life.