Skip to content

State & StateData ​

Two separate mechanisms work together for multi-step input flows. They are not the same thing.

bot_users.stateStateData
Answers"Which handler runs on the next message?""What data have we collected?"
StorageColumn on bot_usersRow in state_data table
FormatJSON payload in a TEXT columnJSON object
Per userOne active state at a timeIndependent rows, referenced by ID
Auto-clearedYes (changeState(null))No — manual delete required
Used in deeplinksNoYes (/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 ​

ActionMethodWhen
Enter input modechangeState($encoded)CallbackQuery starts a flow
Update routing paramsaddParamToState(['key' => 'val'])Move to next step/field
Exit input modechangeState() or changeState(null)Flow complete or cancelled

While state is set:

  • Normal keyboard is replaced with Cancel Process
  • Incoming text messages route to StateAnswerBus instead 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_data or state_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.0

Treat 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. StateData stays 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'];
    // ...

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 StateData

Cleanup ​

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.

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.