PHP & frameworks

Database & FSM

A practical guide to storing states (FSM) and working with databases in Telegram bots on PHP: table schema, PDO example with telegramApi(), Laravel/Eloquent option, nuances of concurrency and TTL.

When a bot goes beyond simple request-response commands, it becomes necessary to store data: user settings, shopping carts, multi-step scenario progress. This requires databases and a Finite State Machine (FSM). In chapter Первый запрос: getMe, we created the telegramApi() function – here we use it to send messages within FSM handlers.

Why a Database in a Bot

Telegram does not store dialogue context for you. chat_id and user_id are the only stable identifiers (more details in chapter chat_id и user_id). Everything else is your responsibility:

  • User profiles: language, timezone, consent to newsletters.
  • FSM state: which step of the scenario the user is on.
  • Business data: shopping cart, post drafts, selected filters.
  • Logs and analytics: update_id, incoming callback_data, errors.

For small projects, SQLite is sufficient; for production, PostgreSQL or MySQL. In Laravel, use Eloquent; in plain PHP, PDO with prepared statements.

FSM: A Pattern for Multi-Step Scenarios

FSM (Finite State Machine) models a dialogue as a set of states and transitions. Example: order placement – idle → awaiting_address → awaiting_phone → confirming → completed.

Store the current state in the database, linked to user_id (or chat_id for groups). With each update, read the state, decide which handler to call, and write the new state. In chapter Long polling, we created the processUpdate() function – that's where the FSM dispatcher call is integrated.

State Table Schema

