Skip to content

Settings ​

Current version: v0.0.17

telegram-bot-essentials/settings (namespace TelegramBotEssentials\Settings) adds a per-bot, typed key/value settings system to Essence. Every other module in the ecosystem (Billing, User Wallet, Affiliates, the gateways) registers its own configurable options through this package rather than inventing its own settings storage.

Installation ​

bash
composer require telegram-bot-essentials/settings
php artisan migrate

The package auto-registers a locale setting out of the box, letting each bot pick its own admin/reply language independently (see Locale switching below).

Core Concepts ​

The Setting DTO ​

A setting is declared once, in a service provider's boot(), as a TelegramBotEssentials\Settings\DTOs\Setting:

php
use TelegramBotEssentials\Settings\DTOs\Setting;
use TelegramBotEssentials\Settings\Enums\SettingType;

settings()->addSetting(new Setting(
    key: 'billing.user_wallet.status',
    label: fn () => __('my-package::settings.wallet_status.label'),
    type: SettingType::CHECKBOX,
    default: false,
));
ParameterTypeNotes
keystringDot-namespaced identifier, e.g. billing.user_wallet.currency
labelstring|ClosureResolved lazily — use a closure to defer translation lookups
typeSettingTypeControls validation, storage, and the admin UI widget
defaultmixedReturned when no BotSetting row exists yet
optionsarray|Closure|nullRequired for SELECT, ENUM, MULTISELECT
descriptionstring|Closure|nullShown as a sub-line under the setting in the parent menu (directory settings), as the submenu header, and above text/select/multiselect prompts
beforeSet?Closurefn (mixed $value, mixed $oldValue, Setting $setting) => mixed — runs after validation, before save; can transform the value or reject it by throwing
afterSet?Closurefn (mixed $newValue, mixed $oldValue, Setting $setting) => void — runs after save; failures are caught, logged via tbeLog('settings'), and do not roll back the write
rulesstring|array|Closure|nullExtra Laravel validation rules, merged additively on top of the type-inferred base rule (see SettingType table)

SettingType ​

src/Enums/SettingType.php defines the supported widget/storage types:

TypeStorageValidation
TEXTraw stringnullable|string
NUMBERraw stringnullable|numeric
CHECKBOXraw string ('1'/'0')nullable|boolean
SENSITIVEencrypted via encrypt()/decrypt()nullable|string
SELECTone of options keysnullable|in:<option keys>
ENUMone of options valuesnullable|in:<option values>
MULTISELECTcomma-joined stringnullable|array
DIRECTORY— (UI grouping only, no dedicated storage branch)—

SENSITIVE is the type to reach for with API keys, gateway secrets, or anything else you don't want sitting in plaintext in the bot_settings table.

The settings() helper ​

Everything is read and written through the global settings() helper (src/helpers.php), which resolves the Settings singleton:

php
// Read (falls back to the Setting's default if no row exists yet)
$locale = settings()->get('locale');
$walletEnabled = settings()->get('billing.user_wallet.status');

// Write (validated against the type's rules before saving)
settings()->set('billing.user_wallet.status', true);

// Introspect a declared setting (for building admin UI)
$setting = settings()->getSetting('billing.user_wallet.status');
$setting->getLabel();
$setting->getTypeEmoji();   // ✅ / ❌ / 🔒 / 🔢 / 💬 / 🧩 / 📋 / 🗃️

// List everything registered, for a settings menu
settings()->getSettings(); // Collection<string, Setting>

get()/set() are always scoped to wHook()->bot()->id — settings are per-bot, not global to the application. This is why other modules read settings inside request-time closures (see How other modules use this) rather than caching the value at boot.

Storage ​

One row per (bot_id, key) pair in bot_settings (model BotSetting, tenant-scoped via BelongsToTenant). Values are always stored as strings/null and cast back to the appropriate shape by Settings::get() based on the setting's declared SettingType.

Hooks and custom validation ​

Settings::set() runs, in order: the type's base validation rule + any rules, beforeSet, the write, then afterSet.

