Appearance
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 migrateThe 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,
));| Parameter | Type | Notes |
|---|---|---|
key | string | Dot-namespaced identifier, e.g. billing.user_wallet.currency |
label | string|Closure | Resolved lazily — use a closure to defer translation lookups |
type | SettingType | Controls validation, storage, and the admin UI widget |
default | mixed | Returned when no BotSetting row exists yet |
options | array|Closure|null | Required for SELECT, ENUM, MULTISELECT |
description | string|Closure|null | Shown as a sub-line under the setting in the parent menu (directory settings), as the submenu header, and above text/select/multiselect prompts |
beforeSet | ?Closure | fn (mixed $value, mixed $oldValue, Setting $setting) => mixed — runs after validation, before save; can transform the value or reject it by throwing |
afterSet | ?Closure | fn (mixed $newValue, mixed $oldValue, Setting $setting) => void — runs after save; failures are caught, logged via tbeLog('settings'), and do not roll back the write |
rules | string|array|Closure|null | Extra 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:
| Type | Storage | Validation |
|---|---|---|
TEXT | raw string | nullable|string |
NUMBER | raw string | nullable|numeric |
CHECKBOX | raw string ('1'/'0') | nullable|boolean |
SENSITIVE | encrypted via encrypt()/decrypt() | nullable|string |
SELECT | one of options keys | nullable|in:<option keys> |
ENUM | one of options values | nullable|in:<option values> |
MULTISELECT | comma-joined string | nullable|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 onBotUpdateReceived, checksChannelMembership::isMember()wheneverchannel_lock.statusis on and blocks the update with a "please join" prompt (with a re-check button) if the user hasn't joinedchannel_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_idvalidates, viabeforeSetandChannelMembership::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 callgetChatMemberreliably, 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 callscommandBus()->route('start')(see Commands) so the bot's normal/startflow 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.