When running a high-volume Telegram bot in Laravel—such as an automated customer booking bot or localized order notification system—blindly accepting incoming HTTP requests on a public controller route exposes your application to spoofed hits, database duplication, and cascading worker timeouts.

Telegram's webhook architecture operates under strict execution rules. When Telegram sends an Update object to your server, it expects an HTTP 200 OK response almost instantly. If your server takes longer than a few seconds due to database transactions, third-party API calls, or email dispatching, Telegram considers the delivery attempt a failure. It will abort the connection and retry the exact same update repeatedly.

If your controller processes logic synchronously without checking whether an update_id was already handled, those retries will create duplicate orders, send multiple confirmation emails, or corrupt system state. Furthermore, if your endpoint URL is public and unauthenticated, malicious actors can send arbitrary HTTP POST requests to your route, injecting fake user input directly into your workflow.

To build an enterprise-grade Telegram webhook pipeline in Laravel, you must implement three distinct defensive layers:

  1. Strict request authorization at the routing layer using Telegram's secret token validation header.
  2. Non-blocking request ingestion that returns an immediate HTTP 200 OK after offloading the update payload to a background queue.
  3. Atomic update deduplication in the queued job via Redis before any business logic executes.

Section 1: Blocking Spoofed Hits with Custom Middleware

When setting up a webhook with Telegram's setWebhook API method, you can pass a secret_token parameter (1 to 256 characters using A-Z, a-z, 0-9, _, and -). When provided, Telegram appends this secret to every webhook request in the HTTP header X-Telegram-Bot-Api-Secret-Token.

If you do not validate this header on the server side, anyone who discovers your public API endpoint can emulate Telegram servers and send forged payload objects. Relying solely on obscure routing paths like /api/telegram/webhook-83921739 is security through obscurity and leaves your application vulnerable to automated network scanners.

To enforce token validation, create a dedicated HTTP middleware using Artisan:

php artisan make:middleware VerifyTelegramSecret 

Inside this middleware, extract the header and perform a constant-time string comparison against your configured application secret. Using hash_equals() is vital to prevent timing attacks.

<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; class VerifyTelegramSecret { /** * Handle an incoming request. */ public function handle(Request $request, Closure $next): Response { $expectedSecret = config('services.telegram.webhook_secret'); $incomingSecret = $request->header('X-Telegram-Bot-Api-Secret-Token'); if (!$expectedSecret || !is_string($incomingSecret) || !hash_equals($expectedSecret, $incomingSecret)) { return response()->json([ 'error' => 'Unauthorized header payload' ], Response::HTTP_UNAUTHORIZED); } return $next($request); } } 

Register this middleware in your routes/api.php file to protect the Telegram incoming entry point:

<?php use App\Http\Controllers\TelegramWebhookController; use App\Http\Middleware\VerifyTelegramSecret; use Illuminate\Support\Facades\Route; Route::post('/telegram/webhook', TelegramWebhookController::class) ->middleware(VerifyTelegramSecret::class); 

Now, any request that fails to present the exact matching X-Telegram-Bot-Api-Secret-Token header is rejected immediately with a 401 Unauthorized status before booting deeper application structures.

Section 2: Non-Blocking Request Ingestion in the Controller

A common mistake when building Laravel Telegram integration is executing database queries, calling OpenAI API endpoints, or generating PDFs inside the Controller executing on the web worker process.

If the webhook execution time exceeds 2 to 5 seconds, Telegram drops the connection and places the update back into its queue for redelivery. This causes a retry storm where your server is flooded with duplicate requests for the same event while original processing jobs are still trying to complete.

To solve this, the Single Action Controller must do only two things:

  1. Extract and validate that the incoming JSON payload contains a valid update_id.
  2. Push a raw copy of the payload to Laravel's queue worker and return an empty 200 OK response immediately.
