larasup/telegram

Laravel package for building Telegram bots with declarative routing, typed views, and keyboard builders

Maintainers

Package info

gitlab.com/larasup/php-telegram

Issues

pkg:composer/larasup/telegram

Statistics

Installs: 20

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-04-12 12:06 UTC

This package is auto-updated.

Last update: 2026-04-12 07:11:58 UTC


README

Laravel package for building Telegram bots. Webhook-based, queue-driven, with declarative routing, typed views, keyboard builders, and full Telegram Bot API coverage.

Table of Contents

Requirements

  • PHP 8.2+
  • Laravel 10, 11, or 12

Installation

composer require larasup/telegram

Run migrations:

php artisan migrate

Publish the config:

php artisan vendor:publish --tag=telegram-config

Configuration

Environment variables:

TELEGRAM_BOT_TOKEN=123456:ABC-DEF          # Bot token from @BotFather
TELEGRAM_BOT_USERNAME=MyBot                 # Bot username (without @)
TELEGRAM_SECRET_TOKEN=your-random-secret    # Webhook secret (X-Telegram-Bot-Api-Secret-Token)
TELEGRAM_BOT_RESPONSE=true                  # Enable/disable bot responses
TELEGRAM_MODE=production                    # production / testing
TELEGRAM_NOTIFIABLE_ID=                     # Admin Telegram ID for debug info
TELEGRAM_LEAVE_CHANNELS=false               # Auto-leave when added to a channel

Config file config/telegram.php:

return [
    'table_prefix'   => 'telegram_',                    // DB table prefix
    'user_model'     => App\Models\User::class,         // Host app User model
    'token'          => env('TELEGRAM_BOT_TOKEN'),
    'username'       => env('TELEGRAM_BOT_USERNAME'),
    'secret_token'   => env('TELEGRAM_SECRET_TOKEN'),
    'response'       => env('TELEGRAM_BOT_RESPONSE', true),
    'mode'           => env('TELEGRAM_MODE', 'production'),
    'leave_channels' => env('TELEGRAM_LEAVE_CHANNELS', false),
    'notifiable_id'  => env('TELEGRAM_NOTIFIABLE_ID'),
    'base_messages'  => [
        'unknown' => 'telegram::base_messages.unknown',
    ],
    'routes_file'    => base_path('routes/telegram.php'),
    'router_cache'   => 'cache/telegram_routes.php',
];

Architecture

Request Flow:

Telegram → POST /webhook/{token}
         → TelegramController (creates RequestLog, dispatches job)
         → AnswerTelegramRequest job (queue: high)
           → TelegramRequestPreparator
             → Update DTO parsed from raw JSON
             → User resolution (find or create TelegramUser + User)
             → Locale setup from user settings
             → TelegramRequestDTO construction
             → Router::process() — finds handler from declared routes
               → YourController::method()
                 → returns View (MessageView, PhotoView, ...)
                 → wrapped into TelegramResponse (method + params)
           → TelegramClient::send(view)
             → SendTelegramRequest job (queue: high)
               → cURL to Telegram API

Key Components

ComponentResponsibility
TelegramControllerWebhook entry point, job dispatch
UpdateTyped DTO for incoming Telegram updates (47 types)
TelegramRequestPreparatorUser management, request parsing, route execution
RouterRoute registration and matching
TelegramResponseWraps view/array into method + params pair
TelegramViewBase class for typed responses (17 view classes)
TelegramClientTelegram API communication (33 methods)
TelegramRequestDTORequest data container with parsed command and route
ControllerBase class for bot controllers

Webhook

Set up the webhook URL with Telegram:

php artisan telegram:show_url

Register via API or use the client:

TelegramClient::setWebhook(
    'https://your-domain.com/webhook/' . config('telegram.token'),
    secretToken: config('telegram.secret_token'),
);

Webhook Security

When TELEGRAM_SECRET_TOKEN is set, every incoming request must carry the matching X-Telegram-Bot-Api-Secret-Token header. Requests without it (or with a wrong value) receive 403 Forbidden and are never processed. This prevents third parties from sending fake updates to your webhook URL.

The webhook endpoint:

POST /webhook/{token}

Route is automatically registered with CSRF and session middleware excluded.

