Back to articles

Refactoring an 800-line WhatsApp chatbot into a state machine in Laravel

September 28, 20267 min read
PHP
Laravel
Architecture
Design Patterns

Our WhatsApp greeting card assistant started out like many features do. We wanted users to build personalized cards over chat, so we wrote an entry-point class to accept inbound webhooks, track user replies in cache, call some actions, and reply with interactive buttons. It worked, but soon CardAssistantService passed 800 lines. The class did too many unrelated jobs, and a subtle type mismatch bug broke card delivery without throwing a visible exception. Here is how we redesigned that service into an isolated state machine with clear boundaries.


The old implementation

The initial service, CardAssistantService, handled the entire lifecycle in one file. Every inbound webhook hit its handle method. The method sanitized the phone number, created the user record if missing, read raw session arrays from Laravel cache, checked for global commands like reset or hi, and routed the request through a giant PHP match expression.

// app/Services/CardBot/CardAssistantService.php (original)

class CardAssistantService
{
    public const STEP_AWAITING_CONTACT = 'awaiting_contact';
    public const STEP_AWAITING_RELATIONSHIP = 'awaiting_relationship';
    public const STEP_AWAITING_OCCASION = 'awaiting_occasion';
    public const STEP_AWAITING_MESSAGE_CHOICE = 'awaiting_message_choice';
    public const STEP_AWAITING_CUSTOM_MESSAGE = 'awaiting_custom_message';
    public const STEP_AWAITING_MESSAGE_APPROVAL = 'awaiting_message_approval';
    public const STEP_AWAITING_CARD_APPROVAL = 'awaiting_card_approval';

    public function __construct(
        protected ResolveContactAction $resolveContactAction,
        protected ResolveRelationshipAction $resolveRelationshipAction,
        protected CreateGreetingCardAction $createGreetingCardAction,
        protected GenerateCardMessageAction $generateCardMessageAction,
        protected GenerateCardImageAction $generateCardImageAction,
        protected SendGreetingCardAction $sendGreetingCardAction,
    ) {}

    public function handle(
        string $senderPhone,
        string $type,
        array|string $payload,
        ?string $replyId = null,
        ?string $replyTitle = null,
        ?string $senderName = null,
    ): void {
        $senderPhone = $this->cleanPhoneNumber($senderPhone);
        $user = $this->resolveSenderUser($senderPhone, $senderName);
        $session = $this->getSession($senderPhone);

        $text = is_string($payload) ? trim($payload) : $replyTitle ?? '';

        if ($type === 'text' && in_array(strtolower($text), [
            'hi', 'hello', 'reset', 'restart', 'cancel', 'menu', 'new card', 'start',
        ])) {
            $this->clearSession($senderPhone);
            $this->sendWelcomeMessage($senderPhone, $user);
            return;
        }

        if (! $session) {
            $this->sendWelcomeMessage($senderPhone, $user);
            return;
        }

        $step = $session['step'] ?? WhatsAppStepsEnum::STEP_AWAITING_CONTACT->value;

        match ($step) {
            WhatsAppStepsEnum::STEP_AWAITING_CONTACT->value => $this->handleAwaitingContact($senderPhone,$user, $session, $type, $payload),
            WhatsAppStepsEnum::STEP_AWAITING_RELATIONSHIP->value => $this-> handleAwaitingRelationship($senderPhone, $user, $session, $text, $replyId),
            WhatsAppStepsEnum::STEP_AWAITING_OCCASION->value => $this->handleAwaitingOccasion($senderPhone,$user, $session, $text, $replyId),
            WhatsAppStepsEnum::STEP_AWAITING_MESSAGE_CHOICE->value => $this-> handleAwaitingMessageChoice($senderPhone, $user, $session, $text, $replyId),
            WhatsAppStepsEnum::STEP_AWAITING_CUSTOM_MESSAGE->value => $this-> handleAwaitingCustomMessage($senderPhone, $user, $session, $text),
            WhatsAppStepsEnum::STEP_AWAITING_MESSAGE_APPROVAL->value => $this-> handleAwaitingMessageApproval($senderPhone, $user, $session, $text, $replyId),
            WhatsAppStepsEnum::STEP_AWAITING_CARD_APPROVAL => $this-> handleAwaitingCardApproval($senderPhone, $user, $session, $text, $replyId),
            default => $this->sendWelcomeMessage($senderPhone, $user),
        };
    }

    // 700 lines of helper methods, step handlers, and API formatting below...
}