<?php namespace App\Http\Controllers; use App\Jobs\ProcessTelegramUpdateJob; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class TelegramWebhookController { public function __invoke(Request $request): JsonResponse { $payload = $request->all(); if (!isset($payload['update_id']) || !is_int($payload['update_id'])) { // Return 200 to prevent Telegram from infinitely retrying corrupt payloads return response()->json(['status' => 'ignored_invalid_payload']); } // Offload payload to queue for background processing ProcessTelegramUpdateJob::dispatch($payload); // Respond immediately to satisfy Telegram's tight HTTP timeout return response()->json(['status' => 'queued']); } } 

By returning response()->json(['status' => 'queued']) within a few milliseconds, Telegram marks the delivery attempt as successful, terminating its retry mechanism at the network edge.

Section 3: Atomic Deduplication with Redis in Queued Jobs

Even with an immediate HTTP 200 response, edge network instability can cause Telegram to re-send an update if an ACK packet is lost in transit. Furthermore, if your queue runner experiences network blips or worker restarts, Laravel may re-execute a job.

Each Telegram update payload includes a unique 64-bit integer called update_id. Numerical sequence is guaranteed to increase incrementally with every event. To ensure idempotency (guaranteeing that an update is executed exactly once), your background job must check and store the update_id inside an atomic cache lock before running business logic.

Using Redis key-value storage with an atomic SET key value EX seconds NX command ensures that concurrent or repeated attempts to process the same integer fail instantly.

Here is the implementation of the ProcessTelegramUpdateJob utilizing Laravel's Redis facade:

<?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\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; class ProcessTelegramUpdateJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. */ public int $tries = 3; public function __construct( public array $payload ) {} public function handle(): void { $updateId = $this->payload['update_id'] ?? null; if (!$updateId) { return; } // Atomic lock key for this specific Telegram Update $lockKey = "telegram:update:" . $updateId; // Set key with 24-hour expiration (86400 seconds) ONLY if it does not already exist (NX) $acquired = Redis::set($lockKey, 'processed', 'EX', 86400, 'NX'); if (!$acquired) { Log::info("Duplicate Telegram update ignored", ['update_id' => $updateId]); return; } // Process business logic safely $this->processUpdatePayload(); } protected function processUpdatePayload(): void { if (!isset($this->payload['message'])) { return; } $message = $this->payload['message']; $chatId = $message['chat']['id'] ?? null; $text = $message['text'] ?? ''; if (!$chatId) { return; } // Handle user input safely escaping HTML special characters $safeInput = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $replyText = "Received your request: <b>" . $safeInput . "</b>"; $token = config('services.telegram.bot_token'); $response = Http::timeout(5)->post("https://api.telegram.org/bot{$token}/sendMessage", [ 'chat_id' => $chatId, 'text' => $replyText, 'parse_mode' => 'HTML', ]); if (!$response->successful()) { Log::error("Failed to send Telegram response", [ 'status' => $response->status(), 'body' => $response->body(), ]); } } } 

If the job encounters a transient external error (such as a database connection drop or third-party service timeout), Laravel can retry the job based on your $tries configuration. However, because the Redis key was successfully set on the initial attempt, retries will exit early. If you want retries to execute business logic upon genuine failures, wrap the Redis set call to only occur after successful execution, or clear the Redis key within a catch block during execution errors.

Production Notes

  1. Webhook Registration Script: Never register webhooks manually in a browser tab. Create an Artisan console command to synchronize your domain route and secret header with Telegram's servers cleanly during deployment.
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; class RegisterTelegramWebhook extends Command { protected $signature = 'telegram:register-webhook'; protected $description = 'Register the HTTP webhook endpoint with Telegram Bot API'; public function handle(): int { $token = config('services.telegram.bot_token'); $url = config('services.telegram.webhook_url'); $secret = config('services.telegram.webhook_secret'); if (!$token || !$url || !$secret) { $this->error('Missing Telegram configuration variables in environment.'); return Command::FAILURE; } $response = Http::post("https://api.telegram.org/bot{$token}/setWebhook", [ 'url' => $url, 'secret_token' => $secret, 'max_connections' => 40, 'allowed_updates' => ['message', 'callback_query', 'my_chat_member'], ]); if ($response->json('ok') === true) { $this->info('Telegram webhook successfully registered.'); return Command::SUCCESS; } $this->error('Webhook registration failed: ' . $response->body()); return Command::FAILURE; } } 
  1. Redis Eviction Policy: Ensure your Redis instance used for deduplication handles key eviction correctly. If your Redis memory limit is reached under peak loads and the eviction policy is set to allkeys-lru, Redis might drop active telegram:update:* keys before their 24-hour expiration, allowing old update retries to process again. Set the eviction policy to volatile-lru or assign dedicated Redis database instances for system locks.

  2. Queue Concurrency & Race Conditions: If your queue runner operates multiple worker threads in parallel, two workers might receive identical payload jobs simultaneously if pushed multiple times rapidly. The Redis NX (Not Exists) parameter executes atomically inside the Redis single-threaded execution loop, guaranteeing that only one Laravel queue worker gets a true return code.

  3. Database-backed Deduplication Fallback: If your infrastructure relies on MySQL/PostgreSQL instead of Redis, create a dedicated processed_updates table with a BIGINT UNSIGNED UNIQUE column for update_id. Perform an insertOrIgnore() query before running business logic.

Building webhooks using this pipeline ensures isolated request validation, near-zero HTTP latency for Telegram's delivery checks, and complete resilience against duplicate jobs.

If you need assistance scaling custom infrastructure, microservices, or complex workflow integrations, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.