wekser / laragram
Laravel package for easy develop Telegram Bot.
Requires
- php: ^8.0
- ext-curl: *
- illuminate/support: ^8.0|^9.0|^10.17|^11.0|^12.0
Requires (Dev)
- laravel/framework: ^10.0
- mockery/mockery: ^1.4.4
- phpunit/phpunit: ^10.1
README
A Laravel package for building Telegram bots in MVC style — routing, controllers, views, and a station-based state machine, all wired into the Laravel ecosystem.
Requirements: PHP ^8.2 · Laravel ^11|^12
Installation
composer require wekser/laragram
Publish the config and run migrations:
php artisan laragram:install php artisan migrate
Add your bot credentials to .env:
LARAGRAM_BOT_TOKEN=your-telegram-bot-token LARAGRAM_WEBHOOK_PREFIX=laragram LARAGRAM_WEBHOOK_SECRET=generated-secret
Register the webhook:
php artisan laragram:webhook:set
How It Works
Telegram sends a POST request to /{prefix}/{secret}. Laragram authenticates the sender, resolves the user's current station (state), matches a route, calls your controller, and returns a JSON response back to Telegram.
Routes
Bot routes live in routes/laragram.php. Use the injected $collection variable or the BotRoute facade — both are equivalent.
use Wekser\Laragram\Facades\BotRoute; // Match /start from any station BotRoute::get('message') ->contains('/start') ->call([StartController::class, 'index']); // Match any text when user is at 'ask_name' station BotRoute::get('message') ->from('ask_name') ->call([OnboardingController::class, 'saveName']); // Match callback with a named param, admin only BotRoute::get('callback_query') ->from('home') ->contains('/action {id}') ->role('admin') ->call([AdminController::class, 'action']); // Catch-all fallback BotRoute::fallback()->call([StartController::class, 'fallback']);
DSL reference:
| Method | Description |
|---|---|
->get('event') |
Telegram update type (message, callback_query, inline_query, etc.) |
->from('station') |
Only match when user is at this station |
->contains('/cmd') |
Command, exact text, or {param} pattern |
->role('admin') |
Restrict to users with a specific role |
->name('route_name') |
Assign a name (shown in route:list) |
->call([Ctrl::class, 'method']) |
Controller action or closure |
->fallback() |
Catch-all — matches anything not matched above |
->group() applies shared station and role to multiple routes:
BotRoute::group(function ($c) { $c->get('message')->contains('/users')->call([AdminController::class, 'users']); $c->get('callback_query')->call([AdminController::class, 'callback']); }, from: 'admin_panel', roles: 'admin');
Controllers
Controllers are resolved through Laravel's IoC container — constructor injection works out of the box.
use Wekser\Laragram\BotRequest; use Wekser\Laragram\BotResponse; use Wekser\Laragram\Models\User; class StartController extends Controller { public function __construct(protected BotResponse $response) {} public function index(BotRequest $request, User $user): BotResponse { return $this->response ->text("Hello, {$user->first_name}!") ->redirect('home'); } }
BotRequest wraps the incoming update:
$request->get('text'); // dot-notation access to any update field $request->input('id'); // named {param} from the matched route pattern $request->message(); // the message sub-object $request->callbackQuery(); // the callback_query sub-object $request->validate([...]); // Laravel validation on the update payload
BotResponse builds the reply:
$response->text('Hello!') // sendMessage (HTML by default) $response->text('Hello!', 'MarkdownV2') // MarkdownV2 parse mode $response->text('Hello!', null) // no escaping $response->view('welcome', ['name' => 'Alice']) // render a view directory $response->photo($fileId, caption: 'A photo') // sendPhoto $response->document($fileId) // sendDocument $response->edit('Updated text') // editMessageText $response->answer('Done!', showAlert: true) // answerCallbackQuery $response->delete() // deleteMessage $response->action('typing') // sendChatAction $response->keyboard([...]) // attach reply_markup (call after content) $response->redirect('next_station') // move user to a new station
Text is auto-escaped for the active parse mode — do not manually escape, it will double-escape. Pass null as the format to send pre-formatted text.
Views
Views are directories under resources/laragram/ (dot notation → subdirectories). Each component of the message is a separate PHP file:
resources/laragram/
└── welcome/
├── text.php ← message text — use {{ expr }} for dynamic values
├── inline_keyboard.php ← call button() / href() / row()
└── reply_keyboard.php ← call reply() / row() / resize() / one_time()
text.php — write plain text plus your own HTML markup (default parse mode is HTML); {{ }} escapes a value, {!! !!} emits it raw:
Hello, <b>{{ $first_name }}</b>!
{!! __('laragram.welcome.body') !!}
Static markup (<b>…</b>) renders as-is. {{ }} values are auto-escaped (safe for user data); {!! !!} values are emitted raw (use for trusted, pre-formatted content like translation strings). Variables from $data are extracted into scope, so $name works directly. $user (the authenticated User model) is also available.
inline_keyboard.php — use global helper functions:
button('Click me', 'action_1'); href('Open site', 'https://example.com'); web_app('Open Mini App', 'https://example.com/app'); row(); button('Row 2', 'action_2');
The full InlineKeyboardButton API is available as helpers: button(), href(), web_app(), login_url(), switch_inline(), switch_inline_chosen(), switch_inline_chosen_chat(), copy_text(), pay(), callback_game(). Each one takes optional trailing style: (primary/success/danger) and icon: (custom emoji) attributes — e.g. button('Delete', 'rm', style: 'danger') (Bot API 9.4+).
reply_keyboard.php:
resize(); reply('Option A'); reply('Option B'); row(); reply('Help');
media.php — for sendMediaGroup:
photo($data['photo_id'], caption: 'First'); video($data['video_id']);
For single media, add a photo.php (or video.php, document.php, etc.) containing just the file_id or URL.
Render with:
$response->view('welcome', ['first_name' => $user->first_name]);
Scaffold a new view directory:
php artisan laragram:make:view welcome
Keyboards (programmatic)
For building keyboards in controllers without view files:
use Wekser\Laragram\Telegram\Keyboards\InlineKeyboard; use Wekser\Laragram\Telegram\Keyboards\ReplyKeyboard; use Wekser\Laragram\Telegram\Keyboards\ForceReply; $response->text('Choose:')->keyboard( InlineKeyboard::make() ->button('Yes', 'confirm') ->button('No', 'cancel') ->row() ->href('Open site', 'https://example.com') ->webApp('Open Mini App', 'https://example.com/app') ->toArray() ); $response->text('Choose:')->keyboard( ReplyKeyboard::make() ->button('Option A')->button('Option B') ->row()->button('Help') ->resize()->oneTime() ->toArray() ); ReplyKeyboard::remove(); // ['remove_keyboard' => true] ForceReply::make()->placeholder('Type here…')->toArray();
InlineKeyboard covers the full button API (switchInline(), switchInlineChosen(), switchInlineChosenChat(), loginUrl(), copyText(), pay(), callbackGame(), plus a paginate() helper). Every button method on both builders accepts optional trailing style: (primary/success/danger) and icon: (custom emoji) attributes — e.g. ->button('Delete', 'rm', style: 'danger') (Bot API 9.4+).
Station (State Machine)
Each user has a station — a string stored in laragram_sessions.station. Routes match only when the user is at the declared station. Use ->redirect() to move users between steps:
// routes/laragram.php BotRoute::get('message')->contains('/start')->call([Ctrl::class, 'start']); BotRoute::get('message')->from('ask_name')->call([Ctrl::class, 'saveName']); BotRoute::get('message')->from('ask_email')->call([Ctrl::class, 'saveEmail']); // controller public function start(): BotResponse { return $this->response->text("What's your name?")->redirect('ask_name'); } public function saveName(BotRequest $request): BotResponse { // store name ... return $this->response->text('Now your email:')->redirect('ask_email'); }
Debug routing in your terminal:
php artisan laragram:route:match message "/start" php artisan laragram:route:match message "hello" --station=ask_name
Artisan Commands
| Command | Description |
|---|---|
laragram:install |
Publish all package assets |
laragram:publish |
Selective publish (config / migrations / views / routes) |
laragram:webhook:set |
Register the webhook with Telegram |
laragram:webhook:remove |
Remove the webhook |
laragram:getMe |
Display bot info (getMe) |
laragram:webhook:info |
Display current webhook state |
laragram:poll |
Start long-polling (dev without a public URL) |
laragram:route:list |
List all registered bot routes |
laragram:route:match {event} {text} |
Debug: show which route matches |
laragram:session:prune |
Delete expired sessions |
laragram:make:controller |
Scaffold a new bot controller |
laragram:make:view |
Scaffold a new bot view directory |
laragram:set-role {uid} {role} |
Assign a role to a user |
Supported Update Types
| Event | Matched against |
|---|---|
message / edited_message / channel_post / edited_channel_post |
text |
callback_query |
data |
inline_query |
query |
chosen_inline_result |
result_id |
shipping_query / pre_checkout_query |
invoice_payload |
poll |
question |
poll_answer |
option_ids |
my_chat_member / chat_member / chat_join_request |
from |
Changelog
See CHANGELOG for release notes.
License
MIT — see LICENSE.