Build a Lightweight Telegram Webhook Handler in Laravel with Queues and Feature Tests

Integrating Telegram bots into Laravel applications often leads developers to install heavy third-party SDKs. While these packages provide abstraction, they can introduce maintenance overhead and lag behind the official Telegram Bot API updates.

This tutorial demonstrates how to build a lightweight, dependency-free Telegram webhook integration in Laravel. We will use Laravel's native Http client, secure the endpoint using a secret token, process updates asynchronously using queued jobs, and write a feature test to verify the entire flow.

We do not claim to build a complete SDK replacement or a complex conversational state machine. Instead, we focus on establishing a secure, scalable foundation for receiving and responding to Telegram updates.

1. Configuration and Environment Setup

First, register your Telegram credentials in your configuration files. Avoid calling env() directly outside of configuration files to ensure config caching works correctly.

Add the following block to your config/services.php file:

// config/services.php
return [
// ... other services
'telegram' => [
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
],
];

Define these variables in your .env file. The TELEGRAM_WEBHOOK_SECRET is an arbitrary string of your choice (1-256 characters, alphanumeric, underscores, and hyphens) used to verify that the webhook request originates from Telegram.

TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_WEBHOOK_SECRET=a_secure_random_string_here

2. Creating the Thin HTTP Client

Laravel's Illuminate\Support\Facades\Http client provides a clean wrapper around Guzzle. We can build a thin client class to handle outgoing requests to the Telegram Bot API.

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;
use RuntimeException;

class TelegramClient

{
protected string $baseUrl;

public function __construct()
{
$token = config('services.telegram.bot_token');
if (!$token) {
throw new RuntimeException('Telegram Bot Token is not configured.');
}
$this->baseUrl = "https://api.telegram.org/bot{$token}/";
}

public function sendMessage(array $params): Response
{
return Http::timeout(10)
->post($this->baseUrl . 'sendMessage', $params);
}
}

3. Securing the Webhook Controller

Telegram allows you to pass an X-Telegram-Bot-Api-Secret-Token header when setting up your webhook. Your application must verify this header to prevent unauthorized payloads from being processed.

Create a controller to handle the incoming POST request from Telegram:

namespace App\Http\Controllers;

use App\Jobs\ProcessTelegramUpdateJob;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class TelegramWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$expectedSecret = config('services.telegram.webhook_secret');
$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');

if (!$expectedSecret || $providedSecret !== $expectedSecret) {
return response('Unauthorized', 401);
}

$update = $request->all();
if (!isset($update['update_id'])) {
return response('Invalid payload', 400);
}

// Dispatch to queue to process asynchronously and respond 200 OK immediately
ProcessTelegramUpdateJob::dispatch($update);

return response('OK', 200);
}
}

Register the route in routes/api.php:

use App\Http\Controllers\TelegramWebhookController;
use Illuminate\Support\Facades\Route;

Route::post('/telegram/webhook', TelegramWebhookController::class);

*Note: Ensure this route is excluded from CSRF protection if you are registering it outside of the api route group.*

4. Processing Updates Asynchronously

Telegram expects your webhook endpoint to return a 200 OK response within a few seconds. If your application performs heavy database queries, external API calls, or complex processing, Telegram may timeout and retry sending the same update, causing duplicate executions.

To prevent this, process the update inside a queued job:

namespace App\Jobs;

use App\Services\TelegramClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessTelegramUpdateJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(public array $update) {}

public function handle(TelegramClient $telegram): void
{
$message = $this->update['message'] ?? null;
if (!$message || !isset($message['text'], $message['chat']['id'])) {
return;
}

$chatId = $message['chat']['id'];
$text = $message['text'];

if ($text === '/start') {
// Always escape dynamic user input using htmlspecialchars when using HTML parse_mode
$welcomeText = "Hello! Welcome to our bot.";

$telegram->sendMessage([
'chat_id' => $chatId,
'text' => $welcomeText,
'parse_mode' => 'HTML',
]);
}
}
}

5. Writing Feature Tests

Testing webhook integrations without hitting the live Telegram API is critical for CI/CD pipelines. We can use Laravel's Queue::fake() and Http::fake() to assert that our controller behaves correctly and our client sends the expected payloads.

namespace Tests\Feature;

use App\Jobs\ProcessTelegramUpdateJob;
use App\Services\TelegramClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class TelegramWebhookTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'services.telegram.bot_token' => 'test-token',
'services.telegram.webhook_secret' => 'test-secret',
]);
}

public function test_webhook_dispatches_job_on_valid_secret(): void
{
Queue::fake();

$response = $this->postJson('/api/telegram/webhook', [
'update_id' => 12345,
'message' => [
'chat' => ['id' => 999],
'text' => '/start',
],
], [
'X-Telegram-Bot-Api-Secret-Token' => 'test-secret',
]);

$response->assertStatus(200);
Queue::assertDispatched(ProcessTelegramUpdateJob::class, function ($job) {
return $job->update['update_id'] === 12345;
});
}

public function test_webhook_rejects_invalid_secret(): void
{
Queue::fake();

$response = $this->postJson('/api/telegram/webhook', [
'update_id' => 12345,
], [
'X-Telegram-Bot-Api-Secret-Token' => 'wrong-secret',
]);

$response->assertStatus(401);
Queue::assertNothingDispatched();
}

public function test_client_sends_correct_payload(): void
{
Http::fake([
'api.telegram.org/*' => Http::response(['ok' => true, 'result' => []], 200),
]);

$client = new TelegramClient();
$response = $client->sendMessage([
'chat_id' => 999,
'text' => 'Test message',
]);

$this->assertTrue($response->successful());
Http::assertSent(function ($request) {
return $request->url() === 'https://api.telegram.org/bottest-token/sendMessage'
&& $request['chat_id'] === 999
&& $request['text'] === 'Test message';
});
}
}

Production Considerations

* Idempotency: Telegram may occasionally send the same update twice. To handle this, store processed update_id values in a fast storage layer like Redis or a database table with a unique index, and check against it before processing the update in your job. * Rate Limiting: Telegram limits outgoing messages to 30 messages per second. If your application scales, implement a rate-limiting queue worker or use a middleware to throttle outgoing API calls.

BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.