CREATE TABLE user_states (
user_id BIGINT PRIMARY KEY,
state VARCHAR(64) NOT NULL DEFAULT 'idle',
payload JSON DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

The payload field (JSON) stores intermediate step data: selected product, entered address, message ID for editing (useful for safeEditMessageText from chapter Редактирование и удаление).

Minimal FSM Handler in PHP (PDO + telegramApi)

We use the telegramApi() function that we created in chapter Первый запрос: getMe. Here we only call it – we don't copy the implementation.

<?php

require 'vendor/autoload.php';

$pdo = new PDO('sqlite:bot.db');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// --- Вспомогательные функции БД ---
function getState(PDO $pdo, int $userId): array {
$stmt = $pdo->prepare('SELECT state, payload FROM user_states WHERE user_id = ?');
$stmt->execute([$userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: ['state' => 'idle', 'payload' => null];
}

function setState(PDO $pdo, int $userId, string $state, ?array $payload = null): void {
$json = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null;
$pdo->prepare(
'INSERT INTO user_states (user_id, state, payload) VALUES (?, ?, ?)'
. ' ON CONFLICT(user_id) DO UPDATE SET state = excluded.state, payload = excluded.payload, updated_at = CURRENT_TIMESTAMP'
)->execute([$userId, $state, $json]);
}

// --- FSM-диспетчер ---
$update = json_decode(file_get_contents('php://input'), true);
if (!$update) exit;

// Удобно смотреть сырые обновления в терминале:
// file_put_contents('php://stderr', print_r($update, true));

$message = $update['message'] ?? ($update['callback_query']['message'] ?? null);
if (!$message) exit;

$userId = $message['from']['id'];
$chatId = $message['chat']['id'];
$text = $message['text'] ?? '';
$callbackData = $update['callback_query']['data'] ?? null;

$current = getState($pdo, $userId);
$payload = $current['payload'] ? json_decode($current['payload'], true) : [];

// Логируем текущее состояние для отладки:
// error_log("FSM: user={$userId} state={$current['state']} payload=" . json_encode($payload));

switch ($current['state']) {
case 'idle':
if ($text === '/order') {
sendMessage($chatId, 'Введите адрес доставки:');
setState($pdo, $userId, 'awaiting_address');
}
break;

case 'awaiting_address':
$payload['address'] = $text;
sendMessage($chatId, "Адрес принят: <b>{$text}</b>. Введите телефон:");
setState($pdo, $userId, 'awaiting_phone', $payload);
break;

case 'awaiting_phone':
$payload['phone'] = $text;
sendMessage($chatId, "Проверьте данные:\n<b>Адрес:</b> {$payload['address']}\n<b>Телефон:</b> {$text}\nПодтвердить?", [
'reply_markup' => json_encode([
'inline_keyboard' => [
[['text' => '✅ Да', 'callback_data' => 'order_confirm']],
[['text' => '❌ Нет', 'callback_data' => 'order_cancel']],
]
])
]);
setState($pdo, $userId, 'confirming', $payload);
break;

case 'confirming':
if ($callbackData === 'order_confirm') {
// Тут сохраняем заказ в БД
setState($pdo, $userId, 'idle');
// Отвечаем на callback_query (убираем "часики"):
telegramApi('answerCallbackQuery', ['callback_query_id' => $update['callback_query']['id']]);
sendMessage($chatId, '✅ Заказ оформлен! Менеджер свяжется с вами.');
} elseif ($callbackData === 'order_cancel') {
setState($pdo, $userId, 'idle');
telegramApi('answerCallbackQuery', ['callback_query_id' => $update['callback_query']['id']]);
sendMessage($chatId, '❌ Отменено.');
}
break;
}

// --- Обёртка sendMessage использует telegramApi() из главы "Первый запрос: getMe" ---
function sendMessage(int $chatId, string $text, array $options = []): array {
return telegramApi('sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML'
], $options));
}

Example of sendMessage Response and How to View It

The telegramApi function returns the result field – a Message object. To see the structure in the terminal:

$sent = sendMessage($chatId, 'Тест');
// В консоли (php script.php) или логах:
var_dump($sent);
// Или красиво:
echo json_encode($sent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
{
"message_id": 123,
"date": 1700000000,
"chat": {
"id": 123456789,
"type": "private",
"first_name": "Ivan"
},
"text": "Тест"
}

What to look for: message_id – needed for subsequent editing/deletion; chat.id – confirms that the message went where it should; date – Unix timestamp of sending.

FSM in Laravel (Eloquent)

In Laravel, it's convenient to structure FSM as a separate service with an Eloquent model UserState. Use updateOrCreate for atomic saving. Extract handlers into separate classes (Action pattern), dispatch by the state field. Middleware can automatically load the state into $request->userState.

We use the TelegramBot service from chapter Laravel for outgoing requests. Here we only call its methods – we don't copy the implementation.

<?php

namespace App\Services;

use App\Models\UserState;
use Illuminate\Support\Facades\Http;

class FsmService
{
public function getState(int $userId): array
{
$record = UserState::find($userId);
return $record ? ['state' => $record->state, 'payload' => $record->payload] : ['state' => 'idle', 'payload' => []];
}

public function setState(int $userId, string $state, array $payload = []): void
{
UserState::updateOrCreate(
['user_id' => $userId],
['state' => $state, 'payload' => $payload]
);
}

public function handle(int $userId, int $chatId, ?string $text, ?string $callbackData): void
{
$current = $this->getState($userId);
$payload = $current['payload'] ?? [];

match ($current['state']) {
'idle' => $text === '/order' && $this->startOrder($chatId, $userId),
'awaiting_address' => $this->saveAddress($chatId, $userId, $text, $payload),
'awaiting_phone' => $this->askConfirm($chatId, $userId, $text, $payload),
'confirming' => $callbackData && $this->processConfirm($chatId, $userId, $callbackData),
default => null,
};
}

private function startOrder(int $chatId, int $userId): void
{
$this->send($chatId, 'Введите адрес доставки:');
$this->setState($userId, 'awaiting_address');
}

private function saveAddress(int $chatId, int $userId, string $text, array $payload): void
{
$payload['address'] = $text;
$this->send($chatId, "Адрес принят: <b>{$text}</b>. Введите телефон:");
$this->setState($userId, 'awaiting_phone', $payload);
}

private function askConfirm(int $chatId, int $userId, string $text, array $payload): void
{
$payload['phone'] = $text;
$this->send($chatId, "Проверьте данные:\n<b>Адрес:</b> {$payload['address']}\n<b>Телефон:</b> {$text}\nПодтвердить?", [
'reply_markup' => json_encode([
'inline_keyboard' => [
[['text' => '✅ Да', 'callback_data' => 'order_confirm']],
[['text' => '❌ Нет', 'callback_data' => 'order_cancel']],
]
])
]);
$this->setState($userId, 'confirming', $payload);
}

private function processConfirm(int $chatId, int $userId, string $callbackData): void
{
if ($callbackData === 'order_confirm') {
// Сохраняем заказ в БД
$this->setState($userId, 'idle');
// Отвечаем на callback_query:
app(\App\Services\TelegramBot::class)->answerCallbackQuery($update['callback_query']['id'] ?? '');
$this->send($chatId, '✅ Заказ оформлен! Менеджер свяжется с вами.');
} elseif ($callbackData === 'order_cancel') {
$this->setState($userId, 'idle');
app(\App\Services\TelegramBot::class)->answerCallbackQuery($update['callback_query']['id'] ?? '');
$this->send($chatId, '❌ Отменено.');
}
}

private function send(int $chatId, string $text, array $options = []): array
{
return app(\App\Services\TelegramBot::class)->sendMessage($chatId, $text, $options);
}
}

Important Nuances

  • Concurrency: a user might send two messages almost simultaneously. Use DB transactions or SELECT ... FOR UPDATE to avoid losing state transitions.
  • State TTL: add a cron job (Laravel Scheduler) to reset stuck states (e.g., older than 24 hours) to idle.
  • Callback buttons: in callback_data, put only the action identifier (order_confirm), not the entire context – the context is already in payload in the database.
  • Debugging: write error_log with user_id, state, payload on each transition – this way you'll quickly find where the scenario broke.

Next: Что такое Mini App – web applications within Telegram with access to user data and native UI.

"