When building Telegram integrations inside Laravel applications—whether for automated notifications, customer support channels, or Mini App backends—developers often default to third-party SDK packages. While packages like irazasyed/telegram-bot-sdk or nutgram/nutgram provide extensive routing mechanisms, they frequently introduce unnecessary complexity. Third-party abstractions can obscure underlying API failures, add redundant dependencies, and make native Laravel features like Http::fake() and Queue::fake() awkward to use.

If your bot fails to respond within Telegram's strict 5-second HTTP request window, Telegram will mark the request as failed and retry it. This often results in duplicate message processing, database race conditions, and exhausted connection pools. If you process incoming messages inline within your public controller instead of offloading them immediately to background workers, a spike in concurrent bot interactions will stall your web app.

In this tutorial, we will build an in-house Telegram integration using Laravel's native Illuminate\Support\Facades\Http client and queue subsystem. You will implement a lightweight API wrapper, set up a secure webhook endpoint that validates incoming secret tokens, offload heavy processing to background jobs, and write clean feature tests using standard Laravel test helpers.

1. Building a Custom Telegram HTTP Service

Third-party packages often handle API responses unpredictably when Telegram returns an error payload. Telegram API endpoints always return HTTP status 200 for many business-logic errors, but set "ok": false within the JSON body. If your client only inspects the HTTP response status code, silent failures occur where requests are assumed successful despite bad parameters or blocked user bots.

First, define your bot configuration in config/telegram.php to pull credentials safely from environment variables:

// config/telegram.php return [ 'bot_token' => env('TELEGRAM_BOT_TOKEN'), 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'), 'base_url' => env('TELEGRAM_BASE_URL', 'https://api.telegram.org'), ]; 

Now build a dedicated service class App\Services\TelegramClient. This class wraps Laravel's Http facade, enforces JSON payload validation, and handles both network-level failures and Telegram's ok: false body responses.