Why we had to refactor

1. Single responsibility breakdown

CardAssistantService managed six distinct concerns:

  1. Inbound webhook payload parsing and text normalization.
  2. User model lookup and registration.
  3. Cache serialization and expiration management.
  4. WhatsApp Cloud API formatting, button truncation, and text fallbacks.
  5. Conversation step logic for seven different screens.
  6. Execution and error handling for external actions.

When code mixes HTTP parsing, cache management, and UI building with business rules, changing any single detail risks breaking other steps.

2. Constructor bloat

The class injected six different action dependencies:

public function __construct(
    protected ResolveContactAction $resolveContactAction,
    protected ResolveRelationshipAction $resolveRelationshipAction,
    protected CreateGreetingCardAction $createGreetingCardAction,
    protected GenerateCardMessageAction $generateCardMessageAction,
    protected GenerateCardImageAction $generateCardImageAction,
    protected SendGreetingCardAction $sendGreetingCardAction,
) {}

Every incoming WhatsApp message caused Laravel to instantiate all six actions, even though handling a user typing their sister’s name only required ResolveRelationshipAction.

3. Primitive obsession and unstructured sessions

Session state was an unstructured array stored directly in Redis or cache:

$session['step'] = self::STEP_AWAITING_RELATIONSHIP;
$session['contact_id'] = $contact->id;
$session['recipient_name'] = $contact->name;
$this->saveSession($senderPhone, $session);

Nothing checked whether a key existed before reading it, and typos in array keys were silent.

4. The silent bug

Look closely at the match expression from the old implementation:

WhatsAppStepsEnum::STEP_AWAITING_MESSAGE_APPROVAL->value => $this->handleAwaitingMessageApproval(...),
WhatsAppStepsEnum::STEP_AWAITING_CARD_APPROVAL => $this->handleAwaitingCardApproval(...),

The author used backed enum values for six steps, but forgot ->value on the final one. Because $step was a string like ‘awaiting_card_approval’, comparing it strictly to the enum object failed.

PHP took the default branch and triggered sendWelcomeMessage(). The user thought their card was sent, but the database record stayed in draft. Because tests did not inspect this exact edge case initially, the bug sat undetected in development.


The design patterns we chose

A conversational flow is a finite state machine. A user sits in a specific state, sends input, the system validates that input, performs an action, and transitions the user to the next state.

We combined three patterns to structure the code. ┌─────────────────────────────────────────┐ │ │ │ HandleWhatsAppIncomingEvent │ │ │ └────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ │ │ CardAssistantService (Dispatcher) ├───┬─────────┐ │ │ │ │ └────────────────────┬────────────────────┘ └─────────┼─────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────┐ ┌───────────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ IncomingMessage │ │ SessionRepository │ │ StepRegistry │ │ │ │ │ │ │ └─────────────────────────────────────────┘ └───────────────────┘ └───────┬──────┘ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ ConversationStepInterface (Active Step) ├◄────────────┬─────────────────────┘ │ │ │ └────────────────────┬────────────────────┘ │ │ │ ▼ ▼ ┌─────────────────────────────────────────┐ ┌───────────────────┐ │ │ │ │ │ CardCreationWorkflow ├──►│ CardBotMessenger │ │ │ │ │ └─────────────────────────────────────────┘ └───────────────────┘

The state pattern

Each conversation step became an independent class implementing ConversationStepInterface. Each step injects only the actions it needs.

Data transfer objects and repositories

We isolated data persistence into SessionRepository. We wrapped raw session arrays in ConversationSession, which provides typed setters and getters. We created IncomingMessage to isolate message normalization and reset checks.

Presentation gateway

We moved interactive button formatting, 20-character WhatsApp button limitations, numbered fallbacks, and image preview logic into CardBotMessenger.


The new implementation

1. The step interface

// app/Services/CardBot/Contracts/ConversationStepInterface.php

namespace App\Services\CardBot\Contracts;

use App\Enums\WhatsAppStepsEnum;
use App\Models\User;
use App\Services\CardBot\DTO\ConversationSession;
use App\Services\CardBot\DTO\IncomingMessage;

interface ConversationStepInterface
{
    public function step(): WhatsAppStepsEnum;

    public function handle(
        IncomingMessage $message,
        User $user,
        ConversationSession $session,
    ): void;
}

2. A concrete step

Here is the step that handles recipient selection:

// app/Services/CardBot/Steps/AwaitingContactStep.php