php
settings()->addSetting(new Setting(
    key: 'channel_lock.channel_id',
    label: fn () => __('tbe-settings::bot_settings.channel_lock.channel_id.label'),
    type: SettingType::TEXT,
    beforeSet: function (mixed $value) {
        if (! $value) {
            return $value;
        }

        if (! app(ChannelMembership::class)->isBotAdmin(ltrim($value, '@'))) {
            throw new TbeLogicException(__('tbe-settings::bot_settings.channel_lock.channel_id.bot_not_admin'));
        }

        return $value; // beforeSet can also transform the value before it's saved
    },
));

Throwing from beforeSet (a TbeLogicException, typically) rejects the write — the admin sees the message and the old value stays in place. afterSet is for side effects that shouldn't block the save if they fail (a notification, a cache warm), which is why its exceptions are caught and logged rather than propagated.

Locale switching ​

The package ships a locale setting (SettingType::SELECT) whose options come from LocaleRegistry::selectOptions() — it scans the host app's translation files (via Essence's TranslationScanner) and shows each supported locale with a live translation-completeness percentage, e.g. فارسی 412/420 (98%).

The package rebinds essence's ResolvesBotLocale contract to TbeSettingsBotLocaleResolver, which returns settings()->get('locale') ?? config('app.locale') for a given bot. Essence's own SetBotLocale listener (on BotWebhookInitialized) and its tbe:set-webhook command-menu loop both call that contract, so rebinding it is all this package does — every bot then runs in its own language on both paths, with no per-request wiring in your code.

Channel lock ​

The package also ships a built-in channel_lock setting group (channel_lock directory, channel_lock.status checkbox, channel_lock.channel_id text) that gates every action behind membership in a Telegram channel:

  • LockActionsToChannelUsers, a listener on BotUpdateReceived, checks ChannelMembership::isMember() whenever channel_lock.status is on and blocks the update with a "please join" prompt (with a re-check button) if the user hasn't joined channel_lock.channel_id.
  • ChannelMembership::isMember() caches a positive result for a day per (bot, channel, user) so the check isn't a Telegram API call on every single update.
  • Setting channel_lock.channel_id validates, via beforeSet and ChannelMembership::isBotAdmin(), that the bot is actually an admin of the channel before accepting the value — a bot that isn't a channel admin can't call getChatMember reliably, so a misconfigured channel ID is rejected up front rather than silently locking everyone out.
  • After a user clears the re-check button (ChannelLockQuery), it calls commandBus()->route('start') (see Commands) so the bot's normal /start flow runs immediately — the user doesn't have to retype the command themselves.
  • Since v0.0.15, a still-not-joined re-check is answered as a callback alert (a blocking pop-up) rather than a silent toast, so the user can't miss that the gate is still closed.
  • Since v0.0.21, the lock fails open: when the bot can't check membership (removed from the channel, no longer an admin, the channel is gone) members are let through rather than all being locked out, and the owner and admins get a throttled admin alert with Telegram's error. The lock being on with no channel set alerts them the same way. The lock itself logs nothing per update.

Admin UI ​

The package registers its own CallbackQuery, StateAnswer, ReplyKey, and Feature (Telegram/CallbackQueries/Admin/BotSettingsQuery.php, etc.) that render a generic settings menu from whatever is currently in the registry — you get a working admin settings screen for free as soon as you addSetting() something, with no bespoke UI code required per setting.

How other modules use this ​

This is the pattern used throughout the ecosystem for feature flags and per-bot configuration — declare the setting once in your service provider's boot(), then check it lazily wherever it matters:

php
// TbeUserWalletServiceProvider::boot()
settings()->addSetting(new Setting(
    key: 'billing.user_wallet.status',
    label: fn () => __('tbe-user-wallet::settings.status.label'),
    type: SettingType::CHECKBOX,
    default: false,
));

// Elsewhere, e.g. registering a conditional sort — evaluated per request, not at boot:
botUserSorts()->addSort(new BotUserSort(
    // ...
    active: fn () => settings()->get('billing.user_wallet.status'),
));

Evaluate lazily

Because settings are per-bot and boot() runs once per application (not per bot/tenant), never resolve settings()->get(...) directly inside boot() to decide whether to register something — the bot context isn't available yet. Instead, wrap the check in a Closure (an active callback, a conditional inside a handler method, etc.) so it's evaluated at request time, once wHook()->bot() is resolved.