Routing

Declaring Routes

Routes are declared in routes/telegram.php (path configurable via config('telegram.routes_file')). Two route types are supported:

use Larasup\Telegram\Facades\TelegramRoute;

// Forward routes — handle commands, button presses, and callbacks
TelegramRoute::make('base.start', [MainController::class, 'start']);
TelegramRoute::make('main.mainMenu', [MainController::class, 'mainMenu']);
TelegramRoute::make('settings.settings', [SettingsController::class, 'settings']);

// Reverse (back) routes — handle the "Назад" / "Back" reply button
TelegramRoute::back('base.back', [MainController::class, 'mainMenu']);            // catch-all
TelegramRoute::back('settings.settings', [PersonalController::class, 'cabinet']);  // Settings → Personal
TelegramRoute::back('settings.timeZone', [SettingsController::class, 'settings']); // Timezone → Settings

Route Types

MethodTypeLookup keyDescription
TelegramRoute::make($uri, $action)Directsection.command from button or callbackForward navigation
TelegramRoute::back($sourceUri, $action)ReverselastCommand->type of the userState-aware back navigation

State-Aware Back Navigation

When a user presses the Back reply button (any text whose section/command resolves to main.back or base.back), the package:

  1. Switches the request route type to Reverse.
  2. Looks up the handler by the user's current screenlastCommand->type — instead of the literal button URI. This means each TelegramRoute::back() declaration says "when leaving screen X, run handler Y".
  3. Falls back to the registered 'base.back' handler if no specific entry matches the source screen, or if lastCommand is missing entirely.

For this to work, every screen controller must record its identifier when the user enters it:

use Larasup\Telegram\Models\UserCommand;

class SettingsController extends Controller
{
    public function settings(): TelegramResponse
    {
        // Mark the user as being on the "settings.settings" screen.
        // UserCommand::create rejects any previous state automatically.
        UserCommand::create($this->request->telegramUser, 'settings.settings');

        return TelegramResponse::fromView(
            MessageView::make(trans('messages.settings'))
                ->keyboard(SettingsKeyboard::menu()),
        );
    }

    public function timeZone(): TelegramResponse
    {
        UserCommand::create($this->request->telegramUser, 'settings.timeZone');
        // ...
    }
}

With those state records in place, this routing table:

TelegramRoute::back('base.back', [MainController::class, 'mainMenu']);
TelegramRoute::back('personal.cabinet', [MainController::class, 'mainMenu']);
TelegramRoute::back('settings.settings', [PersonalController::class, 'cabinet']);
TelegramRoute::back('settings.timeZone', [SettingsController::class, 'settings']);

produces the navigation hierarchy:

Main Menu
  └─ Personal Cabinet
       └─ Settings
            └─ Time Zone

A Back press from any screen walks one level up. From a screen with no registered back handler (or no recorded state at all), the user is sent to the 'base.back' catch-all — typically the main menu.

Why state-aware?

The reply button "Back" looks the same on every screen, but it should lead to different parents depending on context. Naive routing matches by the literal button URI and gives one handler for all back presses, forcing manual dispatch inside it. State-aware reverse routing pushes that decision into the router itself: each back() entry is a single, declarative arrow in the navigation graph.

Route Caching

Cache routes for production:

php artisan telegram:route-cache    # Cache declared routes
php artisan telegram:route-clear    # Clear route cache

Controllers

Extend the base Controller class. The TelegramRequestDTO is available via $this->request.

use Larasup\Telegram\Routing\Controller;
use Larasup\Telegram\Keyboard\Keyboard;
use Larasup\Telegram\Keyboard\InlineButton;
use Larasup\Telegram\Views\MessageView;

class MainController extends Controller
{
    public function start(): MessageView
    {
        return MessageView::make('Welcome! Choose an option:')
            ->markdown()
            ->keyboard(
                Keyboard::inline(
                    Keyboard::row(
                        InlineButton::callback('Profile', '/profile'),
                        InlineButton::callback('Settings', '/settings'),
                    ),
                )
            );
    }
}

Return Types

Controllers can return a View, a TelegramResponse, or a plain array:

// View (recommended) — typed, fluent, knows its Telegram API method
public function start(): MessageView
{
    return MessageView::make('Hello!')->markdown();
}