namespace App\Services\CardBot\Steps;

use App\Actions\Cards\CreateGreetingCardAction;
use App\Actions\Contacts\ResolveContactAction;
use App\Enums\WhatsAppStepsEnum;
use App\Models\User;
use App\Services\CardBot\Contracts\ConversationStepInterface;
use App\Services\CardBot\DTO\ConversationSession;
use App\Services\CardBot\DTO\IncomingMessage;
use App\Services\CardBot\Messaging\CardBotMessenger;
use App\Services\CardBot\Repositories\SessionRepository;
use Illuminate\Support\Facades\Log;
use Throwable;

class AwaitingContactStep implements ConversationStepInterface
{
    public function __construct(
        protected ResolveContactAction $resolveContactAction,
        protected CreateGreetingCardAction $createGreetingCardAction,
        protected CardBotMessenger $messenger,
        protected SessionRepository $sessionRepository,
    ) {}

    public function step(): WhatsAppStepsEnum
    {
        return WhatsAppStepsEnum::STEP_AWAITING_CONTACT;
    }

    public function handle(
        IncomingMessage $message,
        User $user,
        ConversationSession $session,
    ): void {
        $senderPhone = $message->senderPhone;

        try {
            $contact = $this->resolveContactAction->execute($user, $message->payload);
            $card = $this->createGreetingCardAction->execute($contact);

            $session->setContactId($contact->id)
                ->setCardId($card->id)
                ->setRecipientName($contact->name)
                ->setRecipientPhone($contact->whatsapp_number ?? $contact->mobile_number);

            $recipientName = $contact->name;
            $contact->loadMissing('relationship');

            if ($contact->relationship) {
                $savedRelationship = $contact->relationship->name;
                $session->setRelationship($savedRelationship)
                    ->setStep(WhatsAppStepsEnum::STEP_AWAITING_OCCASION);
                $this->sessionRepository->save($senderPhone, $session);

                $this->messenger->sendInteractiveButtonsOrText(
                    to: $senderPhone,
                    body: "Awesome! We are creating a card for your *{$savedRelationship}*,_{$recipientName}_.\n\nWhat is the occasion?",
                    buttons: [
                        'occ_birthday' => 'Birthday',
                        'occ_anniversary' => 'Anniversary',
                        'occ_thank_you' => 'Thank You',
                        ],
                        footerText: 'Or reply with "Graduation", "Promotion", etc.',);
                return;
            }

            $session->setStep(WhatsAppStepsEnum::STEP_AWAITING_RELATIONSHIP);
            $this->sessionRepository->save($senderPhone, $session);

            $this->messenger->sendInteractiveButtonsOrText(
                to: $senderPhone,
                body: "Awesome! We are creating a card for *{$recipientName}*.\n\nWhat is your relationship to {$recipientName}?",
                buttons: [
                    'rel_friend' => 'Friend',
                    'rel_partner' => 'Partner',
                    'rel_family' => 'Family',
                    ],
                    footerText: 'Or reply with "Mom", "Colleague", etc.');
                    } catch (Throwable $e) {
                        Log::error('Error processing contact: ' . $e->getMessage(),
                        ['trace' => $e->getTraceAsString()]);

                        $this->messenger->sendTextSafe($senderPhone,"We could not read that contact. Please share a WhatsApp contact card or reply with thename and phone number.",);
                        }
                    }
            }

The class focuses purely on resolving the contact, setting the session, and sending the next prompt. It does not know how sessions are cached or how WhatsApp formats JSON bodies.

3. Step resolution via container

To avoid manually instantiating classes in a switch block, StepRegistry uses Laravel’s service container to resolve steps on demand:

// app/Services/CardBot/StepRegistry.php

namespace App\Services\CardBot;

use App\Enums\WhatsAppStepsEnum;
use App\Services\CardBot\Contracts\ConversationStepInterface;
use App\Services\CardBot\Steps\AwaitingCardApprovalStep;
use App\Services\CardBot\Steps\AwaitingContactStep;
use App\Services\CardBot\Steps\AwaitingCustomMessageStep;
use App\Services\CardBot\Steps\AwaitingMessageApprovalStep;
use App\Services\CardBot\Steps\AwaitingMessageChoiceStep;
use App\Services\CardBot\Steps\AwaitingOccasionStep;
use App\Services\CardBot\Steps\AwaitingRelationshipStep;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;

