Skip to content

Extending ​

How to add bot functionality in a Laravel app using this package.

Directory Structure ​

Create these directories in your app. Since v0.12.0 they are scanned once per worker boot in TelegramBotServiceProvider (not on every request) — see Registering Classes below:

app/Telegram/
├── CallbackQueries/Admin/     # Inline button handlers (admin)
├── CallbackQueries/Member/    # Inline button handlers (member)
├── StateAnswers/Admin/        # Text input handlers (admin)
├── StateAnswers/Member/       # Text input handlers (member)
├── ReplyKeys/Admin/           # Keyboard buttons (admin)
├── ReplyKeys/Member/          # Keyboard buttons (member)
├── Features/Admin/            # Message builders
├── Features/Member/           # Message builders
├── InlineQueries/             # Inline-mode query handlers
├── Commands/Admin/            # Slash commands (admin)
└── Commands/Member/           # Slash commands (member)

Generators ​

bash
php artisan tbe:make:callback-query ProductsQuery --admin
php artisan tbe:make:state-answer ProductsAnswer --admin
php artisan tbe:make:reply-key BuyServiceKey
php artisan tbe:make:feature ProductsFeature --admin
php artisan tbe:make:inline-query SearchProductsQuery
php artisan tbe:make:command StartCommand

Each generator creates a stub in the matching app/Telegram/ subdirectory. --admin puts the class under Admin/ and pre-fills $perm accordingly (default: Member/). The type suffix in the name is stripped, so ProductsQuery and Products both produce ProductsQuery. Pass --all (or the per-type flags -f/-c/-r/-s) to emit the sibling handlers for the same feature in one run.

Typical Feature Flow ​

A complete admin CRUD menu usually involves four class types:

Feature (UI)  ←── builds TelegramResponse with inline buttons
     │
     ▼
CallbackQuery ── handles button presses, may start input flows
     │
     ▼
StateAnswer  ── handles text input while user.state is set
     │
     ▼
Feature (UI)  ←── show result, return to menu

1. Feature — build the UI ​

php
class ProductsFeature
{
    static string $type = 'PRODUCTS';

    public static function menu(): TelegramResponse
    {
        $replyMarkup = Keyboard::make()->inline();
        $replyMarkup->row([
            Keyboard::inlineButton([
                'text'          => 'Add Product',
                'callback_data' => encodeCallback(self::$type, 'add'),
            ]),
        ]);

        return new TelegramResponse(
            text: 'Product Management',
            replyMarkup: $replyMarkup,
        );
    }
}

Use the same $type string in the corresponding CallbackQuery class.

2. CallbackQuery — handle button presses ​

php
class ProductsQuery extends CallbackQuery
{
    protected string $type = 'PRODUCTS';
    protected int $perm = Roles::ADMIN->value;

    public function add(): void
    {
        $stateData = stateData()->store(['flow' => 'add_product']);

        $messageMeta = MessageMeta::makeWithCurrentMessage();
        $messageMeta->lockAction();

        wHook()->user()->changeState(encodeAnswerState($this->type, 'add', [
            'target_field' => 'title',
            'state_data'   => $stateData->id,
            'message_meta' => $messageMeta->id,
        ]));

        wHook()->api()->sendMessage([
            'chat_id'      => wHook()->user()->telegramUser->peer_id,
            'text'         => 'Enter product title:',
            'reply_markup' => wHook()->user()->getKeyboard(),
        ]);
    }

    public function show(Product $product): void
    {
        ProductsFeature::show($product)->update();
    }
}

Notes:

  • $type must match encodeCallback() / encodeAnswerState() first segment
  • Eloquent model params resolve by position in callback data
  • Use ->update() to edit the current inline message, ->send() for a new one

3. StateAnswer — handle text input ​

php
class ProductsAnswer extends StateAnswer
{
    protected string $type = 'PRODUCTS';
    protected int $perm = Roles::ADMIN->value;
    protected array $allowedFields = [AllowableFields::TEXT->value];

    public function add(): void
    {
        $field = $this->params['target_field'];
        $value = wHook()->update()->message->text;

        stateData()->addData($this->stateData(), [$field => $value]);

        $fields = ['title', 'url', 'price'];
        if ($field !== collect($fields)->last()) {
            $next = getNextFromArray($fields, $field);
            wHook()->user()->addParamToState(['target_field' => $next]);
            wHook()->api()->sendMessage([
                'chat_id' => wHook()->user()->telegramUser->peer_id,
                'text'    => "Enter {$next}:",
                'reply_markup' => wHook()->user()->getKeyboard(),
            ]);
            return;
        }

        Product::create($this->stateData()->data);
        $this->stateData()->delete();
        wHook()->user()->changeState();
        $this->messageMeta()->delete();

        ProductsFeature::menu()->send();
    }

    public function cancel(): void
    {
        $this->messageMeta()?->revertAction();
        $this->stateData()?->delete();
    }
}

Notes:

  • $type must match the CallbackQuery that started the flow
  • $allowedFields controls which message types trigger the handler
  • Implement cancel() for cleanup when the user cancels or starts a new flow
  • $this->params contains named state params; $this->stateData() loads the JSON row

Since v0.12.0 — resuming an expired flow

$this->stateData() and $this->messageMeta() return null (they no longer report an exception as a side effect) when the row they point at has been pruned out from under a resumed flow — a user who started a multi-step action, walked away past the pruning window, then sent the awaited message. When the step genuinely cannot continue without one, call $this->requireStateData() / $this->requireMessageMeta() instead: they throw HandlerContextExpired, which ExceptionHandler catches to show the user a "this step expired" notice and clear their stuck state, rather than crashing the worker on a null dereference.

