Appearance
Forms
A Form collects several answers from a user in a conversation and hands them to your code once they confirm. It replaces hand-rolled wizards (a state answer per step, a half-built database row, cleanup for when the user walks away).
php
namespace App\Telegram\Forms;
class CreateCategoryForm extends Form
{
protected string $type = 'CATEGORY_CREATE';
protected int $perm = Roles::ADMIN->value;
protected string $lang = 'manage_categories.form'; // prefix for this form's texts
public function steps(): array
{
return [
Text::make('title')->rules(['max:60']),
Choice::make('kind')->options(['tbe' => __('...tbe'), 'tbi' => __('...tbi')]),
Text::make('note')->skippable(),
];
}
public function onComplete(array $answers, array $ctx): TelegramResponse
{
$category = Category::create($answers);
return ManageCategoriesFeature::show($category); // what the confirm message turns into
}
}Register it (formRegistry()->addForm(...) in a provider, or drop it in app/Telegram/Forms and it is found), and start it from a callback:
php
CreateCategoryForm::start(['lastPage' => 2]); // ctx: small scalars handed back to onCompleteWhat the user sees
A plain conversation. Nothing is edited or deleted along the way.
- Each step is a new message: a bold label, the question, and a line saying
RequiredorOptionalplus what is expected (derived from the rules, or set with->hint()). The message carries that step's reply keyboard. - The user answers. The bot answers back with what it understood (
Code ➜ SUMMER10, orNote ⏭ Skipped), then asks the next question as a new message. Back,Next,Skip/ClearandCancelare reply-keyboard buttons. Back is hidden on the first step. Going back simply asks the earlier question again, showing the current answer; on a step that already has an answer,Nextkeeps it and moves on, andSkipbecomesClear.- A
Choicewith a fixed array of options shows them as reply-keyboard buttons. AChoicewhose options are a closure (usually from the database) shows inline buttons with paging in a message of its own — a reply keyboard cannot hold a list of unknown length. - Once every required step is answered and only optional ones remain, the keyboard also offers Finish: it skips whatever is left and goes straight to the summary. It appears again when going back through a completed form.
- The last step is a summary with
Confirm. Only Confirm callsonComplete(), so nothing is written for a form the user abandons. Its result is sent as a new message. - The message the form was started from is locked while it runs and handed back afterwards: refreshed to
originalScreen()on completion, unlocked on cancel.
Steps
| Modifier | Effect |
|---|---|
skippable() | The user may leave it empty; the answer is null. |
when(fn ($answers) => bool) | Only ask when the condition holds. Sees the answers given so far. |
dependsOn('type') | If type changes to a different value, this answer is cleared and asked again. |
rules([...]) / rules(fn ($answers) => [...]) | Laravel validation rules; the closure form can depend on earlier answers. |
prompt(string|Closure) | The question, when it can't be a fixed lang line. |
hint('...') / placeholder('...') | Override the derived hint; the grey text in Telegram's input box. |
cast(fn ($raw, $answers) => ...) | Shape the value passed to onComplete(). |
display(fn ($raw) => ...) | How the raw answer reads in prompts and the summary. |
Choice::options(array|Closure), inline(), columns(n) | Options as value => label. |
Text lives under the form's $lang prefix: fields.{step}.prompt (required), fields.{step}.label, lockLabel, summary, finished.
Answers are stored as the raw string the user sent and only cast for onComplete(), so the state stays plain JSON. Free-text steps are capped at forms.max_text_length (1000) characters unless the step sets its own max/size/between.
How answers stay correct
- Filter on read. Answers are never deleted when a step stops applying — they are ignored, and come back if an earlier answer is changed back.
- Dependencies. Changing an answer to a different value clears the answers that declare
dependsOnon it (and, transitively, theirs). Re-sending the same value, or pressingNext, changes nothing. - Safety net. After any change, the later answers are re-validated against the new situation and any that no longer pass are cleared. This catches rules a declared dependency does not (an amount of 500 once the type became a percentage). Before completing, every applicable answer is checked once more.
- When answers are cleared, the next prompt starts with a
⚠ … reset because … changedline.
Where it lives
The form's whole progress is in the user's state ({"t":"FORM","m":"answer","p":{"form", "step", "answers", "options", "ctx", "at", "meta"}}): the step, the answers, your context, a last-activity time and, for an open inline choice, the id of its options message. There is no table and nothing to prune. A form left idle expires after 24 hours ($idleSeconds, null = never), checked on the user's next message.
No editing, no deleting
Telegram cannot edit a message that carries a reply keyboard, and refuses to delete a message older than 48 hours, so a flow that can sit open for days depends on neither. The engine never edits a prompt and never deletes anything, the user's own replies included. The only edits are on inline messages, which Telegram allows: paging through a choice's options, closing them once one is picked (a tap left on one that could not be closed is caught as outdated anyway), and the message the form was started from.
Under the hood
- The state is
FORM, handled byFormAnswer; dynamic-choice taps and paging areFORMcallbacks handled byFormQuery. Every tap is checked against the step the user is actually on, so a stale button answers "outdated". - Back/Next/Skip/Confirm are matched by text inside the form, not registered as reply keys (a registered key would cancel the form). A typed answer that equals a button label is taken as the button; an option label that collides with a control or a reply key throws in debug.
- The keyboard is one-time and re-sent with every prompt, so a button cannot be tapped twice before the next question arrives.
- The keyboard comes from
StateAnswer::keyboard(), whichBotUser::getKeyboard()asks before falling back to the lone Cancel key, andgetAllowedFields()can vary per step. Both hooks are available to any state answer.
Limits
Text and choice steps only; photo and document steps are not built yet. Paging applies to inline (dynamic) choices, not to a fixed reply-keyboard list. There is no edit mode for an existing record yet (start() reserves a $prefill argument for it).
Testing
Drive the form through the real webhook route with the essence test case: post a callback update to start it, then message updates for each answer, and assert on the state (FormState::fromStateString()) and the recorded Telegram calls. The essence test suite (tests/Feature/FormEngineTest.php) and billing's CreateOfferFormTest are worked examples.