milly/laragram
Laragram is a Laravel package for building Telegram bots with a clean, expressive API. It wraps Telegram Bot API calls and follows Laravel conventions for configuration, routing, webhooks, commands, and responses, making bot development simpler and more maintainable.
composer require milly/laragram, then publish the config (php artisan vendor:publish --tag=laragram-config — check php artisan vendor:publish output, tag names vary). This gives you config/laragram.php, where the bot token and default webhook route are configured.TELEGRAM_BOT_TOKEN=... (or whatever key the config file references) to .env. If the config reads env('LARAGRAM_TOKEN'), use that name instead — grep the config file rather than guessing.vendor/milly/laragram/src/...ServiceProvider.php) to see what is bound into the container and which facade alias is registered. That facade is your day-to-day API surface for sending messages.curl "https://api.telegram.org/bot$TOKEN/setWebhook?url=https://your.app/laragram/webhook" — or use the package's artisan command if one exists (php artisan list | grep -i laragram).public function __invoke(Request $request)
{
Log::info('tg update', $request->all());
$chatId = $request->input('message.chat.id');
$text = $request->input('message.text');
Laragram::sendMessage($chatId, "You said: {$text}");
return response()->noContent();
}
Once the echo round-trips, you have verified token, webhook URL, route, and outbound call in one pass.
Thin controller, fat handler. Keep the webhook controller to route resolution and dispatch. Move command logic into a HandleTelegramUpdate job or a dedicated handler class so you can queue it and test it without HTTP. The webhook endpoint must return 200 fast or Telegram retries the same update.
Command routing via a match on the first token.
[$command, $args] = array_pad(explode(' ', $text, 2), 2, null);
match ($command) {
'/start' => $this->start($chatId),
'/help' => $this->help($chatId),
default => $this->fallback($chatId, $text),
};
Strip the @BotName suffix Telegram appends in groups (/start@MyBot) before matching.
Queue outbound calls. Wrap every send in a job with retry(3) and a small backoff. Telegram latency spikes will otherwise stall your webhook response, cause retries, and produce duplicate replies. A simple SendTelegramMessage job that calls the facade is enough.
Idempotency. Persist update_id in a unique-indexed table (or cache with TTL) before processing. Telegram redelivers on non-2xx or timeout, and users double-tap. This is the single highest-value addition on top of a thin package.
Long polling for local dev. Webhooks need a public URL. Either run ngrok http 8000 and re-setWebhook each session, or add a local artisan command that loops getUpdates with an offset — useful when the package exposes the raw client.
Conversation state. For multi-step flows (wizards, forms), key state by chat_id in cache or a bot_states table. Don't rely on the package for this; almost no small bot package provides it, and rolling your own keeps it inspectable.
Integration tip. If the facade hides the endpoint you need (polls, inline keyboards, media groups, payments), don't fight it — use Laravel's Http facade directly against https://api.telegram.org/bot{token}/{method}. Mixing the two is fine; just centralize the base URL and token in one helper.
php artisan config:cache freezes the token and webhook path. After changing either in .env, re-run config:clear. A stale token in a cached config looks exactly like a revoked token (401 from Telegram).X-Telegram-Bot-Api-Secret-Token. Pass secret_token to setWebhook and verify the header in middleware. Without it, your public webhook is an open endpoint anyone can POST fake updates to. Most thin packages do not do this for you.parse_mode=Markdown breaks on unescaped _ * [ ] ( ). MarkdownV2 is stricter still. If messages silently fail, drop parse_mode first — that isolates formatting from delivery.photo/document as JSON with a URL works; sending a local file requires multipart form data. The facade may not abstract this — check before assuming.setWebhook with drop_pending_updates=true, or the bot replays a backlog of stale messages on first connect.getUpdates with long polling produce 409 Conflict: terminated by other getUpdates request. Usually means a leftover polling worker or a second webhook consumer.429 body contains retry_after — respect it in your retry loop rather than hammering.getWebhookInfo tells you last_error_message, pending_update_count, and whether the URL is set at all. Check it before touching code.composer.json, read the source in vendor/ when behavior surprises you, and keep the raw Http fallback in reach.How can I help you explore Laravel packages today?