4. ReplyKey — keyboard navigation ​

Register in config/tbe-essence.php:

php
'keyboard' => [
    'admin' => [
        [ProductsKey::class],
    ],
    'member' => [
        [BuyServiceKey::class],
    ],
],
php
class ProductsKey extends ReplyKey
{
    protected int $perm = Roles::ADMIN->value;

    protected function text(): string
    {
        return __('app.telegram.products.reply_key');
    }

    // Optional — the message sent when the button is pressed.
    protected function response(): ?string
    {
        return null;
    }

    public function handle(): void
    {
        ProductsFeature::menu()->send();
    }
}

Since v0.12.0

Handler labels are methods (text(), response(), and description() on Command), not string properties. They call __() directly on every read so a single registered instance renders correctly in whatever locale each bot is in — a stored property would freeze to the locale that was active when the handler was registered. Return a literal __('…') call rather than a key variable so IDE navigation and missing-key inspection keep working.

Registering Classes ​

Place files under the app/Telegram/** subdirectories above. TelegramBotServiceProvider scans them once per worker boot — in a booted() callback, so the app's classes register after essence and every companion package and win any name collision. The underlying calls are:

php
loadCallbackQueries(base_path('app/Telegram/CallbackQueries/Admin'));
loadStateAnswers(base_path('app/Telegram/StateAnswers/Member'));
loadCommands(base_path('app/Telegram/Commands/Admin'));
loadInlineQueries(base_path('app/Telegram/InlineQueries'));

Manual registration (for packages) ​

In a service provider's boot():

php
callbackQueryBus()->addCallbackQueries([
    MyPackageQuery::class,
]);

stateAnswerBus()->addStateAnswers([
    MyPackageAnswer::class,
]);

replyKeyBus()->addReplyKey(MyPackageKey::class);

Callback vs State Encoding ​

CallbackQueriesStateAnswers
EncodingencodeCallback()encodeAnswerState()
ParamsPositional (?42&7)Named (?product_id=42)
Size limit64 bytes (Telegram)None (database)
TriggerInline button pressText while state is set

When callback data would exceed 64 bytes, store the payload in StateData and pass only the ID:

php
// Too large for callback_data
encodeCallback('MYTYPE', 'action', [$stateData->id]);  // just the ID

MessageMeta in Flows ​

Lock the originating message when starting input:

php
$messageMeta = MessageMeta::makeWithCurrentMessage();
$messageMeta->lockAction('Waiting for input...');

Pass message_meta or message_meta_id in state params so the StateAnswer can clean up:

php
$this->messageMeta()->delete();              // remove lock message
$this->messageMeta()->revertAction();        // restore original (cancel)
$this->messageMeta()->updateAndContinueAction($featureResponse);  // show result

Commands ​

Since v0.10.0, /command messages route through essence's own CommandBus, using the same auto-discovered, dependency-injected handler pattern as CallbackQueries and StateAnswers. Commands extend TelegramBotEssentials\Essence\Telegram\Commands\Command:

php
class StartCommand extends Command
{
    protected string $name = 'start';
    protected int $perm = Roles::MEMBER->value;

    protected function description(): ?string
    {
        return __('app.telegram.start.description');
    }

    public function handle(): ?bool
    {
        wHook()->user()->changeState(); // clear state on /start
        // ...

        return true;
    }
}

App commands under app/Telegram/Commands/ are picked up automatically — no registration. A companion package registers its own with commandBus()->addCommands([...]) in boot(). See Commands for aliases, arguments, model binding, and CommandBus::route().

Inline Confirmations ​

For destructive actions, use the built-in confirmation helper:

php
Keyboard::inlineButton([
    'text' => 'Delete',
    'callback_data' => inlineConfirmationKey(
        keyText: 'Delete',
        targetCallbackData: encodeCallback('PRODUCTS', 'delete', [$product->id]),
        backCallbackData: encodeCallback('PRODUCTS', 'show', [$product->id]),
        confirmationText: 'Are you sure?',
    ),
]);

This creates an InlineConfirmation record and routes through InlineConfirmationFeature.

Dependency Injection in Handlers ​

Both CallbackQuery and StateAnswer resolve method parameters automatically:

  • Eloquent models — loaded by ID from params (position for callbacks, name for state answers)
  • Classes — resolved from the container
  • Scalars — from params

Custom column binding (StateAnswer only):

php
protected array $bindings = [
    Product::class => 'product_id',
];

Package Extension Example ​

For a companion package (billing, wallet, etc.):

php
// MyPackageServiceProvider.php
public function boot(): void
{
    callbackQueryBus()->addCallbackQuery(ManageInvoicesQuery::class);
    stateAnswerBus()->addStateAnswer(ManageInvoicesAnswer::class);
}

Place package defaults under src/Telegram/CallbackQueries/ — they load automatically alongside app classes.

Reacting to Bot Activity ​

To run logic when the bot handles an update — without modifying handler classes — register event listeners in boot():

php
use TelegramBotEssentials\Essence\Events\BotDeepLinkReceived;
use TelegramBotEssentials\Essence\Events\BotCallbackQueryHandled;

public function boot(): void
{
    botEventBus()->listen(BotDeepLinkReceived::class, AffiliateListener::class);
    botEventBus()->listen(BotCallbackQueryHandled::class, UserActivityListener::class);
}

See Events & Listeners for all available events and queued listener support.