laravel-notification-channels/telegram
Send Telegram notifications from Laravel using the Telegram Bot API. Supports text messages, inline keyboards, dice, polls, media groups, photos, documents, locations, contacts, stickers and more, plus message routing, response handling and direct client access.
Three commands and one config block get you from zero to a working notification:
composer require laravel-notification-channels/telegram
# add TELEGRAM_BOT_TOKEN to .env
// config/services.php
'telegram' => [
'token' => env('TELEGRAM_BOT_TOKEN'),
],
Get the token from @BotFather. Then do not skip the chat ID step — this is where most first attempts stall. A bot cannot message anyone until that person (or group/channel) has interacted with it. Send /start to your bot from your own account, then:
$updates = TelegramUpdates::create()->limit(1)->get();
$chatId = $updates['result'][0]['message']['chat']['id'] ?? null;
Store that on the notifiable model (telegram_chat_id column is the obvious choice). Note this fails silently-ish if a webhook is registered — see Gotchas.
The first real use case is almost always a one-way "something happened in my app" alert:
class InvoicePaid extends Notification
{
public function via($notifiable) { return ['telegram']; }
public function toTelegram($notifiable)
{
return TelegramMessage::create()
->to($notifiable->telegram_chat_id)
->line('Your invoice has been *PAID*')
->line("Amount: {$notifiable->amount}")
->button('View Invoice', url("/invoice/{$notifiable->invoice_id}"));
}
}
Where to look first: the README's Available Methods section is the real API surface — skim it once before you start inventing workarounds for things the package already does (polls, media groups, rich messages, keyboards, reply parameters). The ->view() helper for Blade-rendered bodies is also easy to miss and often the cleanest way to handle anything longer than a few lines.
Notifications are the default; the client is the escape hatch. For anything triggered by a domain event, write a Notification class and queue it (implements ShouldQueue). Reach for the Telegram client directly only when you're outside the notification flow — answering callback queries, editing a previously sent message, fetching updates. That boundary keeps message formatting testable in isolation from delivery.
One notification class per event, one Blade view per class. ->view('notifications.telegram.invoice-paid', [...]) keeps formatting out of PHP and lets non-backend people tweak copy. Pair it with ->lineIf() / ->sendWhen() so a notification that has nothing to say doesn't get sent at all.
Inline buttons + a webhook controller is the interactive pattern. ->button() gives you URL buttons (no server needed). ->buttonWithCallback('Confirm', 'invoice:confirm:123') gives you callback_data, which arrives at your webhook handler as an update.callback_query; you then act on it and answer the query via the client. Keep the payload under 64 bytes and treat it as an opaque token — parse it server-side, don't embed state you can't validate.
Reply keyboards for guided input. ->keyboard('Button 1')->keyboard('Button 2') renders a persistent reply keyboard; keyboard('Send number', requestContact: true) and requestLocation: true request structured input. Useful for onboarding flows ("share your phone number") far more than for menus, since the keyboard sticks around.
On-demand and fan-out sends. For recipients who aren't models, Notification::route('telegram', $chatId)->notify(...) avoids creating throwaway notifiables. For teams/groups, loop over chat IDs — but throttle, and prefer one media group message over N single sends for photo batches.
Rich messages for anything resembling a document. Order confirmations, digests, AI-generated answers. You can pass Markdown with tg://photo?id= references plus ->media(...), or compose from blocks (heading, paragraph, listBlock, table, divider, footer, buttons with alignment). For streaming an LLM response, TelegramRichMessageDraft with a stable draftId() animates replacements in place — a much better UX than editing a message on a timer.
Test the payload, not the network. Notification::fake() + asserting toTelegram() returns the expected object covers 90% of regressions. The remaining 10% (chat ID missing, bot blocked) surfaces at runtime and belongs in your failure handling, not your unit tests.
getUpdates and webhooks are mutually exclusive. If getUpdates returns {"ok": true, "result": []} while you know messages are arriving, you have a webhook set. Call deleteWebhook before debugging chat IDs. This eats an hour of most people's first day.
Telegram returns HTTP 200 with ok: false. Do not treat a 200 as success. The package raises exceptions for API errors — catch them. Two hooks exist: the NotificationFailed event (app-wide, log to Sentry here) and the per-notification onError callback (context-specific cleanup). Use both: the event for observability, the callback for "mark this recipient as unreachable."
Prune dead chat IDs. Users block bots and leave groups constantly. A 403 / "chat not found" is permanent, not transient — null out the stored chat ID or you'll retry it forever. Distinguish that from 429, which is transient and should back off.
Rate limits bite at scale. Roughly 30 messages/second globally and tighter per-chat limits for groups. Queued notifications plus a rate-limited queue (Redis::throttle) is the fix; a naive foreach ($users as $u) notify() will get you flood-waited. Batch into groups where the product allows it.
MarkdownV2 escaping is genuinely painful. Underscores, dots, parens, and hyphens in user-supplied strings will break sends or render wrong. Prefer escapedLine() for interpolated values and keep literal formatting in code. If a message renders as plain text with stray backslashes, that's the parse mode, not the API.
4096 characters is a hard cap for text messages, and caption limits are lower still. Long digests need splitting into multiple line()/message calls — or a rich message, which is designed for long-form content.
Uploads vs. references. Passing a public URL or an existing file_id is fast and cheap; uploading local bytes is slower and counts against different limits. Cache a file_id after the first send of a recurring image (logo, receipt template) and reference it thereafter.
Reply keyboards are sticky. Once shown, they stay until removed. If you send a keyboard() by accident in a transactional alert, users see it forever. Inline buttons don't have this problem — prefer them unless you're deliberately building an input flow.
Config quirks worth knowing. services.telegram.base_uri points at a self-hosted Bot API server or bridge (essential in blocked regions); services.telegram.http.proxy / HTTPS_PROXY handles the same problem at the transport layer. The legacy services.telegram-bot-api.* keys still work but new code should use services.telegram.*. Set timeout/connect_timeout explicitly if your queue workers are latency-sensitive.
Forum topics and business messages need messageThreadId() / businessConnectionId() respectively — a message silently landing in the wrong topic or getting rejected is usually a missing one of these.
Debugging loop that works: private test group with the bot added → send via php artisan tinker calling toTelegram() and dumping the payload → only then wire it into the notification. Verifying the payload shape before it hits the network separates "my formatting is wrong" from "my chat ID is wrong."
How can I help you explore Laravel packages today?