wekser/laragram
Laragram
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.