DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Integrate Telegram Webhooks in Laravel: Thin Client, Queues, and Fake Update Testing

When integrating Telegram bots into Laravel applications, developers often reach for heavy, opinionated SDKs. While frameworks like Nutgram or SDKs like irazasyed/telegram-bot-sdk are excellent for complex conversational bots, they can introduce unnecessary abstraction, maintenance overhead, and upgrade friction during major Laravel releases.

For many applications, a thin HTTP client wrapper combined with Laravel's native routing, queuing, and testing utilities is cleaner, faster, and easier to maintain.

This tutorial demonstrates how to build a production-ready Telegram webhook integration in Laravel from scratch. We will build a lightweight HTTP client, secure the webhook endpoint using Telegram's secret token header, offload processing to a queued job to maintain sub-100ms response times, implement idempotency checks, and write a comprehensive integration test using Laravel's built-in testing tools. We do not claim to build a complete conversational state machine; instead, we focus on establishing a reliable, secure, and testable architectural foundation.

1. Configuration and the Thin HTTP Client

We begin by configuring our environment variables and creating a lightweight service class to interact with the Telegram Bot API. This service uses Laravel's native Illuminate\Support\Facades\Http client, which provides a clean API for sending requests, handling timeouts, and mocking responses during testing.

First, add your Telegram credentials to your config/services.php file:

// config/services.php
return [
    // ... other services
    'telegram' => [
        'bot_token' => env('TELEGRAM_BOT_TOKEN'),
        'secret_token' => env('TELEGRAM_SECRET_TOKEN'),
    ],
];
Enter fullscreen mode Exit fullscreen mode

