thexerc/laravel-bale

A Laravel package for building Bale messenger bots and mini apps

Maintainers

Package info

github.com/TheXERC/laravel-bale

pkg:composer/thexerc/laravel-bale

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

V1 2026-05-14 12:17 UTC

This package is auto-updated.

Last update: 2026-05-14 12:26:39 UTC


README

A first-class Laravel package for building Bale messenger bots and Bale Mini Apps. Everything you need in one composer require — bot API client, webhook handling, long polling, command router, Laravel events, payment helpers, file helpers, and a JavaScript SDK for Mini Apps.

Table of Contents

Requirements

Requirement Version
PHP ≥ 8.1
Laravel 10 or 11
Guzzle ≥ 7.0

Installation

composer require TheXERC/laravel-bale

The package auto-discovers itself via Laravel's package auto-discovery. No manual provider registration is needed.

Publish the config file:

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

This creates config/bale.php in your application.

Optionally publish the stubs (so you can customise generated handler classes):

php artisan vendor:publish --tag=bale-stubs

Optionally publish the Mini App JS assets to public/vendor/bale/js/:

php artisan vendor:publish --tag=bale-assets

Configuration

Environment Variables

Add the following to your .env file:

# Your bot token from @BotFather on Bale
BALE_TOKEN=your-bot-token-here

# How your bot receives updates: "webhook" or "polling"
BALE_MODE=webhook

# Optional: a secret string used to verify incoming webhook requests
BALE_WEBHOOK_SECRET=your-random-secret

# Optional: override the route prefix for the webhook URL
# Default: bale/webhook  → https://your-app.com/bale/webhook/{token}
BALE_WEBHOOK_PREFIX=bale/webhook

Published Config (config/bale.php)

return [
    'default' => env('BALE_BOT', 'default'),

    'bots' => [
        'default' => [
            'token'          => env('BALE_TOKEN'),
            'webhook_secret' => env('BALE_WEBHOOK_SECRET'),
            'mode'           => env('BALE_MODE', 'webhook'),
        ],
    ],

    'api_base_url' => 'https://tapi.bale.ai/bot',
    'file_base_url' => 'https://tapi.bale.ai/file/bot',

    'webhook_prefix' => env('BALE_WEBHOOK_PREFIX', 'bale/webhook'),

    'polling' => [
        'timeout' => 30,
        'limit'   => 100,
        'sleep'   => 1,
    ],

    'miniapp' => [
        'auth_route_enabled' => true,
        'init_data_ttl'      => 3600,
    ],
];

Chat Administration

Pinning Messages

// Pin a message (silently, no notification)
Bale::pinChatMessage($chatId, $messageId, disableNotification: true);

// Unpin a specific message
Bale::unpinChatMessage($chatId, $messageId);

// Unpin the most recently pinned message
Bale::unpinChatMessage($chatId);

// Unpin all pinned messages
Bale::unpinAllChatMessages($chatId);

Chat Info & Photo

Bale::setChatTitle($chatId, 'My Awesome Group');
Bale::setChatDescription($chatId, 'The official support group.');

// setChatPhoto requires a local file path — Bale does not accept file_id or URLs here
Bale::setChatPhoto($chatId, '/path/to/photo.jpg');

$admins = Bale::getChatAdministrators($chatId); // ChatMember[]
$count  = Bale::getChatMembersCount($chatId);

Member Permissions

// Mute a user
Bale::restrictChatMember($chatId, $userId, [
    'can_send_messages'       => false,
    'can_send_media_messages' => false,
]);

// Restore permissions
Bale::restrictChatMember($chatId, $userId, [
    'can_send_messages'       => true,
    'can_send_media_messages' => true,
]);

// Temporary restriction (lifts after 1 hour)
Bale::restrictChatMember($chatId, $userId, ['can_send_messages' => false], untilDate: time() + 3600);

// Promote to admin with specific rights
Bale::promoteChatMember($chatId, $userId, [
    'can_delete_messages' => true,
    'can_pin_messages'    => true,
    'can_invite_users'    => true,
]);

