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

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. thexerc/laravel-bale

ActiveLibrary[API Development](/categories/api)

thexerc/laravel-bale
====================

A Laravel package for building Bale messenger bots and mini apps

V1.1(3w ago)00MITPHPPHP ^8.2

Since May 14Pushed 3w agoCompare

[ Source](https://github.com/TheXERC/laravel-bale)[ Packagist](https://packagist.org/packages/thexerc/laravel-bale)[ RSS](/packages/thexerc-laravel-bale/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (6)Versions (3)Used By (0)

laravel-bale
============

[](#laravel-bale)

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
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
    - [Environment Variables](#environment-variables)
    - [Multiple Bots](#multiple-bots)
- [Receiving Updates](#receiving-updates)
    - [Webhook Mode](#webhook-mode)
    - [Long Polling Mode](#long-polling-mode)
- [Sending Messages](#sending-messages)
    - [Basic Text Message](#basic-text-message)
    - [Parse Mode (Markdown / HTML)](#parse-mode)
    - [Replying to a Message](#replying-to-a-message)
    - [Deleting a Message](#deleting-a-message)
    - [Forwarding a Message](#forwarding-a-message)
- [Keyboards](#keyboards)
    - [Inline Keyboard](#inline-keyboard)
    - [Reply Keyboard](#reply-keyboard)
    - [Remove Keyboard](#remove-keyboard)
- [Sending Media](#sending-media)
    - [Photo](#photo)
    - [Document](#document)
    - [Video](#video)
    - [Audio &amp; Voice](#audio--voice)
    - [Location](#location)
    - [Contact](#contact)
- [File Helpers](#file-helpers)
- [Command &amp; Message Routing](#command--message-routing)
    - [Registering Commands](#registering-commands)
    - [Class-based Handlers](#class-based-handlers)
    - [Message Pattern Matching](#message-pattern-matching)
    - [Callback Query Routing](#callback-query-routing)
- [Laravel Events](#laravel-events)
- [Payments](#payments)
- [Mini Apps](#mini-apps)
    - [Backend: Validating initData](#backend-validating-initdata)
    - [Frontend: JavaScript Helper](#frontend-javascript-helper)
- [Multiple Bots](#multiple-bots-1)
- [Artisan Commands](#artisan-commands)
- [Objects Reference](#objects-reference)
- [Troubleshooting](#troubleshooting)

---

Requirements
------------

[](#requirements)

RequirementVersionPHP≥ 8.1Laravel10 or 11Guzzle≥ 7.0---

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

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

[](#configuration)

### Environment Variables

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

[](#published-config-configbalephp)

```
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,
    ],
];
```

### GitHub Copilot Agent

[](#github-copilot-agent)

The package ships a GitHub Copilot agent that gives Copilot precise, domain-specific context about Bale development. It installs:

- `.github/instructions/laravel-bale.instructions.md` — always-loaded context with a **skill routing table** that tells Copilot exactly which skill to load for each Bale task vs. which Laravel Boost skill to use for general Laravel work.
- `.github/skills/bale-*/SKILL.md` — five on-demand skill files that are loaded only when relevant.

### Setup with Laravel Boost (recommended)

[](#setup-with-laravel-boost-recommended)

If your project uses [Laravel Boost](https://laravel.com/docs/boost), the agent is registered automatically. Run:

```
php artisan boost:install
```

Select **GitHub Copilot (VS Code)** from the agent list. Boost will write all files to `.github/`.

Keep the generated files out of source control (they are regenerated by `boost:install`):

```
.github/instructions/laravel-bale.instructions.md
.github/skills/bale-*/
```

### Standalone setup (without Laravel Boost)

[](#standalone-setup-without-laravel-boost)

```
php artisan bale:copilot-setup
```

This writes the same files without requiring Boost. Commit them to source control so your whole team benefits.

```
# Overwrite existing files without prompting
php artisan bale:copilot-setup --force
```

### How skill routing works

[](#how-skill-routing-works)

Copilot loads the instructions file upfront. It contains a routing table that maps every Bale task to the correct skill and every general Laravel task to a Laravel Boost skill:

TaskSkill loadedCreating a handler, registering commands`/bale-bot-development`Building keyboards (inline / reply)`/bale-bot-development`Mini App initData validation, JS SDK`/bale-miniapp-development`Invoices, pre-checkout, payments`/bale-payments`Sending/receiving photos, videos, files`/bale-media-handling`Banning, restricting, pinning, chat settings`/bale-chat-administration`Writing tests`/pest-testing` (Boost)Livewire / Inertia components`/livewire-development` (Boost)General LaravelLaravel Boost guidelinesCopilot reads this table and loads only the skill relevant to your current task, keeping the context window focused.

### Available skills

[](#available-skills)

SkillActivates when`bale-bot-development`Building handlers, routing updates, keyboards, webhook/polling`bale-miniapp-development`Mini App backend validation, JS SDK, `web_app_data``bale-payments`Invoices, pre-checkout, fulfillment, transactions`bale-media-handling`Sending/receiving photos, videos, documents, albums`bale-chat-administration`Banning, restricting, promoting, pinning, chat settings### Pinning Messages

[](#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 &amp; Photo

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

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

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

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

[](#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](#multiple-bots-1)).

---

Receiving Updates
-----------------

[](#receiving-updates)

### Webhook Mode

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

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

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

[](#basic-text-message)

```
use TheXERC\Bale\Facades\Bale;

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

### Parse Mode

[](#parse-mode)

Bale supports `Markdown` and `HTML` formatting:

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

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

### Replying to a Message

[](#replying-to-a-message)

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

### Deleting a Message

[](#deleting-a-message)

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

### Forwarding a Message

[](#forwarding-a-message)

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

### Editing a Message

[](#editing-a-message)

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

---

Keyboards
---------

[](#keyboards)

### Inline Keyboard

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

[](#remove-keyboard)

```
use TheXERC\Bale\Keyboards\ReplyKeyboard;

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

---

Sending Media
-------------

[](#sending-media)

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

### Photo

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

[](#document)

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

### Video

[](#video)

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

### Audio &amp; Voice

[](#audio--voice)

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

### Location

[](#location)

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

### Contact

[](#contact)

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

### Animation (GIF)

[](#animation-gif)

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

### Media Group (Album)

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

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

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

### Chat Action (Typing Indicator)

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

[](#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 &amp; Message Routing
-----------------------------

[](#command--message-routing)

### Registering Commands

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

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

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

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

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

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

[](#mini-apps)

### Receiving Data from a Mini App

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

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

[](#frontend-javascript-helper)

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

```

```

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`:**

MethodDescription`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
-------------

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

[](#artisan-commands)

### `bale:webhook`

[](#balewebhook)

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`

[](#balepoll)

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`

[](#balemake-handler)

Scaffold a new handler class.

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

---

Objects Reference
-----------------

[](#objects-reference)

ClassKey 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
---------------

[](#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](https://ngrok.com) 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
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance94

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

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

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

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

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

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

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

25d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/67eda7458b1a69d5f8f75bf2f334740178734c0d3a71ee9ab0d0c388c98abad4?d=identicon)[TheXERC](/maintainers/TheXERC)

---

Top Contributors

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

---

Tags

laravelbotMessengerBaleminiapp

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/thexerc-laravel-bale/health.svg)

```
[![Health](https://phpackages.com/badges/thexerc-laravel-bale/health.svg)](https://phpackages.com/packages/thexerc-laravel-bale)
```

###  Alternatives

[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)[spatie/laravel-health

Monitor the health of a Laravel application

87311.3M149](/packages/spatie-laravel-health)[spatie/laravel-export

Create a static site bundle from a Laravel app

670139.5k6](/packages/spatie-laravel-export)[simplestats-io/laravel-client

Analytics for Laravel. Track visitors, registrations, and payments. Discover which channels actually drive revenue, not just traffic. Server-side, GDPR compliant, ad-blocker proof.

5019.3k](/packages/simplestats-io-laravel-client)

PHPackages © 2026

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