botman/driver-telegram
Telegram driver for BotMan, the PHP chatbot framework. Connect your Laravel or plain PHP app to the Telegram Bot API and build conversational bots with commands, conversations, inline keyboards, and rich media through BotMan's expressive, framework-agnostic API.
1. Install the driver
composer require botman/driver-telegram
This pulls in botman/botman (^2.0) if it isn't already there.
2. Wire it up
config/botman/telegram.php with a token key. Point it at TELEGRAM_TOKEN in .env and run php artisan config:clear.use BotMan\BotMan\BotManFactory;
use BotMan\BotMan\Drivers\DriverManager;
use BotMan\Drivers\Telegram\TelegramDriver;
DriverManager::loadDriver(TelegramDriver::class);
$botman = BotManFactory::create($config);
3. Register the webhook
php artisan botman:telegram:register
or manually hit https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://your-app.test/botman. Verify with getWebhookInfo. For local dev, expose your app with ngrok/expose — Telegram requires a valid HTTPS endpoint and will silently fail on self-signed certs.
4. First use case: /start
$bot->hears('/start', function (BotMan $bot) {
$bot->reply('Welcome! Try /help.');
});
$bot->hears('/help', function (BotMan $bot) {
$bot->reply('Commands: /start, /help');
});
Where to look first
config/botman/telegram.php — token and driver config.BotMan\Drivers\Telegram\TelegramDriver — the class that maps Telegram updates into BotMan messages; read loadMessages() and sendPayload() to understand what's supported.BotMan\Drivers\Telegram\Extensions\Keyboard, KeyboardButton, and Button — the only sane way to build inline/reply keyboards.BotMan\Drivers\Telegram\TelegramPhotoDriver (and siblings TelegramAudioDriver, TelegramVideoDriver, TelegramFileDriver, TelegramVoiceDriver, TelegramLocationDriver, TelegramContactDriver) — these are what make $bot->receivesImages(), receivesVideos(), etc. work only for Telegram.BotManServiceProvider — defaults to a POST /botman webhook route.First realistic milestone: a working webhook handler that answers /start, plus one inline-keyboard menu. That exercises the whole chain (route → driver → middleware → hears) before you add conversations.
Keyword listeners and pattern matching
BotMan's hears() takes a regex. Anchor it, or /^start/ will also match "restarting".
$bot->hears('^/order (\d+)$', function (BotMan $bot, $orderId) {
$bot->reply("Looking up order {$orderId}…");
});
Conversations for multi-step flows
Conversations are the right tool for anything stateful (checkout, onboarding, forms). Extend Conversation, implement run(), and use $this->ask():
class OnboardConversation extends Conversation
{
public function askName()
{
$this->ask('What should I call you?', function (Answer $answer) {
$this->name = $answer->getText();
$this->askEmail();
});
}
}
State lives in the BotMan cache. On more than one app server, switch the cache driver to Redis, or a conversation started on node A and continued on node B will reset.
Inline keyboards + callbacks
Build with the provided extension classes and pass the array as the second argument to reply():
use BotMan\Drivers\Telegram\Extensions\Keyboard;
use BotMan\Drivers\Telegram\Extensions\KeyboardButton;
$keyboard = Keyboard::create()
->type(Keyboard::TYPE_INLINE)
->addRow(KeyboardButton::create('Confirm')->callbackData('confirm'))
->addRow(KeyboardButton::create('Cancel')->callbackData('cancel'))
->toArray();
$bot->reply('Proceed?', $keyboard);
Callback presses arrive as updates whose text is the callback_data, so handle them with the same hears() matcher you'd use for typed commands:
$bot->hears('^confirm$', function (BotMan $bot) {
// ...
});
Callback queries also need answering via answerCallbackQuery, otherwise the user sees a spinning loader. If the driver version you're on doesn't auto-answer, call the Telegram API directly (see extension points below).
Reply keyboards (persistent buttons)
Use Keyboard::TYPE_KEYBOARD with ->resizeKeyboard(), ->oneTimeKeyboard(). Remember they replace the user's keyboard — always provide a way back (->addRow(KeyboardButton::create('Menu'))).
Sending media
Compose an OutgoingMessage with an attachment; the driver routes to the correct Telegram endpoint:
$message = OutgoingMessage::create('Here is your receipt')
->withAttachment(Image::url($signedUrl));
$bot->reply($message);
Telegram fetches media by URL, so the URL must be publicly reachable — signed S3 URLs work, localhost storage paths don't. Captions are capped at 1024 chars; long text needs a separate message.
Formatting
Pass parse_mode as an additional parameter:
$bot->reply("<b>{$name}</b>, your order shipped.", ['parse_mode' => 'HTML']);
HTML is usually safer than Markdown (Markdown v1 chokes on stray underscores in usernames).
Middleware for cross-cutting concerns
Register middleware in config/botman/config.php (or BotManFactory config) for rate limiting, allow-listing chat IDs, logging the raw update, and injecting tenant context:
class OnlyAllowListedChats
{
public function handle(IncomingMessage $message, $next, BotMan $bot)
{
if (! in_array($message->getSender(), config('bot.allowed_ids'))) {
return null; // stop processing
}
return $next($message);
}
}
The driver's group middleware hook is the cleanest place to enforce “this bot only responds in the private DM channel of the right tenant”, rather than repeating checks per listener.
Webhook vs. polling
Webhooks are the default for Laravel: fast, no daemon, but each request blocks a PHP-FPM worker. Dispatch slow work (CRM updates, PDF generation) to a queue and reply immediately — Telegram will retry the webhook if you exceed its timeout, and you'll get duplicate handling without idempotency keys.
Polling is a custom Artisan command running a listen() loop with sleep(1) and a distributed lock so two workers don't call getUpdates concurrently (Telegram will 409-conflict). Useful for dev machines behind firewalls; rarely worth it in production versus a tunnel.
Persisting Telegram-specific data
BotMan normalises everything into IncomingMessage. For things it doesn't expose (message_id, chat type, callback query ID, reply-to-message), grab the raw payload:
$raw = $bot->getMessage()->getPayload();
$chatId = $raw['chat']['id'];
Store chat_id rather than user ID — group chats and supergroups deliver a different chat.id than the sender's id, and broadcasting to users should go through the chat, not the user.
try/catch with Log::error() at the top of your handler or in middleware; don't trust “no errors in Telegram” as success.update_id/message_id — a cache add() with a TTL is enough.DriverManager::loadDriver() is the single most common error in non-Laravel setups: “Driver [Telegram] not supported”. In Laravel it's the service provider's job, but if you registered providers selectively, check it.config:cache and tokens. After editing TELEGRAM_TOKEN, run php artisan config:cache (or config:clear) — a cached config will keep hitting the old token with no obvious error.parse_mode + user input = broken messages. A user typing <3 in HTML mode produces a Telegram 400 (“can't parse entities”). Run user-supplied strings through htmlspecialchars() (or e()), or send without a parse mode and accept plain text.usleep() or BotMan's typesAndWaits(). Blasting 500 users in a foreach will get you a 429 with a retry_after you're probably ignoring.str_repeat in a test blow up in production.getUpdates means either two pollers are running or a webhook is still registered. deleteWebhook before switching to polling; setWebhook before switching back.secret_token when registering and check the X-Telegram-Bot-Api-Secret-Token header in a middleware. Without it, anyone who learns your URL can feed you fake updates.answerCallbackQuery via the driver's HTTP helper (below)./foo) and replies unless privacy mode is disabled in BotFather — that's why your “bot ignores normal messages in groups” bug isn't a bug.message_thread_id, spoilers, custom emoji, link_preview_options) aren't mapped. You can still reach them: the base HttpDriver exposes sendRequest($endpoint, $parameters), so $bot->getDriver()->sendRequest('sendMessage', [...]) handles raw calls. Wrap those in a small service class rather than scattering them across listeners.TelegramDriver, override loadMessages()/sendPayload(), and bind your subclass into the container so BotMan resolves it instead of the stock one. Keeps vendor upgrades (if they ever happen) merges clean.$bot->receivesImages() only fires because TelegramPhotoDriver is registered. If photo handling mysteriously stops, check you haven't replaced the driver list in config.botman:tinker session open. The driver's payload normalisation is the part most worth verifying by hand — payload shapes differ for edited messages, channel posts, and callbacks in ways unit tests miss.How can I help you explore Laravel packages today?