// Demote back to member
Bale::promoteChatMember($chatId, $userId, [
    'can_delete_messages' => false,
    'can_pin_messages'    => false,
]);

Invite Links

$link     = Bale::exportChatInviteLink($chatId);

$linkInfo = Bale::createChatInviteLink($chatId, [
    'name'         => 'Summer Campaign',
    'expire_date'  => time() + 86400,
    'member_limit' => 50,
]);

Bale::revokeChatInviteLink($chatId, $linkInfo['invite_link']);

Editing Message Media

Replace the photo, video, document, or animation of an already-sent message:

Bale::editMessageMedia($chatId, $messageId, [
    'type'    => 'photo',
    'media'   => $newFileId,
    'caption' => 'Updated caption',
]);

// Swap media and update the inline keyboard simultaneously
$keyboard = (new InlineKeyboard())->button('Download', callbackData: 'dl:42');
Bale::editMessageMedia($chatId, $messageId, ['type' => 'photo', 'media' => $fileId], $keyboard);

Multiple Bots (named)

// config/bale.php
'bots' => [
    'default' => [
        'token' => env('BALE_TOKEN'),
    ],
    'support' => [
        'token' => env('BALE_SUPPORT_TOKEN'),
    ],
    'shop' => [
        'token'          => env('BALE_SHOP_TOKEN'),
        'webhook_secret' => env('BALE_SHOP_SECRET'),
    ],
],

Access a specific bot by name anywhere with Bale::bot('support') (see Multiple Bots).

Receiving Updates

Webhook Mode

1. Register the webhook (one-time setup):

php artisan bale:webhook set

This automatically constructs the correct URL from APP_URL + your configured prefix + bot token, e.g. https://your-app.com/bale/webhook/your-bot-token.

The package registers the route for you — no changes to routes/web.php or routes/api.php are needed. The CSRF middleware is not applied to this route.

2. Register your handlers in a service provider or AppServiceProvider::boot():

use TheXERC\Bale\Facades\Bale;

public function boot(): void
{
    Bale::onCommand('start', \App\Bale\Handlers\StartHandler::class);
    Bale::onCommand('help',  \App\Bale\Handlers\HelpHandler::class);
}

When Bale sends an update to your webhook URL, the package automatically parses it, fires the appropriate Laravel events, and routes it to your handlers.

Long Polling Mode

If your server is not publicly accessible (e.g. local development), use long polling:

php artisan bale:poll

This command runs indefinitely, fetching updates from Bale and dispatching them through the same router and events as webhook mode. It automatically removes any existing webhook before starting.

Options:

php artisan bale:poll --bot=support --timeout=30 --limit=100 --sleep=1

Production note: For production, always prefer webhook mode. Use long polling only for local development or environments without a public URL.

Sending Messages

All send methods are available on the Bale facade (default bot) or on a specific BotApi instance returned by Bale::bot('name').

Basic Text Message

use TheXERC\Bale\Facades\Bale;

$message = Bale::sendMessage($chatId, 'Hello, world!');

Parse Mode

Bale supports Markdown and HTML formatting:

// Markdown
Bale::sendMessage($chatId, '*Bold* and _italic_', [
    'parse_mode' => 'Markdown',
]);

// HTML
Bale::sendMessage($chatId, '<b>Bold</b> and <i>italic</i>', [
    'parse_mode' => 'HTML',
]);

Replying to a Message

Bale::sendMessage($chatId, 'Got it!', [
    'reply_to_message_id' => $message->messageId,
]);

Deleting a Message

Bale::deleteMessage($chatId, $messageId);

Forwarding a Message

Bale::forwardMessage($toChatId, $fromChatId, $messageId);

Editing a Message

Bale::editMessageText($chatId, $messageId, 'Updated text');

Keyboards

Inline Keyboard

Inline keyboards appear directly below the message.