// Photo, document, location, etc. — same pattern
public function photo(): PhotoView
{
    return PhotoView::make($fileId)->caption('Check this out')->spoiler();
}

// Array (backward compatible) — sent as sendMessage
public function legacy(): array
{
    return ['text' => 'Hello!', 'parse_mode' => 'Markdown'];
}

// TelegramResponse (full control)
public function custom(): TelegramResponse
{
    return TelegramResponse::fromView(
        LocationView::make(55.75, 37.61)->live(3600)
    );
}

The API method (sendMessage, sendPhoto, editMessageText, ...) is determined automatically from the view — no need to specify it manually.

See Views for the full list of available views.

Accessing Request Data

class MyController extends Controller
{
    public function handle(): MessageView
    {
        $update   = $this->request->update;              // Update DTO
        $user     = $this->request->telegramUser;         // TelegramUser model
        $callback = $this->request->callback;             // CallbackData (if callback)

        // Typed access to update data
        $from     = $update->from();                      // UserData
        $chat     = $update->chat();                      // ChatData
        $text     = $update->text();                      // ?string

        // Media from message
        $photo    = $update->message?->largestPhoto();    // ?PhotoSizeData
        $location = $update->message?->location;          // ?LocationData
        $document = $update->message?->document;          // ?DocumentData

        // Callback data
        $id = $callback?->data['id'] ?? null;

        return MessageView::make('Hello, ' . $from->firstName . '!');
    }
}

Answering Callback Queries

When a controller handles an inline button click, the package automatically calls answerCallbackQuery after the controller returns — this stops the loading spinner on the button. By default the answer carries no text, so the user sees no popup.

To raise a toast (transient banner at the top of the screen) or an alert (modal popup), call $this->answerCallback() from the controller:

class CartController extends Controller
{
    public function add(): TelegramResponse
    {
        // ... do work ...

        // Brief toast — disappears after a second
        $this->answerCallback('✅ Добавлено в корзину');

        return TelegramResponse::empty();
    }

    public function purchase(): TelegramResponse
    {
        // Modal alert — user must dismiss it
        $this->answerCallback(
            text: '🚧 Покупка временно недоступна',
            showAlert: true,
        );

        return TelegramResponse::empty();
    }

    public function lastPage(): TelegramResponse
    {
        // Pagination edge case: tell the user there is nothing further
        $this->answerCallback('Это последняя страница');

        return TelegramResponse::empty();
    }
}

The payload is stored on TelegramRequestDTO::$callbackAnswer and forwarded to Telegram by the request preparator. Returning TelegramResponse::empty() keeps the original message intact — useful when the only feedback is the toast itself. Of course you can also combine answerCallback() with an edited or new message.

Note: Telegram allows exactly one answer per callback query. Calling answerCallback() from the controller is the only way to set the text — do not invoke TelegramClient::answerCallbackQuery directly, it would conflict with the framework's auto-answer.

Typed DTOs

All incoming Telegram data is parsed into typed, readonly DTOs. No more $data['message']['from']['id'].

Update Types (in Larasup\Telegram\DTOs\Api)

DTOTelegram TypeAccess via Update
UpdateUpdatetop-level
MessageDataMessage$update->message
CallbackQueryDataCallbackQuery$update->callbackQuery
InlineQueryDataInlineQuery$update->inlineQuery
ChosenInlineResultDataChosenInlineResult$update->chosenInlineResult
ChatMemberUpdatedDataChatMemberUpdated$update->myChatMember, $update->chatMember
ChatJoinRequestDataChatJoinRequest$update->chatJoinRequest
ShippingQueryDataShippingQuery$update->shippingQuery
PreCheckoutQueryDataPreCheckoutQuery$update->preCheckoutQuery
PollDataPoll$update->poll
PollAnswerDataPollAnswer$update->pollAnswer

Message Content Types

DTOAccess via Message
UserData$msg->from
ChatData$msg->chat
PhotoSizeData$msg->photo (array), $msg->largestPhoto()
DocumentData$msg->document
VideoData$msg->video
AudioData$msg->audio
VoiceData$msg->voice
VideoNoteData$msg->videoNote
AnimationData$msg->animation
StickerData$msg->sticker
LocationData$msg->location
VenueData$msg->venue
ContactData$msg->contact
DiceData$msg->dice
StoryData$msg->story
MessageEntityData$msg->entities (array)
InvoiceData$msg->invoice
SuccessfulPaymentData$msg->successfulPayment
WebAppDataData$msg->webAppData

