Appearance
State & StateData
Two separate mechanisms work together for multi-step input flows. They are not the same thing.
bot_users.state | StateData | |
|---|---|---|
| Answers | "Which handler runs on the next message?" | "What data have we collected?" |
| Storage | Column on bot_users | Row in state_data table |
| Format | JSON payload in a TEXT column | JSON object |
| Per user | One active state at a time | Independent rows, referenced by ID |
| Auto-cleared | Yes (changeState(null)) | No — manual delete required |
| Used in deeplinks | No | Yes (/start={id}) |
bot_users.state
Format
The state is a JSON payload — {"t": type, "m": method, "p": params} — stored in a TEXT column:
json
{"t":"PRODUCTSMANAGEMENT","m":"add","p":{"target_field":"title","state_data":7,"message_meta":42}}Earlier versions stored a 255-character TYPE#method?query string, which percent-encoded non-ASCII text to three times its size and turned every param into a string. The 0.13 migration converts states in the old format, so users mid-flow at deploy time carry on; there is no reader for the old format afterwards. Never parse the raw string yourself — go through decodeAnswerState(), and log it with answerStateSummary() (a short type#method) rather than in full.
Built with encodeAnswerState($type, $method, $params):
php
wHook()->user()->changeState(encodeAnswerState('PRODUCTSMANAGEMENT', 'add', [
'target_field' => 'title',
'state_data' => 7,
'message_meta' => 42,
]));Decoded by decodeAnswerState() into:
php
[
'type' => 'PRODUCTSMANAGEMENT',
'method' => 'add',
'params' => ['target_field' => 'title', 'state_data' => 7, ...], // types are kept
]Lifecycle
| Action | Method | When |
|---|---|---|
| Enter input mode | changeState($encoded) | CallbackQuery starts a flow |
| Update routing params | addParamToState(['key' => 'val']) | Move to next step/field |
| Exit input mode | changeState() or changeState(null) | Flow complete or cancelled |
While state is set:
- Normal keyboard is replaced with Cancel Process
- Incoming text messages route to
StateAnswerBusinstead of ReplyKeys - Commands cancel the active state first
Source: src/Models/BotUser.php
What belongs in state params
Routing and identifiers:
- Handler routing:
target_field,step - Model IDs:
product_id,order_id,message_meta_id - StateData pointer:
state_dataorstate_data_id
The column is TEXT, so a multi-step flow may keep what it has collected here — the Forms engine does exactly that, and its draft dies with the state when the user cancels. Keep it to what the user is doing right now: state is loaded and rewritten on every message, and nothing else should read it.
StateData
Schema
sql
state_data
├── id (bigint)
├── data (json, nullable)
└── created_at / updated_at (timestamp) -- added in v0.12.0Treat rows as ephemeral session data. Since v0.12.0 the table carries timestamps and essence prunes it automatically (see Cleanup).
Source: database/migrations/2025_07_23_191544_create_state_data_table.php, database/migrations/*_add_timestamps_to_state_data_table.php
API
php
// Create
$row = stateData()->store(['flow' => 'add_product', 'type' => 'tbe']);
// Append (merges, does not replace)
stateData()->addData($row, ['title' => 'My Product']);
// Read
$row->data['title'];
// Cleanup (your responsibility)
$row->delete();Source: src/Services/StateDataService.php, src/Models/StateData.php
Access from StateAnswer
php
$this->stateData()->data['title'];Loads the row whose ID is in $this->params['state_data'] or $this->params['state_data_id'].
Source: src/Telegram/StateAnswers/StateAnswer.php
Patterns
Pattern 1: State only (single-field edit)
No StateData needed. Pass the model ID directly in state params:
php
// CallbackQuery
wHook()->user()->changeState(encodeAnswerState($this->type, 'edit', [
'product_id' => $product->id,
'target_field' => $targetField,
'message_meta_id' => $messageMeta->id,
]));
// StateAnswer
public function edit(Product $product, string $targetField): void
{
$value = wHook()->update()->message->text;
$product->$targetField = $value;
$product->save();
wHook()->user()->changeState();
}Pattern 2: State + StateData (multi-step wizard)
New multi-step flows should use the Forms engine instead: it keeps the draft in the state, adds Back/Next/Skip and a confirm step, and needs none of the plumbing below.
StateDatastays for payloads that must outlive the user's state — a draft a button has to reach after the state was cleared, or a deeplink payload.
Start (CallbackQuery):
php
$stateData = stateData()->store(['type' => $type]);
wHook()->user()->changeState(encodeAnswerState($this->type, 'add', [
'target_field' => 'title',
'state_data' => $stateData->id,
'message_meta' => $messageMeta->id,
]));Each step (StateAnswer):
php
public function add(): void
{
$field = $this->params['target_field'];
$value = wHook()->update()->message->text;
stateData()->addData($this->stateData(), [$field => $value]);
if ($field !== 'last_field') {
wHook()->user()->addParamToState(['target_field' => $nextField]);
// prompt next question...
return;
}
// Finalize from $this->stateData()->data
Product::create([...]);
$this->stateData()->delete();
wHook()->user()->changeState();
$this->messageMeta()->delete();
}Rules:
- One StateData row per wizard — don't create a new row per step
- Advance steps with
addParamToState()— only the pointer in state changes - Delete the row when done
Pattern 3: Mid-flow StateData creation
Create StateData when the first step produces a value needed later:
php
case 'telegram_id':
$tgUser = TelegramUser::where('peer_id', $value)->firstOrFail();
$stateData = stateData()->store(['telegram_id' => $tgUser->peer_id]);
$params['target_field'] = 'username';
$params['state_data'] = $stateData->id;
wHook()->user()->changeState(encodeAnswerState($this->type, 'add', $params));
break;
case 'username':
$telegramId = $this->stateData()->data['telegram_id'];
// ...Pattern 4: Deeplink / cross-app payload
StateData without setting bot_users.state:
php
// Web app
$stateData = StateData::create([
'data' => ['type' => 'connect', 'token' => $token],
]);
return redirect("https://t.me/{$botUsername}?start={$stateData->id}");php
// StartCommand
$stateData = StateData::findOrFail($argument);
switch ($stateData->data['type']) {
case 'connect': /* ... */ break;
}Pass $stateData->id into callback buttons instead of the token (stays under 64-byte callback limit).
Decision Checklist
Collecting 2+ text inputs in sequence?
→ StateData + bot_users.state with state_data=ID
Passing payload from web/external system?
→ StateData + /start={id} or callback with ID
Single field edit on existing model?
→ bot_users.state only (model ID in params)
One-shot input, save immediately?
→ bot_users.state only, no StateDataCleanup
changeState(null) does not delete StateData rows. Delete them yourself as soon as a flow finishes or is cancelled:
php
$this->stateData()?->delete();Rows you don't get to (the user abandons the flow mid-way) are swept up by essence: since v0.12.0 StateData is Prunable and model:prune runs hourly via routes/console.php, deleting rows older than config('tbe-essence.pruning.state_data_days') (default 7). MessageMeta and InlineConfirmation are pruned the same way.
Resuming after a prune
If a flow resumes against a row that was pruned out from under it — user walked away past the retention window, then sent the awaited message — $this->stateData() / $this->messageMeta() return null. Where the step cannot continue without one, call $this->requireStateData() / $this->requireMessageMeta(): they throw HandlerContextExpired, which ExceptionHandler turns into a "this step expired" notice to the user and clears their stuck state, instead of a null-dereference crash.
Recommended JSON Shape
For new flows, use a consistent structure:
php
[
'flow' => 'add_product', // dispatch key
'variant' => 'tbe', // business subtype
// flat collected fields, or:
'fields' => ['title' => '...'], // nested user input
]This keeps dispatch logic separate from collected values.