use TheXERC\Bale\Facades\Bale;
use TheXERC\Bale\Keyboards\InlineKeyboard;
use TheXERC\Bale\Keyboards\InlineKeyboardButton;
use TheXERC\Bale\Objects\CopyTextButton;

$keyboard = (new InlineKeyboard())
    ->row(
        new InlineKeyboardButton('✅ Confirm', callbackData: 'confirm'),
        new InlineKeyboardButton('❌ Cancel',  callbackData: 'cancel'),
    )
    ->row(
        new InlineKeyboardButton('🌐 Visit Website', url: 'https://example.com'),
    )
    ->row(
        // Opens a Mini App
        new InlineKeyboardButton('🚀 Open App', webAppUrl: 'https://your-app.com/miniapp'),
    )
    ->row(
        // Copy text to clipboard when tapped
        new InlineKeyboardButton('📋 Copy Code', copyText: new CopyTextButton('DISCOUNT50')),
    );

Bale::sendMessage($chatId, 'Choose an option:', [
    'reply_markup' => $keyboard,
]);

Reply Keyboard

Reply keyboards replace the user's text input area.

use TheXERC\Bale\Keyboards\ReplyKeyboard;
use TheXERC\Bale\Keyboards\ReplyKeyboardButton;

$keyboard = (new ReplyKeyboard())
    ->row('📦 My Orders', '🔍 Search')
    ->row(
        new ReplyKeyboardButton('📞 Share Contact', requestContact: true),
        new ReplyKeyboardButton('📍 Share Location', requestLocation: true),
    )
    ->row(
        // Open a Mini App directly from a reply keyboard button
        new ReplyKeyboardButton('🚀 Open App', webAppUrl: 'https://your-app.com/miniapp'),
    )
    ->resize()
    ->oneTime();

Bale::sendMessage($chatId, 'What would you like to do?', [
    'reply_markup' => $keyboard,
]);

Remove Keyboard

use TheXERC\Bale\Keyboards\ReplyKeyboard;

Bale::sendMessage($chatId, 'Keyboard removed.', [
    'reply_markup' => ReplyKeyboard::remove(),
]);

Sending Media

All media methods accept the same $options array for additional parameters like caption, reply_markup, reply_to_message_id, etc.

Photo

// By file_id (already on Bale servers — recommended)
Bale::sendPhoto($chatId, 'AgACAgIAAxkBAAI...', ['caption' => 'A nice photo']);

// By local file path (uploaded via multipart)
Bale::sendPhoto($chatId, '/path/to/photo.jpg', ['caption' => 'Uploaded photo']);

// By URL
Bale::sendPhoto($chatId, 'https://example.com/photo.jpg');

Document

Bale::sendDocument($chatId, '/path/to/report.pdf', ['caption' => 'Monthly report']);

Video

Bale::sendVideo($chatId, '/path/to/video.mp4');

Audio & Voice

Bale::sendAudio($chatId, '/path/to/audio.mp3');
Bale::sendVoice($chatId, '/path/to/voice.ogg');

Location

Bale::sendLocation($chatId, latitude: 35.6892, longitude: 51.3890);

Contact

Bale::sendContact($chatId, phoneNumber: '+989123456789', firstName: 'Ali');

Animation (GIF)

Bale::sendAnimation($chatId, '/path/to/animation.gif', ['caption' => 'Look at this!']);

Media Group (Album)

Send 2–10 photos or videos as an album. You can mix file_id, URLs, and local file paths — the package automatically switches to multipart upload when local files are detected:

// Using file_ids (no upload, fastest)
$messages = Bale::sendMediaGroup($chatId, [
    ['type' => 'photo', 'media' => $fileId1, 'caption' => 'First photo'],
    ['type' => 'photo', 'media' => $fileId2],
]);

// Uploading local files (multipart, attach:// handled automatically)
$messages = Bale::sendMediaGroup($chatId, [
    ['type' => 'photo', 'media' => '/path/to/photo1.jpg', 'caption' => 'Uploaded 1'],
    ['type' => 'photo', 'media' => '/path/to/photo2.jpg'],
]);