Service Message Types

DTOAccess via Message
ForumTopicData$msg->forumTopicCreated, $msg->forumTopicEdited
UsersSharedData / SharedUserData$msg->usersShared
ChatSharedData$msg->chatShared
ProximityAlertData$msg->proximityAlertTriggered
VideoChatData$msg->videoChatStarted, $msg->videoChatEnded, etc.
WriteAccessAllowedData$msg->writeAccessAllowed

Helper Types

DTOUsed by
ChatMemberDataChatMemberUpdatedData
ChatInviteLinkDataChatMemberUpdatedData, ChatJoinRequestData
ChatPermissionsDataTelegramClient::restrictChatMember()
LinkPreviewOptionsDataMessageData
ReplyParametersDataMessageData
OrderInfoData / ShippingAddressDataPayment DTOs
InputMediaDataTelegramClient::sendMediaGroup()
BotCommandDataTelegramClient::setMyCommands()
PollOptionDataPollData

Message Helpers

$msg->hasText();           // has text content
$msg->isCommand();         // starts with /
$msg->hasMedia();          // photo, video, document, audio, voice, etc.
$msg->isMediaGroup();      // part of an album
$msg->isServiceMessage();  // new members, forum topic, video chat, etc.
$msg->isPayment();         // successful payment
$msg->largestPhoto();      // biggest PhotoSize from photo array

User Management

When a new Telegram user sends a message, the package automatically:

  1. Creates an internal User model (configurable via config('telegram.user_model'))
  2. Creates a TelegramUser linked to it
  3. Stores user's language preference in UserSettings
  4. Dispatches TelegramUserCreated event
  5. Authenticates the user via Auth::login()

User Settings

// Read
$locale = $telegramUser->getSettings('language_code', 'en');

// Write
$telegramUser->setSettings('language_code', 'ru');

// Remove (drops the row from telegram_user_settings — `getSettings` will
// return the default value next time)
$telegramUser->removeSettings('favorite_bookmaker_id');

// With enum keys
$telegramUser->getSettings(UserSettingEnum::LanguageCode);
$telegramUser->setSettings(UserSettingEnum::LanguageCode, 'ru');
$telegramUser->removeSettings(UserSettingEnum::FavoriteBookmakerId);

Note: the value column is NOT NULL, so you can't write null to clear a setting — use removeSettings() to drop the row instead.

User Command State

UserCommand records the user's "current screen" in telegram_user_commands. It serves two purposes:

  1. Multi-step input — when a user sends text that does not match any declared button, the router falls through to lastCommand->type so the active screen handler can interpret the input (typing a number, choosing from a custom list, etc.).
  2. Back navigationTelegramRoute::back() looks up handlers by lastCommand->type, see State-Aware Back Navigation.
use Larasup\Telegram\Models\UserCommand;

// On screen entry: record where the user is (rejects any previous state)
UserCommand::create($telegramUser, 'settings.settings');

// Anywhere: read the current screen
$user->lastCommand?->type;             // 'settings.settings'
$user->lastCommand?->data;             // optional array payload

// Force-clear all enabled commands (rarely needed — `create` does it for you)
UserCommand::rejectAllCommands($telegramUser);

Pick stable identifiers — by convention section.command matching your forward routes, e.g. 'settings.settings', 'settings.timeZone', 'personal.cabinet'.

Telegram API Client

Sending Views

use Larasup\Telegram\Client\TelegramClient;
use Larasup\Telegram\Views\MessageView;

TelegramClient::send(
    MessageView::make('Hello!')->chatId($chatId)->markdown(),
);

Chat Actions

use Larasup\Telegram\Enums\ChatActionEnum;

TelegramClient::sendChatAction($chatId, ChatActionEnum::TYPING);
TelegramClient::sendChatAction($chatId, ChatActionEnum::UPLOAD_PHOTO);

Message Operations