Next, create the service class. This class handles outbound requests to the Telegram Bot API, checks the HTTP status code, verifies the ok field in the JSON response, and logs failures.

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
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}";
    }

    /**
     * Send a message to a specific chat.
     */
    public function sendMessage(array $payload): array
    {
        return $this->call('sendMessage', $payload);
    }

    /**
     * Acknowledge a callback query from an inline keyboard.
     */
    public function answerCallbackQuery(array $payload): array
    {
        return $this->call('answerCallbackQuery', $payload);
    }

    /**
     * Execute the HTTP request to the Telegram Bot API.
     */
    protected function call(string $method, array $payload): array
    {
        $response = Http::timeout(10)
            ->connectTimeout(5)
            ->post("{$this->baseUrl}/{$method}", $payload);

        if (!$response->successful()) {
            Log::error("Telegram API HTTP Error on {$method}", [
                'status' => $response->status(),
                'body' => $response->body(),
            ]);
            throw new RuntimeException("Telegram API returned status code {$response->status()}");
        }

        $data = $response->json();
        if (json_last_error() !== JSON_ERROR_NONE) {
            Log::error("Telegram API returned invalid JSON on {$method}", [
                'body' => $response->body(),
            ]);
            throw new RuntimeException('Telegram API returned invalid JSON.');
        }

        if (!($data['ok'] ?? false)) {
            Log::error("Telegram API returned ok:false on {$method}", [
                'response' => $data,
            ]);
            throw new RuntimeException("Telegram API error: " . ($data['description'] ?? 'Unknown error'));
        }

        return $data;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Securing and Routing the Webhook

Telegram allows you to specify an X-Telegram-Bot-Api-Secret-Token header when setting up your webhook. Telegram will pass this token with every incoming request. Your application must verify this token to ensure the request originates from Telegram and not an unauthorized third party.

First, define the route in routes/api.php:

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

Route::post('/telegram/webhook', TelegramWebhookController::class)
    ->name('telegram.webhook');
Enter fullscreen mode Exit fullscreen mode

Next, create the controller. The controller's sole responsibility is to validate the incoming request as quickly as possible, dispatch a queued job to handle the heavy lifting, and return an HTTP 200 OK response. Telegram retries delivery if your server does not respond promptly, so you must not perform database queries, API calls, or complex business logic directly inside the controller.

namespace App\Http\Controllers;

use App\Jobs\ProcessTelegramUpdate;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;

class TelegramWebhookController
{
    public function __invoke(Request $request): Response
    {
        $configuredSecret = config('services.telegram.secret_token');
        $incomingSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');

        if (!$configuredSecret || !hash_equals($configuredSecret, (string) $incomingSecret)) {
            Log::warning('Unauthorized Telegram webhook attempt detected.', [
                'ip' => $request->ip(),
            ]);
            return response('Unauthorized', 403);
        }

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

        // Dispatch the job to the queue
        ProcessTelegramUpdate::dispatch($payload);

        return response('OK', 200);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Processing Updates with Queued Jobs and Idempotency

Because network issues can cause Telegram to retry sending an update that your application has already processed, you must implement idempotency. We can achieve this by storing processed update_id values in Redis or a database cache.

Create the queued job using Artisan:

php artisan make:job ProcessTelegramUpdate
Enter fullscreen mode Exit fullscreen mode

Open the generated job and implement the processing logic. In this example, we check for duplicate updates, escape dynamic user input using htmlspecialchars to prevent HTML parsing errors, and handle both standard text messages and callback queries.

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;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;

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

    public int $tries = 3;
    public int $backoff = 5;

    public function __construct(protected array $payload)
    {
    }

    public function handle(TelegramClient $telegram): void
    {
        $updateId = $this->payload['update_id'];
        $cacheKey = "telegram_update:{$updateId}";

        // Prevent processing the same update multiple times (1-hour lock)
        if (!Cache::add($cacheKey, true, 3600)) {
            Log::info("Duplicate Telegram update ignored: {$updateId}");
            return;
        }

        try {
            if (isset($this->payload['message'])) {
                $this->handleMessage($this->payload['message'], $telegram);
            } elseif (isset($this->payload['callback_query'])) {
                $this->handleCallbackQuery($this->payload['callback_query'], $telegram);
            }
        } catch (Throwable $e) {
            // Release the lock on failure so the retried job can run
            Cache::forget($cacheKey);
            throw $e;
        }
    }

    protected function handleMessage(array $message, TelegramClient $telegram): void
    {
        $chatId = $message['chat']['id'] ?? null;
        $text = $message['text'] ?? '';

        if (!$chatId) {
            return;
        }

        // Example: Handle deep-linking payload from t.me/Bot?start=payload
        if (str_starts_with($text, '/start')) {
            $parts = explode(' ', $text, 2);
            $startPayload = $parts[1] ?? null;

            if ($startPayload) {
                // Map start payload to application state here
                Log::info("User started bot with payload: {$startPayload}");
            }
        }

        $safeName = htmlspecialchars($message['from']['first_name'] ?? 'User', ENT_QUOTES, 'UTF-8');

        $telegram->sendMessage([
            'chat_id' => $chatId,
            'text' => "Hello, <b>{$safeName}</b>! Welcome to our service.",
            'parse_mode' => 'HTML',
        ]);
    }

    protected function handleCallbackQuery(array $callbackQuery, TelegramClient $telegram): void
    {
        $callbackQueryId = $callbackQuery['id'];
        $data = $callbackQuery['data'] ?? '';
        $chatId = $callbackQuery['message']['chat']['id'] ?? null;

        // Always answer callback queries to remove the loading state on the user's screen
        $telegram->answerCallbackQuery([
            'callback_query_id' => $callbackQueryId,
        ]);

        if ($chatId && $data === 'ping') {
            $telegram->sendMessage([
                'chat_id' => $chatId,
                'text' => 'Pong!',
            ]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Writing Integration Tests with Fake Updates

To ensure your integration remains functional during application updates, write an integration test. We will mock the queue and the HTTP client to verify that the controller validates the secret token, rejects unauthorized requests, and dispatches the processing job with the correct payload.

Create a feature test:

php artisan make:test TelegramWebhookTest
Enter fullscreen mode Exit fullscreen mode

Implement the test cases:

namespace Tests\Feature;

use App\Jobs\ProcessTelegramUpdate;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

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

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

        $payload = [
            'update_id' => 100001,
            'message' => [
                'chat' => ['id' => 12345],
                'text' => 'Hello',
            ],
        ];

        // Missing token
        $response = $this->postJson(route('telegram.webhook'), $payload);
        $response->assertStatus(403);

        // Invalid token
        $response = $this->postJson(route('telegram.webhook'), $payload, [
            'X-Telegram-Bot-Api-Secret-Token' => 'wrong-token',
        ]);
        $response->assertStatus(403);

        Queue::assertNothingDispatched();
    }

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

        $payload = [
            'update_id' => 100002,
            'message' => [
                'chat' => ['id' => 12345],
                'text' => 'Hello',
            ],
        ];

        $response = $this->postJson(route('telegram.webhook'), $payload, [
            'X-Telegram-Bot-Api-Secret-Token' => 'super-secret-token',
        ]);

        $response->assertStatus(200);
        $response->assertSeeText('OK');

        Queue::assertDispatched(ProcessTelegramUpdate::class, function ($job) use ($payload) {
            return $job->payload['update_id'] === $payload['update_id'];
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Production Considerations

When deploying this setup to production, keep the following architectural constraints in mind:

  • Rate Limits (HTTP 429): Telegram limits outbound messages to 30 messages per second across all chats, and 1 message per second to a specific chat. If you hit these limits, Telegram returns an HTTP 429 Too Many Requests status code with a retry_after field. Ensure your queue worker configuration handles retries gracefully, or implement a rate-limited queue driver (such as Redis rate limiting) to throttle outbound requests.
  • Callback Data Limits: The callback_data field in inline keyboards has a strict limit of 64 bytes. If you need to pass complex state, generate a unique, short key (e.g., using bin2hex(random_bytes(7))), store the state in your database or cache, and pass only the key in the callback payload.
  • Webhook Registration: You must register your webhook URL with Telegram once. You can do this by sending a POST request to https://api.telegram.org/bot<YOUR_TOKEN>/setWebhook with the url and secret_token parameters. Keep this script in a deployment step or an Artisan command.

For more details on the underlying API endpoints and payload structures, refer to the official documentation at https://botservice.biz/telegram-bot-api.

If you need assistance building, scaling, or securing your Telegram integrations, check out BotCreator — studio that ships Telegram bots / Mini Apps.

Top comments (0)