If you ever tried to build a chatbot in PHP – chances are you ran into BotMan. It is a framework for building interactive chatbots that support dozens of various chat platforms. I really love its simplicity and great architecture. BotMan was primarily developed to be used with Laravel – but it markets itself as a “framework agnostic” library. I decided to give it a shot for my fairly complex chatbot project in Symfony FLEX.

Conversations and the Service Container

It is a matter of time when you will need access to your service container to create any kind of complex logic. Whether you need it for accessing doctrine or your own services. The problem lies in the way that BotMan works under the hood. This design choice was crucial to make it as easy to use as it is – but creates a bit of a pickle.

In the following example, we have a simplified example that illustrates the problem. To use the Container in our conversation we have to add the ContainerAwareTrait and inject it. This container is then stored in a class property $container.

// ...
class OrderConversation extends Conversation
{
    use ContainerAwareTrait;

    /**
     * @return mixed
     */
    public function run()
    {
        $question = Question::create('Do you want to order?')
            ->callbackId('order_start_selection')
            ->addButton(Button::create('Yes!')->value('yes'));

        $this->ask($question, function (Answer $answer) {
            if ($answer->isInteractiveMessageReply()) {
                $fullMenuLink = $this->container->get('router')->generate('food.menu.full', [], UrlGeneratorInterface::ABSOLUTE_URL);
                $this->say('Read the menu here: '.$fullMenuLink);
            }
        });
    }
}

When BotMan sends the initial question to the user, it also stores the state of the conversation in the cache. This state is used to continue the conversation with the user later on. It basically serializes the whole Conversation object and caches it. Now, our Service Container doesn’t really like to be serialized. It will yell this error at us if we dare to try:

request.CRITICAL: Uncaught PHP Exception Exception: "Serialization of 'Closure' is not allowed"

Serializing the Unserializable

To solve this problem we can use the magical __sleep and __wakeup methods. These run before the object is serialized and after it is unserialized respectively. To resolve our problem, we have to remove the Container reference before we serialize and recreate it when we do the opposite.

Destroying stuff is always easy:

public function __sleep()
{
    $this->setContainer(null);
    return parent::__sleep();
}

However, when we have to get the Container on wakeup – we don’t really have a place where we can get one. Which means we have to boot the kernel and get a new one.

public function __wakeup()
{
    $env = $_SERVER['APP_ENV'] ?? 'dev';
    $debug = (bool) ($_SERVER['APP_DEBUG'] ?? ('prod' !== $env));
    $kernel = new Kernel($env, $debug);
    $kernel->boot();
    $this->setContainer($kernel->getContainer());
}

And that’s it! Now you can eat your cake and have it too.

Cleaner Solution

It is unlikely you will have just a single conversation in your project. This means it would make sense to compile this into a trait so we can reuse this behaviour.

<?php
namespace App\Services\Communication;

use App\Kernel;
use Symfony\Component\DependencyInjection\ContainerAwareTrait;

trait ContainerAwareConversationTrait
{
    use ContainerAwareTrait;

    public function __sleep()
    {
        $this->setContainer(null);
        return parent::__sleep();
    }

    public function __wakeup()
    {
        $env = $_SERVER['APP_ENV'] ?? 'dev';
        $debug = (bool) ($_SERVER['APP_DEBUG'] ?? ('prod' !== $env));
        $kernel = new Kernel($env, $debug);
        $kernel->boot();
        $this->setContainer($kernel->getContainer());
    }
}

All we have to do now is use the ContainerAwareConversationTrait in the Conversation that needs the container and inject it. Since all of my Conversations use the container I opted for this config in my services.yml file.

App\Conversation\:
    resource: '../src/Conversation/*'
    calls:
        - [ setContainer, [ '@service_container' ]]

 

One Comment

  • Andrew Wolder says:

    Hi. I spent 3 days trying to get your example to work and didn’t manage until today. I feel somewhat like an idiot now as the answer was so easy. I am using Botman in a Symfony 3.4 project, and your example is built for laravel. In Symfony 3.4, the trait ContainerAwareConversationTrait class needs to be changed to boot the Symfony 3.4 kernel instead of the laravel kernel. Symfony 3.4 kernel is AppKernel.php and simply use AppKernel; Not sure if you want to add this info to your blog. your example is the most complete example of this topic I have found. Thanks!!

Leave a Reply