// Mixed (first local, second already uploaded)
$messages = Bale::sendMediaGroup($chatId, [
    ['type' => 'photo', 'media' => '/path/to/new.jpg'],
    ['type' => 'photo', 'media' => $existingFileId],
]);

Copy Message

Copy a message to another chat without the "Forwarded from" label:

Bale::copyMessage(toChatId: $chatId, fromChatId: $sourceChatId, messageId: $msgId);

Chat Action (Typing Indicator)

Show a status indicator while your bot is processing:

Bale::sendChatAction($chatId, 'typing');
// Other actions: upload_photo | record_video | upload_video |
//                record_voice | upload_voice | upload_document | find_location

File Helpers

// Get a File object containing the file_path
$file = Bale::getFile($fileId);

// Build the download URL
$url = Bale::getFileUrl($file->filePath);

// Download the file to a local path
$localPath = Bale::downloadFile($file, storage_path('app/downloads/myfile.pdf'));

When a user sends a document, you can retrieve and download it like this:

public function handle(Update $update, BotApi $bot): void
{
    $document  = $update->message->document; // Document object
    $file      = $bot->getFile($document->fileId);
    $localPath = $bot->downloadFile($file, storage_path("app/uploads/{$document->fileName}"));

    $bot->sendMessage($update->getChatId(), "File saved: {$document->fileName}");
}

Bale file size limit: Bots can download files up to 20 MB.

Command & Message Routing

Registering Commands

Register handlers in AppServiceProvider::boot() (or any service provider):

use TheXERC\Bale\Facades\Bale;

public function boot(): void
{
    // Class-based handler
    Bale::onCommand('start', \App\Bale\Handlers\StartHandler::class);

    // Closure handler
    Bale::onCommand('ping', function (Update $update, BotApi $bot) {
        $bot->reply($update, 'Pong! 🏓');
    });
}

Class-based Handlers

Generate a handler class with the artisan command:

php artisan bale:make-handler StartHandler

This creates app/Bale/Handlers/StartHandler.php:

namespace App\Bale\Handlers;

use TheXERC\Bale\BotApi;
use TheXERC\Bale\Objects\Update;
use TheXERC\Bale\Router\Handler;

class StartHandler extends Handler
{
    public function handle(Update $update, BotApi $bot): void
    {
        $from = $update->getFrom();

        $bot->sendMessage(
            $update->getChatId(),
            "👋 Hello {$from->getFullName()}!\n\nWelcome to the bot. Use /help to see commands.",
        );
    }
}

Handler classes are resolved out of Laravel's container, so you can type-hint any service in the constructor:

class StartHandler extends Handler
{
    public function __construct(private UserRepository $users) {}

    public function handle(Update $update, BotApi $bot): void
    {
        $from = $update->getFrom();
        $this->users->firstOrCreate(['bale_id' => $from->id], [
            'name' => $from->getFullName(),
        ]);

        $bot->reply($update, 'Welcome back!');
    }
}

Message Pattern Matching

Match plain text messages using a regex pattern:

// Match any message (no pattern)
Bale::onMessage(function (Update $update, BotApi $bot) {
    $bot->reply($update, 'You said: '.$update->message->text);
});

// Match only messages containing a phone number
Bale::onMessage(\App\Bale\Handlers\PhoneHandler::class, '/\+?\d{10,}/');

Callback Query Routing

// Handle all callback queries
Bale::onCallbackQuery(function (Update $update, BotApi $bot) {
    $data = $update->callbackQuery->data;
    $bot->answerCallbackQuery($update->callbackQuery->id, "You clicked: {$data}");
});

// Filter by data prefix
Bale::onCallbackQuery(\App\Bale\Handlers\OrderHandler::class, 'order:');
// Handles queries like "order:42", "order:cancel:5", etc.

Inside a callback handler you can answer the query and optionally edit the original message:

public function handle(Update $update, BotApi $bot): void
{
    $query  = $update->callbackQuery;
    $chatId = $query->message->chat->id;
    $msgId  = $query->message->messageId;

    // Remove the inline keyboard after the click
    $bot->editMessageText($chatId, $msgId, 'Order confirmed ✅');
    $bot->answerCallbackQuery($query->id);
}

Laravel Events

The package fires these events automatically when an update arrives — whether via webhook or long polling. Register listeners in app/Providers/EventServiceProvider.php:

use TheXERC\Bale\Events\UpdateReceived;
use TheXERC\Bale\Events\MessageReceived;
use TheXERC\Bale\Events\EditedMessageReceived;
use TheXERC\Bale\Events\CommandReceived;
use TheXERC\Bale\Events\CallbackQueryReceived;
use TheXERC\Bale\Events\PreCheckoutQueryReceived;
use TheXERC\Bale\Events\PaymentReceived;

protected $listen = [
    UpdateReceived::class            => [App\Listeners\LogAllUpdates::class],
    MessageReceived::class           => [App\Listeners\HandleMessage::class],
    EditedMessageReceived::class     => [App\Listeners\HandleEditedMessage::class],
    CommandReceived::class           => [App\Listeners\HandleCommand::class],
    CallbackQueryReceived::class     => [App\Listeners\HandleCallback::class],
    PreCheckoutQueryReceived::class  => [App\Listeners\ApproveCheckout::class],
    PaymentReceived::class           => [App\Listeners\FulfillOrder::class],
];

You can also handle edited messages directly via the router:

Bale::onEditedMessage(function (Update $update, BotApi $bot) {
    $edited = $update->editedMessage;
    $bot->sendMessage($edited->chat->id, "You edited: {$edited->text}");
});

All event objects expose the $update (raw Update object) and $bot (BotApi instance for the receiving bot).

Example listener:

namespace App\Listeners;

use TheXERC\Bale\Events\PaymentReceived;

class FulfillOrder
{
    public function handle(PaymentReceived $event): void
    {
        $payment = $event->payment;       // SuccessfulPayment object
        $chatId  = $event->update->getChatId();

        // $payment->invoicePayload holds whatever you put in sendInvoice()
        Order::where('payload', $payment->invoicePayload)->fulfill();

        $event->bot->sendMessage($chatId, "✅ Payment received! Thank you.");
    }
}

Payments

Test token: WALLET-TEST-1111111111111111
The test token behaves exactly like a real token but does not transfer real money.

use TheXERC\Bale\Facades\Bale;
use TheXERC\Bale\Payments\LabeledPrice;

// 1. Send an invoice
Bale::sendInvoice(
    chatId:        $chatId,
    title:         'Premium Subscription',
    description:   '30 days of access to all features.',
    payload:       'sub_' . $userId,
    providerToken: env('BALE_PAYMENT_TOKEN'),
    prices:        [
        new LabeledPrice('Subscription', 50_000),
    ],
);

// 2. Handle pre_checkout_query — MUST answer within 10 seconds
Bale::onPreCheckoutQuery(function ($update, $bot) {
    $query = $update->preCheckoutQuery;

    // Validate stock, eligibility, etc.
    $ok = true;

    $bot->answerPreCheckoutQuery($query->id, ok: $ok);
    // Or reject with a reason:
    // $bot->answerPreCheckoutQuery($query->id, ok: false, errorMessage: 'Item out of stock');
});

// 3. Fulfil the order after successful payment (via PaymentReceived event — see above)

Pre-checkout queries also fire a PreCheckoutQueryReceived event, so you can handle them in a listener:

use TheXERC\Bale\Events\PreCheckoutQueryReceived;

class ApproveCheckout
{
    public function handle(PreCheckoutQueryReceived $event): void
    {
        $event->bot->answerPreCheckoutQuery($event->preCheckoutQuery->id, ok: true);
    }
}

Inquire about a transaction (Bale-specific):