class StepRegistry
{
    /**
     * @var array<string, class-string<ConversationStepInterface>>
     */
    protected array $steps = [
        WhatsAppStepsEnum::STEP_AWAITING_CONTACT->value => AwaitingContactStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_RELATIONSHIP->value => AwaitingRelationshipStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_OCCASION->value => AwaitingOccasionStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_MESSAGE_CHOICE->value => AwaitingMessageChoiceStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_CUSTOM_MESSAGE->value => AwaitingCustomMessageStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_MESSAGE_APPROVAL->value => AwaitingMessageApprovalStep::class,
        WhatsAppStepsEnum::STEP_AWAITING_CARD_APPROVAL->value => AwaitingCardApprovalStep::class,
    ];

    public function __construct(
        protected Container $container,
    ) {}

    public function get(string|WhatsAppStepsEnum $step): ConversationStepInterface
    {
        $stepValue = $step instanceof WhatsAppStepsEnum ? $step->value : $step;

        if (! isset($this->steps[$stepValue])) {
            throw new InvalidArgumentException("No step handler registered for step: [{$stepValue}]");
        }

        return $this->container->make($this->steps[$stepValue]);
    }
}

Only the step needed for the incoming request gets constructed.

4. The refactored orchestrator

CardAssistantService dropped from 809 lines to roughly 120 lines:

// app/Services/CardBot/CardAssistantService.php

namespace App\Services\CardBot;

use App\Enums\WhatsAppStepsEnum;
use App\Models\User;
use App\Services\CardBot\DTO\IncomingMessage;
use App\Services\CardBot\Messaging\CardBotMessenger;
use App\Services\CardBot\Repositories\SessionRepository;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use InvalidArgumentException;

class CardAssistantService
{
    public function __construct(
        protected SessionRepository $sessionRepository,
        protected CardBotMessenger $messenger,
        protected StepRegistry $stepRegistry,
    ) {}

    public function handle(
        string $senderPhone,
        string $type,
        array|string $payload,
        ?string $replyId = null,
        ?string $replyTitle = null,
        ?string $senderName = null,
    ): void {
        $senderPhone = $this->cleanPhoneNumber($senderPhone);
        $user = $this->resolveSenderUser($senderPhone, $senderName);

        $incomingMessage = new IncomingMessage(
            senderPhone: $senderPhone,
            type: $type,
            payload: $payload,
            replyId: $replyId,
            replyTitle: $replyTitle,
            senderName: $senderName,
        );

        if ($incomingMessage->isGlobalReset()) {
            $this->sessionRepository->clear($senderPhone);
            $this->sendWelcomeMessage($senderPhone, $user);
            return;
        }

        $session = $this->sessionRepository->get($senderPhone);

        if (! $session) {
            $this->sendWelcomeMessage($senderPhone, $user);
            return;
        }

        $step = $session->getStep();

        try {
            $stepHandler = $this->stepRegistry->get($step);
            $stepHandler->handle($incomingMessage, $user, $session);
        } catch (InvalidArgumentException $e) {
            Log::warning("Unhandled step [{$step}], resetting to welcome: {$e->getMessage()}");
            $this->sendWelcomeMessage($senderPhone, $user);
        }
    }

    public function sendWelcomeMessage(string $senderPhone, User $user): void
    {
        $this->saveSession($senderPhone, [
            'step' => WhatsAppStepsEnum::STEP_AWAITING_CONTACT->value,
            'user_id' => $user->id,
        ]);

        $this->messenger->sendWelcome($senderPhone);
    }

    // Small forwarding helpers for session, user lookup, and backwards compatibility...
}

Results and lessons learned

Cleaner test isolation

In the past, testing a change in the occasion step required instantiating the entire service with mocks for six actions.

Now, tests can target AwaitingOccasionStep directly, supplying only ResolveRelationshipAction and the session mock.

Elimination of string constants

We dropped the string constants from the service class and standardized on WhatsAppStepsEnum across both production code and WhatsAppGreetingCardAssistantTest.

Full suite verification

Running Pest confirmed that all 29 tests across the application pass, including the delivery scenarios that had been failing due to the enum comparison bug:

php artisan test --compact
# Output: passed (29 tests, 92 assertions)

Formatting with Laravel Pint brought all new files under the project’s styling rules:

mago fmt && mago lint --fix

When building bots or multi-step wizards, a monolithic service is fine for a weekend prototype. Once you add branching paths, image generation, and third-party APIs, separating the state machine, presentation layer, and persistence keeps the codebase maintainable.