TelegramClient::forwardMessage($toChatId, $fromChatId, $messageId);
TelegramClient::copyMessage($toChatId, $fromChatId, $messageId);
TelegramClient::deleteMessage($chatId, $messageId);
TelegramClient::deleteMessages($chatId, [1, 2, 3]);

Media Groups

use Larasup\Telegram\DTOs\Api\InputMediaData;

TelegramClient::sendMediaGroup($chatId, [
    InputMediaData::photo('file_id_1', caption: 'First')->toArray(),
    InputMediaData::photo('file_id_2', caption: 'Second')->toArray(),
]);

Moderation

TelegramClient::banChatMember($chatId, $userId);
TelegramClient::unbanChatMember($chatId, $userId);
TelegramClient::restrictChatMember($chatId, $userId, $permissions->toArray());
TelegramClient::promoteChatMember($chatId, $userId, ['can_pin_messages' => true]);
TelegramClient::setChatAdministratorCustomTitle($chatId, $userId, 'Moderator');

Pin/Unpin

TelegramClient::pinChatMessage($chatId, $messageId);
TelegramClient::unpinChatMessage($chatId, $messageId);
TelegramClient::unpinAllChatMessages($chatId);

Live Location

TelegramClient::editMessageLiveLocation(48.85, 2.35, chatId: $chatId, messageId: $msgId);
TelegramClient::stopMessageLiveLocation(chatId: $chatId, messageId: $msgId);

Inline Mode

TelegramClient::answerInlineQuery($queryId, $results, cacheTime: 300);

Callbacks

TelegramClient::answerCallbackQuery($callbackId);
TelegramClient::answerCallbackQuery($callbackId, text: 'Done!', showAlert: true);

Bot Settings

use Larasup\Telegram\DTOs\Api\BotCommandData;

TelegramClient::setMyCommands([
    new BotCommandData('start', 'Start the bot'),
    new BotCommandData('help', 'Show help'),
]);
TelegramClient::deleteMyCommands();
TelegramClient::setMyName('My Bot');
TelegramClient::setMyDescription('A useful bot');
TelegramClient::setMyShortDescription('A useful bot for everyone');

Webhook Management

TelegramClient::setWebhook('https://example.com/webhook/TOKEN', secretToken: 'secret');
TelegramClient::deleteWebhook(dropPendingUpdates: true);

Invite Links

TelegramClient::exportChatInviteLink($chatId);
TelegramClient::createChatInviteLink($chatId, name: 'Promo', memberLimit: 100);

Other

TelegramClient::leaveChat($chatId);
TelegramClient::getUserProfilePhotos($userId);
TelegramClient::getFile($filePath);   // returns file contents

Full Method Coverage

The TelegramMethodEnum covers all 120 methods of the Telegram Bot API: messages, editing, stickers, inline mode, payments, games, chat management, forum topics, bot settings, webhook, and passport.

Rate Limiting

The package protects against Telegram's rate limits on two levels:

1. Proactive throttling

SendTelegramRequest checks a sliding-window rate limiter before making the HTTP call. If the limit is reached, the job is released back to the queue with an appropriate delay — no request is wasted.

Telegram enforces three independent limits:

ScopeLimitDescription
Global30 msg/secTotal messages across all chats
Private chat1 msg/secMessages to the same user
Group / supergroup / channel20 msg/minMessages to the same group

All three are checked; the longest required delay wins.

2. Reactive retry on 429

If Telegram still returns HTTP 429 Too Many Requests (e.g. limits changed, or multiple workers race), the job reads retry_after from the response and releases itself for exactly that many seconds. This does not count as a failed attempt — the job will not exhaust its retry budget due to throttling.

Server errors (5xx) and network failures are retried with exponential backoff (5 → 15 → 30 s). Client errors (4xx except 429) are not retried.

Configuration

All limits are configurable via config/telegram.php or .env:

TELEGRAM_RATE_LIMITS=true                   # Enable/disable proactive throttling
TELEGRAM_RATE_LIMIT_GLOBAL=30               # Messages per second (all chats)
TELEGRAM_RATE_LIMIT_CHAT=1                  # Messages per second (one private chat)
TELEGRAM_RATE_LIMIT_GROUP=20                # Messages per minute (one group)
// config/telegram.php
'rate_limits' => [
    'enabled'           => env('TELEGRAM_RATE_LIMITS', true),
    'global_per_second' => (int) env('TELEGRAM_RATE_LIMIT_GLOBAL', 30),
    'chat_per_second'   => (int) env('TELEGRAM_RATE_LIMIT_CHAT', 1),
    'group_per_minute'  => (int) env('TELEGRAM_RATE_LIMIT_GROUP', 20),
],

Set TELEGRAM_RATE_LIMITS=false to disable proactive throttling entirely. The 429 retry_after handling always remains active regardless of this setting.

Note: The rate limiter uses the cache store (prefers redis or memcached). With the array or file driver it works per-process only and cannot coordinate across multiple queue workers.

Console Commands

CommandDescription
telegram:listenLong-polling loop for development — see docs/long-polling.md. Supports --timeout=N and --force.
telegram:route-cacheCache bot routes for production
telegram:route-clearClear cached routes
telegram:clear_logsDelete request logs older than 30 days
telegram:show_urlDisplay the webhook URL
telegram:translations-makeSync button translations from lang files into the telegram_buttons table

Models

ModelTableDescription
TelegramUsertelegram_usersTelegram user linked to internal User
UserCommandtelegram_user_commandsTracks current command state
UserSettingstelegram_user_settingsKey-value user settings
RequestLogtelegram_request_logsWebhook request/response logs
CallbackDatatelegram_callback_dataStored callback payloads (crc32b hash)
Buttontelegram_buttonsLocalizable bot buttons
UserQuerytelegram_user_queriesUser query deduplication

Events

EventDispatched When
TelegramUserCreatedNew Telegram user is registered

Listen in your ServiceProvider:

Event::listen(TelegramUserCreated::class, SendWelcomeNotification::class);

Testing

The package ships with two helpers that make controller-level tests fluent and avoid hand-rolling Telegram API payloads.

Update factories

Update::fakeMessage() and Update::fakeCallback() build wire-format Update objects identical to what Telegram would deliver. The callback variant internally creates the matching CallbackData row so that buildRoute() resolves the callback hash exactly as it does in production:

use Larasup\Telegram\DTOs\Api\Update;

// Plain text message — useful for testing reply-keyboard handlers
$update = Update::fakeMessage('Главное меню', fromId: 12345);

// Inline button click — pass the command and its data payload
$update = Update::fakeCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);

Response assertions

TelegramResponse mixes in the Larasup\Telegram\Testing\AssertableResponse trait. Every assertion returns $this, so they chain naturally:

$response
    ->assertSent()                              // method === sendMessage
    ->assertEdited()                            // method === editMessageText
    ->assertEmpty()
    ->assertText('exact text')
    ->assertTextContains('substring')
    ->assertTextDoesNotContain('forbidden')
    ->assertParseMode('Markdown')
    ->assertChatId(12345)
    ->assertMessageId(7)
    ->assertHasButton('🏠 Главное меню')        // any keyboard kind
    ->assertNoButton('Removed button')
    ->assertHasReplyKeyboard()
    ->assertHasInlineKeyboard()
    ->assertHasInlineCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);

assertHasInlineCallback() resolves the inline button's callback_data hash via CallbackData::byHash() and matches both the command and the optional stored payload. Helpful for verifying that pagination/navigation buttons fire the right route.

Putting it together

A typical controller test looks like this:

public function testSetBookmakerSavesSelectionAndShowsCheckmark(): void
{
    $update = Update::fakeCallback('settings.setBookmaker', ['id' => 5, 'page' => 1]);

    $this->callController(SettingsController::class, 'setBookmaker', $this->user, $update)
        ->assertEdited()
        ->assertTextContains('Букмекер указан: bet365')
        ->assertHasButton('✅ bet365 (0)')
        ->assertHasInlineCallback('settings.getBookmakerPage', ['page' => 1]);

    $this->assertSame(5, (int) $this->user->fresh()->getSettings(UserSettingEnum::FavoriteBookmakerId));
}

The host application is responsible for callController() (or any equivalent thin wrapper that instantiates a controller with a built TelegramRequestDTO) — see the project's tests/Stubs/Telegram/ directory for an example.

License

MIT