namespace App\Services; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use RuntimeException; class TelegramClient { protected string $baseUrl; protected string $token; public function __construct() { $this->token = (string) config('telegram.bot_token'); $this->baseUrl = rtrim((string) config('telegram.base_url'), '/') . '/bot' . $this->token; if (empty($this->token)) { throw new RuntimeException('Telegram bot token is not configured.'); } } /** * Send a text message to a specific chat. */ public function sendMessage(int|string $chatId, string $text, ?array $replyMarkup = null): array { $payload = [ 'chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'HTML', ]; if ($replyMarkup !== null) { $payload['reply_markup'] = $replyMarkup; } return $this->request('sendMessage', $payload); } /** * Answer an inline callback query. */ public function answerCallbackQuery(string $callbackQueryId, ?string $text = null): array { $payload = ['callback_query_id' => $callbackQueryId]; if ($text !== null) { $payload['text'] = $text; } return $this->request('answerCallbackQuery', $payload); } /** * Execute an HTTP POST request to the Telegram Bot API. */ protected function request(string $method, array $payload): array { $response = Http::timeout(5) ->retry(2, 200, throw: false) ->post("{$this->baseUrl}/{$method}", $payload); if ($response->failed()) { Log::error("Telegram API HTTP error during {$method}", [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new RuntimeException("Telegram HTTP request failed with status {$response->status()}"); } $data = $response->json(); if (!is_array($data) || !($data['ok'] ?? false)) { $description = $data['description'] ?? 'Unknown error'; $errorCode = $data['error_code'] ?? 0; Log::error("Telegram API returned error for {$method}", [ 'error_code' => $errorCode, 'description' => $description, 'payload' => $payload, ]); throw new RuntimeException("Telegram API error [{$errorCode}]: {$description}"); } return $data['result'] ?? []; } } 

By checking $data['ok'] ?? false directly, this service prevents silent errors. If a user blocks your bot, Telegram returns an error code 403 Forbidden inside the payload; our service catches this, logs the error contextual to the requested method, and throws an explicit exception.

2. Ingesting Webhooks via Asynchronous Queue Jobs

A critical design error in webhooks is inline execution. If an incoming message handler performs database writes, external network requests, or complex logic inside the controller thread, your application will eventually exceed Telegram's HTTP timeout limit. Telegram will then cancel the connection and resend the update, leading to duplicate database records.

To decouple ingestion from processing, we accept the request in the controller, verify the X-Telegram-Bot-Api-Secret-Token header, dispatch an asynchronous job, and instantly return an HTTP 200 OK response.

First, register the endpoint route in routes/api.php:

use App\Http\Controllers\TelegramWebhookController; use Illuminate\Support\Facades\Route; Route::post('/telegram/webhook', TelegramWebhookController::class); 

Next, implement the controller and the corresponding job:

// app/Http/Controllers/TelegramWebhookController.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 { $expectedSecret = (string) config('telegram.webhook_secret'); $providedSecret = (string) $request->header('X-Telegram-Bot-Api-Secret-Token'); if (!empty($expectedSecret) && !hash_equals($expectedSecret, $providedSecret)) { Log::warning('Unauthorized Telegram webhook attempt detected.', [ 'ip' => $request->ip(), ]); return response()->json(['error' => 'Unauthorized'], 401); } $payload = $request->all(); if (isset($payload['update_id'])) { ProcessTelegramUpdateJob::dispatch($payload); } return response()->json(['status' => 'ok']); } } 

Now build the queue job that executes the actual business domain logic safely in your background workers:

// app/Jobs/ProcessTelegramUpdateJob.php 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\Log; use Throwable; class ProcessTelegramUpdateJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; public int $backoff = 5; public function __construct(public array $update) { } public function handle(TelegramClient $telegram): void { $updateId = $this->update['update_id'] ?? null; if (isset($this->update['message'])) { $this->handleMessage($telegram, $this->update['message']); } elseif (isset($this->update['callback_query'])) { $this->handleCallbackQuery($telegram, $this->update['callback_query']); } } protected function handleMessage(TelegramClient $telegram, array $message): void { $chatId = $message['chat']['id'] ?? null; $text = trim($message['text'] ?? ''); if (!$chatId || empty($text)) { return; } if ($text === '/start') { $telegram->sendMessage( chatId: $chatId, text: "Welcome! Your account is ready. Send <b>/help</b> for commands." ); } } protected function handleCallbackQuery(TelegramClient $telegram, array $callbackQuery): void { $callbackId = $callbackQuery['id'] ?? null; $chatId = $callbackQuery['message']['chat']['id'] ?? null; $data = $callbackQuery['data'] ?? ''; if ($callbackId) { $telegram->answerCallbackQuery($callbackId, 'Processing your choice...'); } if ($chatId && $data === 'action_confirm') { $telegram->sendMessage($chatId, 'Action confirmed successfully!'); } } public function failed(Throwable $exception): void { Log::error('ProcessTelegramUpdateJob failed permanently', [ 'update_id' => $this->update['update_id'] ?? null, 'error' => $exception->getMessage(), ]); } } 

Notice how hash_equals() is used during header verification to guard against timing attacks. If an attacker attempts to flood your webhook endpoint with malicious payloads without the matching secret token, the controller returns a 401 HTTP response immediately without queuing unnecessary work.

3. Mocking Telegram APIs and Webhook Ingestion in Laravel Tests

One major downside of heavy bot SDKs is that they break or complicate Laravel's built-in testing tools. Because our custom implementation relies entirely on Laravel's native HTTP Facade and Job Queue system, writing reliable, high-speed unit and feature tests requires zero external mocking libraries.

You can mock outgoing calls to Telegram's API using Http::fake(), and verify queue dispatches using Queue::fake().

Here is a complete Feature test asserting that valid incoming webhook HTTP calls queue jobs, reject invalid secrets, and execute outbound API requests accurately:

// tests/Feature/TelegramWebhookTest.php namespace Tests\Feature; use App\Jobs\ProcessTelegramUpdateJob; use App\Services\TelegramClient; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Tests\TestCase; class TelegramWebhookTest extends TestCase { use RefreshDatabase; protected function setUp(): void { parent::setUp(); Config::set('telegram.bot_token', '123456:TEST_TOKEN'); Config::set('telegram.webhook_secret', 'super-secret-token'); } public function test_webhook_rejects_unauthorized_requests(): void { Queue::fake(); $response = $this->postJson('/api/telegram/webhook', [ 'update_id' => 100001, 'message' => ['text' => '/start'], ], [ 'X-Telegram-Bot-Api-Secret-Token' => 'wrong-secret', ]); $response->assertStatus(401); Queue::assertNothingDispatched(); } public function test_webhook_dispatches_job_on_valid_payload(): void { Queue::fake(); $payload = [ 'update_id' => 100002, 'message' => [ 'message_id' => 1, 'chat' => ['id' => 999888], 'text' => '/start', ], ]; $response = $this->postJson('/api/telegram/webhook', $payload, [ 'X-Telegram-Bot-Api-Secret-Token' => 'super-secret-token', ]); $response->assertStatus(200) ->assertJson(['status' => 'ok']); Queue::assertPushed(ProcessTelegramUpdateJob::class, function ($job) use ($payload) { return $job->update['update_id'] === $payload['update_id']; }); } public function test_telegram_client_sends_message_successfully(): void { Http::fake([ 'https://api.telegram.org/bot123456:TEST_TOKEN/sendMessage' => Http::response([ 'ok' => true, 'result' => [ 'message_id' => 42, 'chat' => ['id' => 999888], 'text' => 'Hello World', ], ], 200), ]); $client = app(TelegramClient::class); $result = $client->sendMessage(999888, 'Hello World'); $this->assertEquals(42, $result['message_id']); Http::assertSent(function ($request) { return $request->url() === 'https://api.telegram.org/bot123456:TEST_TOKEN/sendMessage' && $request['chat_id'] === 999888 && $request['text'] === 'Hello World'; }); } } 

Running php artisan test now tests your Telegram integration locally in milliseconds without triggering actual network calls or relying on third-party SDK mocks.

Production Notes: Rate Limits, Idempotency, and Security

Deploying Telegram bots to production introduces specific failure modes that require defensive coding standards:

  1. Telegram API Rate Limits (HTTP 429): Telegram limits global bot output to roughly 30 messages per second across all chats, and 1 message per second to an individual user chat. If your background worker receives a 429 response status, read the retry_after field returned in the response payload. Use Laravel's $this->release($seconds) method inside your queue job to put the message back on the queue delayed by the recommended time frame.

  2. Idempotency with update_id: Network issues or queue retries can occasionally cause the same update to be processed twice. Store processed update_id values in Redis or a dedicated database table with a unique index. Before executing the main logic inside ProcessTelegramUpdateJob, check whether the update_id has already been recorded. If it exists, abort execution early.

  3. HTML Sanitization: Because our service enables 'parse_mode' => 'HTML', any raw user input included in your bot responses must be sanitized using PHP's htmlspecialchars() function. Otherwise, unescaped characters such as < or > submitted by a user will cause Telegram to return an unprocessable entity error.

  4. Handling Callback Queries: Telegram requires bots to acknowledge every callback query triggered by inline buttons via answerCallbackQuery. If you fail to call this endpoint, Telegram apps will keep displaying a loading spinner on the user's interface until it times out.

By keeping your HTTP client minimal and delegating update processing to queue jobs, you eliminate heavy SDK overhead while maintaining complete test coverage over your bot workflows.

For teams building custom integration architectures or production WebApps, BotCreator provides end-to-end engineering services to ship secure Telegram bots and Mini Apps. Learn more about raw payload mechanics in the official Telegram Bot API documentation.