PHPackages                             wekser/laragram - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. wekser/laragram

ActiveLibrary[API Development](/categories/api)

wekser/laragram
===============

Laravel package for building Telegram bots in MVC style.

v1.6(1y ago)798[2 PRs](https://github.com/wekser/laragram/pulls)MITPHPPHP ^8.0CI passing

Since Feb 5Pushed 1mo ago3 watchersCompare

[ Source](https://github.com/wekser/laragram)[ Packagist](https://packagist.org/packages/wekser/laragram)[ Docs](https://github.com/wekser/laragram)[ RSS](/packages/wekser-laragram/feed)WikiDiscussions master Synced 4w ago

READMEChangelog (10)Dependencies (4)Versions (30)Used By (0)

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.3 · Laravel ^12|^13

---

Installation
------------

[](#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
------------

[](#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, and calls your controller. The controller returns one **or several** `BotResponse` objects; Laragram delivers each as an outbound Telegram Bot API call and answers the webhook with `200 OK`.

---

Routes
------

[](#routes)

Bot routes live in `routes/laragram/routes.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:**

MethodDescription`->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`->chat('supergroup')`Restrict to chat type(s) — `->inGroups()` / `->inPrivate()` are shortcuts`->thread(42)`Restrict to forum topic(s) — see [Group Chats &amp; Forum Topics](#group-chats--forum-topics)`->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 a shared station, role, chat type, and forum topic 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)

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->react('👍')                             // setMessageReaction
$response->action('typing')                        // sendChatAction
$response->invoice(Invoice::make()...)             // sendInvoice (see Payments)
$response->inlineResults(InlineResults::make()...) // answerInlineQuery (see Inline Mode)
$response->keyboard([...])                         // attach reply_markup (call after content)
$response->thread(42)                              // send into a forum topic (see Group Chats)
$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.

### Multiple messages

[](#multiple-messages)

Return an **array** of responses to send several messages in reply to one update. Each is delivered as a separate Bot API call, in order:

```
public function welcome(): array
{
    return [
        $this->response->view('greeting'),
        $this->response->text('Here is a quick tip 👇'),
        $this->response->photo($fileId, caption: 'And a picture')->redirect('home'),
    ];
}
```

- The next **station** is taken from the last response that calls `->redirect()` (last-write-wins); if none do, the user stays at the current station.
- Delivery is resilient: a failed message is logged and the batch continues — unless the user is unreachable (blocked the bot, deactivated, chat gone), in which case the remaining messages are skipped.
- Each `BotResponse::text()`, `view()`, `photo()`, etc. returns a **fresh** instance, so collecting several of them into an array always produces distinct messages — even when built through the `BotResponse` facade.

---

Views
-----

[](#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, {{ $first_name }}!
{!! __('laragram.welcome.body') !!}

```

Static markup (`…`) 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)
------------------------

[](#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()
        ->requestLocation('📍 Send my location')   // also requestContact / requestPoll / requestUser / requestChat
        ->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); `ReplyKeyboard` adds one-tap `requestContact()` / `requestLocation()` / `requestPoll()` / `requestUser()` / `requestChat()` buttons. 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)
-----------------------

[](#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/routes.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(): array
{
    // Send several messages at once; the next station comes from the last
    // response that calls redirect() (here, the question).
    return [
        $this->response->text('Welcome! 👋'),
        $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
```

---

Group Chats &amp; Forum Topics
------------------------------

[](#group-chats--forum-topics)

The bot works in private chats **and** in groups/supergroups — private (1-on-1) behaviour is unchanged.

```
// Restrict routes to a chat type (default: any)
BotRoute::get('message')->contains('/rules')->inGroups()->call([Ctrl::class, 'rules']);
BotRoute::get('message')->contains('/settings')->inPrivate()->call([Ctrl::class, 'settings']);
BotRoute::get('message')->contains('/kick')->chat('supergroup')->call([Ctrl::class, 'kick']);

// Inspect the chat in a handler
public function rules(BotRequest $request): BotResponse
{
    $request->isGroup();       // group || supergroup
    $request->isPrivate();
    $request->chatType();      // 'private' | 'group' | 'supergroup' | 'channel'
    return $this->response->view('rules');
}
```

- **Commands** like `/start` arrive as `/start@YourBot` in groups — the mention is stripped automatically. Set `LARAGRAM_BOT_USERNAME` (your bot's @username, without `@`) so only *your* bot's mention matches.
- **State is per-(user, chat, topic):** each member has independent station + scene state in each chat — and in each forum topic within it — so wizards never collide between a group, a topic, and a private chat.
- **Group privacy is ON by default** in @BotFather: the bot only sees commands aimed at it, replies to its own messages, and @mentions. To receive all group messages, disable privacy via @BotFather (`/setprivacy` → Disable) and re-add the bot.

### Forum topics

[](#forum-topics)

In a supergroup with topics enabled, **a reply is sent back to the topic it came from** — nothing to configure. Beyond that:

```
// Restrict a route to one or more topics
BotRoute::get('message')->contains('/deploy')->thread(42)->call([OpsController::class, 'deploy']);

public function deploy(BotRequest $request): array
{
    $request->threadId();          // 42 — the topic, or null outside one
    $request->isTopicMessage();

    return [
        $this->response->text('Deploying…'),                 // → back into topic 42
        $this->response->text('Logged')->thread(7),           // → into topic 7 instead
        $this->response->text('Announcement')->thread(null),  // → the General topic
    ];
}
```

To **push a notification into a topic** with no incoming update to reply to, call the API directly — `BotAPI` is a `__call()` proxy, so any parameter passes straight through:

```
BotAPI::sendMessage(['chat_id' => -1001234567890, 'message_thread_id' => 42, 'text' => 'Build failed']);
```

- A topic is detected from `is_topic_message`, not from the bare presence of `message_thread_id` — Telegram also sets that field on plain **replies** inside a non-forum supergroup, where the id is not a valid send target. A forum's **General** topic behaves like any non-forum chat (no topic).
- `message_thread_id` is injected only onto methods that accept it (any `send*`, plus `copyMessage` / `forwardMessage`) — never onto `answer*`, `edit*`, `delete*`, or `setMessageReaction`.
- The bot needs the *Manage Topics* admin right to post in a closed topic.

> **Upgrading?** Per-topic state adds a `thread_id` column to `laragram_sessions`. Fresh installs get it from `laragram:install`; an existing app must add it, or the session upsert will fail on every update:
>
> ```
> Schema::table('laragram_sessions', function (Blueprint $table) {
>     $table->unsignedBigInteger('thread_id')->default(0)->after('chat_id');
>     $table->dropUnique(['user_id', 'chat_id']);
>     $table->unique(['user_id', 'chat_id', 'thread_id']);
>     $table->index(['user_id', 'chat_id', 'thread_id', 'last_activity']);
> });
> ```
>
>
>
> `thread_id` is `NOT NULL DEFAULT 0` (`0` = no topic) because SQL treats `NULL`s as distinct, which would defeat the unique key.

---

Scenes (Wizards)
----------------

[](#scenes-wizards)

For multi-step forms — registration, an order, a survey — a **scene** manages the whole flow for you: it asks each step's question, validates the answer, stores it, and hands all answers to a completion handler. It's built on top of stations, but you don't wire them by hand.

Define scenes in `routes/laragram/scenes.php`:

```
use Wekser\Laragram\Facades\BotResponse;
use Wekser\Laragram\Facades\BotScene;
use Wekser\Laragram\Telegram\Keyboards\InlineKeyboard;

BotScene::define('order')
    ->step('size')
        ->ask(fn ($ctx) => BotResponse::text('What size?')->keyboard(   // pick by tapping
            InlineKeyboard::make()
                ->button('Small', 'Small')->button('Medium', 'Medium')->button('Large', 'Large')
                ->toArray()))
        ->expectCallback()                                             // read the tapped button
        ->rules(['required', 'in:Small,Medium,Large'])
    ->step('address')
        ->ask(fn ($ctx) => BotResponse::text("Address for {$ctx->get('size')}?"))
        ->rules(['required', 'min:5'])
        ->transform(fn ($v) => trim($v))
    ->cancelOn('/cancel')
    ->onComplete([OrderController::class, 'place']);
```

Enter the scene from any route handler, and handle the result when it finishes:

```
public function order(BotRequest $request)
{
    return BotScene::enter('order');        // sends the first question
}

public function place(SceneContext $ctx)    // onComplete handler — gets every answer
{
    Order::create($ctx->all());
    return BotResponse::view('order.done')->redirect('home');
}
```

More step/scene options: `->when(fn ($ctx) => …)` (conditional steps), `->allowBack('/back')`, `->timeout($minutes)` + `->onTimeout()`, `->onInvalid($view|$closure)` (custom error message), and typed extractors `->expectContact()/expectLocation()/expectPhoto()/expectCallback()`. A `SceneContext` exposes `get()/all()/has()/request()/user()`. Back navigation drops any answers a changed earlier answer makes irrelevant (so `onComplete` never sees inconsistent data), and an invalid reply re-asks the step without resetting the `timeout()` clock.

> Scenes require the `database` auth driver (step state is persisted between updates in `laragram_sessions.payload`). Scaffold one with `php artisan laragram:make:scene order --steps=size,address`.

See the [Scenes wiki page](https://github.com/wekser/laragram/wiki/Scenes) for the full reference.

---

Payments (Telegram Stars &amp; fiat)
------------------------------------

[](#payments-telegram-stars--fiat)

Send invoices, answer the checkout steps, and handle completed payments — for both **Telegram Stars** (`XTR`) and fiat currencies (Telegram Payments 2.0):

```
use Wekser\Laragram\Telegram\Payments\Invoice;
use Wekser\Laragram\Telegram\Keyboards\InlineKeyboard;

// 1. Send a Stars invoice — chat_id is injected automatically
public function donate()
{
    return $this->response->invoice(
        Invoice::make()->title('Support us')->description('One-time donation')
            ->payload('donate_1')->stars(100, 'Donation')
    )->keyboard(InlineKeyboard::make()->pay('Pay ⭐100')->toArray());
}

// Fiat variant: ->currency('USD')->price('Item', 1999)->providerToken(...)

// 2. Approve (or decline) the pre-checkout step — answer within 10 seconds
BotRoute::get('pre_checkout_query')->call(fn () => BotResponse::approveCheckout());

// 3. Handle the completed payment (a field on a "message" update — note the listener override)
BotRoute::get('message', 'successful_payment')->call([ShopController::class, 'paid']);
```

- Amounts are in the currency's **smallest unit** (cents; whole stars for `XTR`).
- `approveShipping(array $options)` / `declineShipping($reason)` cover flexible-price shipping.
- `BotRequest` accessors: `preCheckoutQuery()`, `shippingQuery()`, `successfulPayment()`.
- **`Events\PaymentReceived`** fires for every completed payment — independently of routing — so you can grant the entitlement from a single listener (`invoicePayload()`, `chargeId()`, `totalAmount()`, `isStars()`).
- Opt-in **payment history**: set `LARAGRAM_PAYMENTS_STORE=true` (plus the published `laragram_payments` migration) and every payment is persisted idempotently.
- Outbound actions live on the `Services\Payments` service: `invoiceLink()` (createInvoiceLink), `refund($userId, $chargeId)` (refundStarPayment), `starTransactions()`.

```
LARAGRAM_PAYMENT_PROVIDER_TOKEN=   # fiat only; Stars need no provider
LARAGRAM_PAYMENT_CURRENCY=USD
LARAGRAM_PAYMENTS_STORE=false
```

---

Inline Mode
-----------

[](#inline-mode)

Answer `inline_query` updates — the results users get typing `@yourbot query` in any chat:

```
use Wekser\Laragram\Telegram\Inline\InlineResults;

BotRoute::get('inline_query')->call(function (BotRequest $request) {
    return BotResponse::inlineResults(
        InlineResults::make()
            ->article('1', 'Say hello', 'Hello there!')       // sends text when picked
            ->photo('2', 'https://example.com/pic.jpg')
            ->cachedPhoto('3', $fileId)                        // by cached file_id
            ->cache(300)->personal()
    );
});

// Optional: know which result the user picked (enable inline feedback with @BotFather)
BotRoute::get('chosen_inline_result')->call([StatsController::class, 'picked']);
```

Result builders: `article()`, `photo()`, `gif()`, `video()`, `document()`, `cachedPhoto()`, `sticker()`, and `raw(array)` for any other `InlineQueryResult` type. Answer-level options: `cache()`, `personal()`, `nextOffset()` (pagination), `button()`. The `inline_query_id` is injected automatically.

---

Receiving Files
---------------

[](#receiving-files)

Turn a file a user **sent to the bot** into bytes or a stored file — the mirror image of `MediaUploader`:

```
use Wekser\Laragram\Services\MediaDownloader;

public function receive(BotRequest $request, MediaDownloader $downloader)
{
    // Fluent handle off the request (null when the update carries no file):
    $path  = $request->file()?->save('local', 'inbox/receipt.jpg');   // store on a disk
    $bytes = $request->file()?->bytes();                              // raw bytes

    // Or the service directly:
    $path = $downloader->save($request->fileId(), 's3', 'kyc/doc.pdf');

    return $this->response->text('Got it!');
}
```

`BotRequest::fileId()` finds the file across the common media fields (photo → largest size, document, video, audio, voice, animation, video\_note, sticker). Downloads are SSRF-hardened and size-capped:

```
LARAGRAM_DOWNLOADS_DISK=local
LARAGRAM_DOWNLOADS_MAX_SIZE=20971520   # 20 MB — Telegram's getFile limit
LARAGRAM_DOWNLOADS_TIMEOUT=30          # download HTTP timeout, seconds
```

---

Message Reactions
-----------------

[](#message-reactions)

React to messages and handle users' reactions:

```
// React to an incoming message (chat_id + message_id injected automatically)
BotRoute::get('message')->contains('/like')
    ->call(fn () => BotResponse::react('👍', big: true));

// Handle a user changing their reaction on a message
BotRoute::get('message_reaction')->call(function (BotRequest $request) {
    $reaction = $request->messageReaction();   // chat, message_id, user, old/new_reaction
    return BotResponse::react('❤️');           // react back on the same message
});
```

`react()` accepts an emoji string, a list of them, raw `ReactionType` arrays (custom emoji), or `[]` to clear the bot's reaction.

> Telegram delivers `message_reaction` / `message_reaction_count` updates only when `allowed_updates` explicitly includes them — pass it when calling `setWebhook`.

---

Queue (optional, for scale)
---------------------------

[](#queue-optional-for-scale)

By default Laragram processes each update **inside** the webhook request. Under bursts of concurrent users you can offload processing to a queue: the webhook validates the update, dispatches a job, and answers `200 OK` immediately, while routing and the outbound Bot API calls run on a worker.

Enable it in `.env`:

```
LARAGRAM_QUEUE_ENABLED=true
LARAGRAM_QUEUE_CONNECTION=redis   # leave unset to use your default connection
LARAGRAM_QUEUE_NAME=default
LARAGRAM_QUEUE_RATE_LIMIT=25      # max update jobs/sec across all workers
```

Run a worker:

```
php artisan queue:work --queue=default
```

- The four webhook middleware (verify → auth → dedup → throttle) still run synchronously, so only **verified, non-bot, non-duplicate, rate-limited** updates are ever queued.
- **Per-user ordering:** jobs are serialized per sender (`WithoutOverlapping`), avoiding session races. This is mutual exclusion, not strict FIFO — run a **single worker per queue** if a step-by-step station flow must never reorder.
- **Throughput:** a named `laragram` rate limiter caps global execution to stay under Telegram's ~30 msg/sec outbound limit.
- **Privacy:** the job implements `ShouldBeEncrypted`, so the payload (which carries user PII) is encrypted at rest in the queue store.

> Use Redis in production — the rate limiter and the per-user lock need a shared cache store to be accurate across multiple workers. When disabled (the default), behaviour is fully synchronous and unchanged.

---

Broadcasting (mass messaging)
-----------------------------

[](#broadcasting-mass-messaging)

Send one message to your whole user base — announcements, promos, downtime notices — with the `BotBroadcast` facade or the `laragram:broadcast` command. Requires the `database` auth driver (there are no persisted users under `array`).

```
use Wekser\Laragram\Facades\BotBroadcast;
use Wekser\Laragram\Facades\BotResponse;
use Wekser\Laragram\Telegram\Keyboards\InlineKeyboard;

// Raw text to every active user
BotBroadcast::text('We are back online!')->send();

// A view, rendered per recipient (in their own language, with $user in scope)
BotBroadcast::view('news.release', ['version' => '2.0'])
    ->role(['admin', 'moderator'])     // optional: restrict to roles
    ->includeInactive()                // optional: also reach deactivated users
    ->send();

// A fully-composed BotResponse — formatting + keyboard + media, anything a reply can carry
BotBroadcast::message(
    BotResponse::photo($fileId, 'Launch day!')
        ->keyboard(InlineKeyboard::make()->href('Read more', $url)->toArray())
)->send();
```

Three ways to compose a broadcast — all can carry **full formatting, inline/reply keyboards, and media**:

MethodRenderedUse for`text($text)`oncea plain announcement`view($name, $data)`**per recipient** (localized, `$user` in scope)a reusable, translated message; buttons/media come from the view's component files`message(BotResponse)`oncean ad-hoc rich message (keyboard/media built inline) — throws on an empty `BotResponse`From the CLI:

```
php artisan laragram:broadcast "We are back online!"             # text to all active users
php artisan laragram:broadcast --view=news.release              # render a view per recipient
php artisan laragram:broadcast --view=news.release --data='{"version":"2.0"}'   # + template data (JSON)
php artisan laragram:broadcast "Admins only" --role=admin --dry-run   # just print the recipient count
```

- **Delivery scales with your setup.** With the queue enabled, a broadcast dispatches one job per recipient, throttled by the same `laragram` rate limiter as incoming updates; otherwise it sends synchronously, paced just under Telegram's ~30 msg/sec limit. `text()` / `view()` messages are rendered per recipient (so views localize to each user); a `message()` payload is rendered once at compose time.
- **Unreachable users self-prune.** The first time a send fails because a user blocked the bot, deactivated, or the chat is gone, that user is marked inactive (`User::deactivate()`) so future broadcasts skip them. This runs for *every* send, not just broadcasts, via the `BotExceptionHandled` event — toggle with `LARAGRAM_BROADCAST_DEACTIVATE_UNREACHABLE`.

```
LARAGRAM_BROADCAST_CHUNK_SIZE=500             # recipients loaded per batch
LARAGRAM_BROADCAST_SYNC_DELAY_MS=40           # pause between sends on the synchronous path (≈25/sec)
LARAGRAM_BROADCAST_DEACTIVATE_UNREACHABLE=true
```

---

Admin Panel
-----------

[](#admin-panel)

A bundled, server-rendered dashboard for your bot's user base — metrics, users &amp; roles, sessions, and a broadcast composer. Self-contained (own routes + Blade views, no build step, no external dependencies), mounted at `/laragram/admin` by default. Requires the `database` auth driver.

The panel is protected by its **own login page** backed by the `laragram_admins` table — no host-app web auth is required, and it works in production from any IP. Run the migration (`laragram:install` scaffolds it), then create an account:

```
php artisan laragram:admin:create            # prompts for username + password (min 8 chars)
php artisan laragram:admin:create alice --name="Alice" --password=secret123   # or non-interactively
php artisan laragram:admin:delete alice      # remove an account
```

Browse to `/laragram/admin` and you'll be redirected to the login page. Passwords are hashed automatically by `Models\Admin` (a dedicated `Authenticatable`, distinct from the Telegram `User`), which logs in through a self-registered `laragram_admin` session guard — so **no `config/auth.php` edits are needed**.

**Escape hatch:** if you'd rather reuse your host app's own web auth, define a `viewLaragram` Gate ability — it overrides the login and decides access itself (a denying gate is a hard 403):

```
// app/Providers/AppServiceProvider.php
Gate::define('viewLaragram', fn ($user) => $user->isAdmin());
```

```
LARAGRAM_ADMIN_ENABLED=true
LARAGRAM_ADMIN_PATH=laragram/admin
```

Pages: **Dashboard** (total/active users, new today/week, roles, active sessions) · **Users** (set role, activate/deactivate) · **Sessions** (browse, prune) · **Broadcast** — compose a plain-text message **or** pick an on-disk view (with optional JSON data) so an operator can send a fully-formatted, per-recipient-localized message with buttons/media, not just text; dry-run count or send, honouring the queue/sync path.

---

Observability
-------------

[](#observability)

Laragram never lets an exception escape update processing — routing, delivery, and the queued job all funnel their errors through `ExceptionHandler`, which logs reportable ones and silences user-unreachable ones. That makes silently-handled failures invisible (they never reach `failed_jobs`). The `BotExceptionHandled` event is the seam for surfacing them:

```
use Illuminate\Support\Facades\Event;
use Wekser\Laragram\Events\BotExceptionHandled;

Event::listen(function (BotExceptionHandled $e) {
    // $e->exception, $e->reportable (was it logged?), $e->terminal (user unreachable?)
    if ($e->terminal) {
        // e.g. count how many users blocked the bot
    }
    // push to Sentry / StatsD / Horizon tags…
});
```

Listening is optional (no listener = near-zero-cost no-op); dispatch is guarded, so a faulty listener can never break exception handling.

---

Artisan Commands
----------------

[](#artisan-commands)

CommandDescription`laragram:install`Bootstraps the host app: config, migrations, blank route/scene files, `.env` variables`laragram:publish`Publishes the runnable demo: views, lang, demo controllers + routes (incl. Stars payments, inline mode, file receiving)`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:make:scene`Scaffold a new scene (wizard) in the scenes file`laragram:scene:list`List all registered scenes`laragram:set-role {uid} {role}`Assign a role to a user`laragram:broadcast {message?}`Mass-message users (`--view`, `--data`, `--role=*`, `--include-inactive`, `--dry-run`, `--no-confirm`)`laragram:admin:create {username?}`Create (or reset the password of) an admin-panel login account (`--name`, `--password`)`laragram:admin:delete {username}`Delete an admin-panel login account---

Supported Update Types
----------------------

[](#supported-update-types)

EventMatched 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``message_reaction``user``message_reaction_count``reactions`---

Changelog
---------

[](#changelog)

See [CHANGELOG](CHANGELOG.md) for release notes.

License
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

46

—

FairBetter than 92% of packages

Maintenance69

Regular maintenance activity

Popularity15

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity77

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~84 days

Recently: every ~207 days

Total

28

Last Release

455d ago

PHP version history (4 changes)v1.0.0PHP ^7.1.3

v1.2.1PHP ^7.3|^8.0

v1.5.0PHP ^8.0

v1.5.12PHP ^8.1

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/17425956?v=4)[Sergey Lapin](/maintainers/wekser)[@wekser](https://github.com/wekser)

---

Top Contributors

[![wekser](https://avatars.githubusercontent.com/u/17425956?v=4)](https://github.com/wekser "wekser (108 commits)")

---

Tags

laravellaravel-telegramtelegramtelegram-bottelegram-bot-apiapilaravellumenbottelegramchatbot

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/wekser-laragram/health.svg)

```
[![Health](https://phpackages.com/badges/wekser-laragram/health.svg)](https://phpackages.com/packages/wekser-laragram)
```

###  Alternatives

[mollie/laravel-mollie

Mollie API client wrapper for Laravel &amp; Mollie Connect provider for Laravel Socialite

3614.7M40](/packages/mollie-laravel-mollie)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