// Returns a typed Transaction object — no chat_id needed
$tx = Bale::inquireTransaction('txn_abc123');
// $tx->id | $tx->status | $tx->amount | $tx->currency | $tx->date

Ask a user to review the bot (Bale-specific):

// delay_seconds is required per the Bale API
Bale::askReview(userId: $userId, delaySeconds: 5);
$url = Bale::createInvoiceLink(
    title:         'Premium Plan',
    description:   '30-day access',
    payload:       'plan_premium',
    providerToken: env('BALE_PAYMENT_TOKEN'),
    prices:        [new LabeledPrice('Plan', 50_000)],
);

Mini Apps

Receiving Data from a Mini App

When the Mini App calls Bale.WebApp.sendData(...), the bot receives a message containing a web_app_data field. The package maps this to a WebAppData object:

Bale::onMessage(function (Update $update, BotApi $bot) {
    $webAppData = $update->message->webAppData;

    if ($webAppData) {
        // $webAppData->data        — raw string sent by the Mini App
        // $webAppData->buttonText  — label of the keyboard button that opened the app
        // $webAppData->json()      — decoded array if the data was JSON

        $payload = $webAppData->json();
        $bot->sendMessage($update->getChatId(), "Received order #{$payload['order_id']}");
    }
});

Backend: Validating initData

Every Mini App request from a user carries initData — a signed string you must validate on the server before trusting the user identity.

The package provides a ready-made endpoint at POST /bale/miniapp/auth and a helper class you can call anywhere.

Using the built-in endpoint:

// In your Mini App JS
const res = await fetch('/bale/miniapp/auth', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ initData: Bale.WebApp.initData }),
});
const { valid, user } = await res.json();

Validating manually in a controller:

use TheXERC\Bale\MiniApp\MiniAppHelper;

public function store(Request $request): JsonResponse
{
    $initData = $request->header('X-Bale-Init-Data');
    $token    = config('bale.bots.default.token');

    $result = MiniAppHelper::validate($initData, $token, ttl: 3600);

    if (! $result['valid']) {
        return response()->json(['error' => $result['error']], 401);
    }

    $user = $result['user']; // ['id' => ..., 'first_name' => ..., ...]

    // Proceed with the authenticated user
}

Using the X-Bale-Init-Data header (automatic with BaleApp JS helper):

The JS helper automatically injects X-Bale-Init-Data into every request. In your middleware or controller base class you can validate it once:

// app/Http/Middleware/ValidateBaleUser.php
use TheXERC\Bale\MiniApp\MiniAppHelper;

class ValidateBaleUser
{
    public function handle(Request $request, Closure $next): Response
    {
        $initData = $request->header('X-Bale-Init-Data');
        $result   = MiniAppHelper::validate($initData, config('bale.bots.default.token'));

        if (! $result['valid']) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        // Attach the user to the request for downstream use
        $request->attributes->set('bale_user', $result['user']);

        return $next($request);
    }
}

Frontend: JavaScript Helper

After publishing assets (php artisan vendor:publish --tag=bale-assets), include the script in your Mini App's HTML:

<script src="/vendor/bale/js/bale-miniapp.js"></script>

Or import it as an ES module in your Vite project:

import BaleApp from '/vendor/bale/js/bale-miniapp.js';

Basic usage:

const app = new BaleApp({ baseUrl: '/api' });
await app.ready(); // always await this first

// Authenticate the user (validates initData with your backend)
const user = await app.authenticate();
console.log('Hello,', user.first_name);

// Make API calls — initData is injected automatically
const orders = await app.get('/orders');
await app.post('/orders', { product_id: 42 });

// Show native UI elements
app.expand();
app.showMainButton('Checkout', () => {
    app.setMainButtonLoading(true);
    app.post('/checkout', cart).then(() => app.close());
});

// Send data back to the bot (triggers web_app_data update)
app.sendData({ action: 'order_placed', order_id: 99 });

Full API Reference — BaleApp:

Method Description
new BaleApp({ baseUrl, authEndpoint, headers }) Constructor
await app.ready() Initialize — always call first
await app.authenticate() Validate initData with backend, returns user object
app.user Locally parsed user (not server-validated)
app.initData Raw initData string
await app.get(path, query) GET request to your API
await app.post(path, body) POST request
await app.put(path, body) PUT request
await app.delete(path) DELETE request
app.expand() Expand to full screen
app.close() Close Mini App
app.showMainButton(text, cb) Show bottom action button
app.hideMainButton() Hide action button
app.setMainButtonLoading(bool) Show/hide spinner on action button
app.showBackButton(cb) Show native back button
app.hideBackButton() Hide back button
app.showAlert(msg, cb) Native alert dialog
app.showConfirm(msg, cb) Native confirm dialog
app.sendData(data) Send data string/object back to bot
app.openLink(url, options) Open URL in external/in-app browser
app.openInvoice(url, cb) Open a payment invoice; callback receives status
app.showScanQrPopup(params, cb) Show native QR-code scanner
app.addToHomeScreen() Prompt user to add app to home screen
app.checkHomeScreenStatus(cb) Check home-screen shortcut status
app.requestContact(cb) Request user's phone number / contact
app.askReview() Trigger native bot-review prompt
app.enableClosingConfirmation() Show confirm dialog on app close
app.disableClosingConfirmation() Remove closing confirmation
app.setHeaderColor(color) Set title-bar background color
app.colorScheme 'light' or 'dark'
app.themeParams Theme color object
app.onThemeChanged(cb) Listen for theme changes
app.onViewportChanged(cb) Listen for viewport changes
app.onBackButtonPressed(cb) Listen for native Back button tap
app.onSettingsButtonClicked(cb) Listen for Settings button tap
app.onSettingsButtonPressed(cb) Alias using the Bale docs canonical event name
app.onQrTextReceived(cb) Listen for successful QR scan; receives { data }
app.onScanQrPopupClosed(cb) Listen for QR popup dismiss without scanning
app.onInvoiceClosed(cb) Listen for invoice screen close; receives { url, status }
app.onPopupClosed(cb) Listen for any native popup dismissal; receives { button_id }
app.onEvent(eventType, cb) Generic passthrough to Bale.WebApp.onEvent() for any event name
app.offEvent(eventType, cb) Remove a previously registered event listener
app.showSettingsButton(cb) Show native Settings (gear) button in header
app.hideSettingsButton() Hide Settings button

Multiple Bots

use TheXERC\Bale\Facades\Bale;

// Default bot
Bale::sendMessage($chatId, 'Hello from the default bot');

// Named bot
Bale::bot('support')->sendMessage($chatId, 'Hello from the support bot');

// Register handlers per bot
Bale::bot('shop')->onCommand('start', \App\Bale\Shop\StartHandler::class);
Bale::bot('shop')->onCommand('catalog', \App\Bale\Shop\CatalogHandler::class);

Each bot gets its own independent router and event context. Webhooks are set per bot:

php artisan bale:webhook set --bot=shop
php artisan bale:webhook set --bot=support

Artisan Commands

bale:webhook

Manage the webhook for any bot.

# Set the webhook (auto-constructs URL from APP_URL)
php artisan bale:webhook set

# Set with a custom URL
php artisan bale:webhook set --url=https://my-tunnel.ngrok.io/bale/webhook/TOKEN

# Set for a specific bot
php artisan bale:webhook set --bot=support

# View webhook status
php artisan bale:webhook info

# Delete the webhook
php artisan bale:webhook delete

# Delete and drop all pending updates
php artisan bale:webhook delete --drop-pending

bale:poll

Start long polling (for development).

php artisan bale:poll
php artisan bale:poll --bot=support
php artisan bale:poll --timeout=60 --limit=50

bale:make-handler

Scaffold a new handler class.

php artisan bale:make-handler StartHandler
# Creates app/Bale/Handlers/StartHandler.php

Objects Reference

