thexerc / laravel-bale
A Laravel package for building Bale messenger bots and mini apps
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0
- pestphp/pest: ^2.0
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
- Installation
- Configuration
- Receiving Updates
- Sending Messages
- Keyboards
- Sending Media
- File Helpers
- Command & Message Routing
- Laravel Events
- Payments
- Mini Apps
- Multiple Bots
- Artisan Commands
- Objects Reference
- Troubleshooting
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_URLmust 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 infoto see if the webhook URL is correctly registered and checklastErrorMessage.
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.