Class Key Properties
Update updateId, message, editedMessage, callbackQuery, preCheckoutQuery, getChatId(), getFrom()
Message messageId, date, chat, from, text, document, photo, photos, video, audio, voice, animation, sticker, contact, location, caption, webAppData, replyToMessage, successfulPayment, getCommand(), getCommandArgs()
User id, isBot, firstName, lastName, username, languageCode, getFullName()
Chat id, type, title, username, firstName, isPrivate(), isGroup(), isChannel()
CallbackQuery id, from, message, data
PreCheckoutQuery id, from, currency, totalAmount, invoicePayload, shippingOptionId, orderInfo
File fileId, fileUniqueId, fileSize, filePath
Document fileId, fileUniqueId, fileName, mimeType, fileSize, thumbnail
PhotoSize fileId, fileUniqueId, width, height, fileSize
Video fileId, fileUniqueId, width, height, duration, thumbnail, mimeType, fileSize
Audio fileId, fileUniqueId, duration, performer, title, mimeType, fileSize
Voice fileId, fileUniqueId, duration, mimeType, fileSize
Animation fileId, fileUniqueId, width, height, duration, thumbnail, mimeType, fileSize
Sticker fileId, fileUniqueId, width, height, isAnimated, thumbnail, emoji, fileSize
Contact phoneNumber, firstName, lastName, userId
Location latitude, longitude
WebAppData data, buttonText, json()
ChatMember user, status, isAdmin(), isMember(), isCreator()
Invoice title, description, currency, totalAmount
SuccessfulPayment currency, totalAmount, invoicePayload, orderInfo, telegramPaymentChargeId, providerPaymentChargeId
OrderInfo name, phoneNumber, email
WebhookInfo url, pendingUpdateCount, lastErrorMessage, isActive()

Troubleshooting

Webhook returns 401 Unauthorized
Make sure BALE_WEBHOOK_SECRET in your .env matches what you registered with bale:webhook set. If you don't use a secret, leave webhook_secret empty.

Updates not arriving via webhook

  • Your APP_URL must be HTTPS and publicly reachable. Bale supports ports 80, 88, 443, and 8443.
  • For local development use a tunnel tool like ngrok and run php artisan bale:webhook set --url=https://your-tunnel.ngrok.io/bale/webhook/TOKEN.
  • Run php artisan bale:webhook info to see if the webhook URL is correctly registered and check lastErrorMessage.

CSRF errors on the webhook route
The package registers the webhook route outside Laravel's web middleware group, so CSRF verification does not apply. If you have a custom global middleware stack that adds CSRF verification everywhere, exclude the bale/webhook/* path.

Bale.WebApp is not available in the browser console
Your page is being loaded outside the Bale app. The BaleApp JS helper degrades gracefully — API calls still work, but WebApp-specific methods (sendData, showMainButton, etc.) will silently no-op.

initData has expired from MiniAppHelper::validate()
The user opened the Mini App more than miniapp.init_data_ttl seconds ago (default: 1 hour). Ask the user to close and reopen the app. You can raise or disable the TTL in config/bale.php.

Long polling stops after an error
The bale:poll command backs off for 5 seconds after any exception and resumes automatically. Check storage/logs/laravel.log for the error message.

Rate limit errors (429 Too Many Requests)
When Bale rate-limits your bot it returns a ResponseParameters object with retry_after. The package surfaces this on the exception so you can respect it:

use TheXERC\Bale\Exceptions\BaleApiException;

try {
    Bale::sendMessage($chatId, $text);
} catch (BaleApiException $e) {
    if ($e->isRateLimited()) {
        // $e->retryAfter — seconds to wait before retrying
        sleep($e->retryAfter);
        Bale::sendMessage($chatId, $text); // retry
    } elseif ($e->migrateToChatId !== null) {
        // Group was upgraded to supergroup — update stored chat ID
        $newChatId = $e->migrateToChatId;
    } else {
        throw $e;
    }
}

License

MIT — see LICENSE.