At my company, CodeBuds, we make use of the Mattermost messaging platform to communicate. This is a very nice open source system that allows us to have it on our own server without any limits, unlike Slack.

We figured it would be nice to be able to have our Symfony apps easily communicate with us on the platform. So we ended up making a Symfony Bundle that allows you to make this happen.

In this post I will get into detail about how the bundle was made and why we chose some of the things we did.

Requirements

The requirements for this bundle are the following:

  • php: ^7.4
  • symfony/http-client: 5.0.*

We chose to only be compatible with the latest version on PHP, 7.4, to get used to some of its new functionality.

We could have not used the symfony/http-client but as this will be a symfony bundle and it makes things more secure and easier to get working we used that for our HTTP requests.

Seeing as we use the 5.0.* Symfony package this bundle will only be compatible with the latest Symfony version.

How to use it

In order to get the bundle to work you need to download it with the following composer command : composer require codebuds/mattermost-publication-bundle

If you use Symfony flex this will add the following line in your .env file :

###> codebuds/mattermost-publication###
MATTERMOST_WEBHOOK_URL="http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx"
###< codebuds/mattermost-publication###

And create a config/packages/mattermost_publication.yaml file

mattermost_publication:
    webhook_url: '%env(MATTERMOST_WEBHOOK_URL)%'
    # You can also set the default channel, username and icon_url
    # for more information you can checkout the bundle documentation
    # https://github.com/codebuds33/mattermost-publication-bundle/blob/master/README.md

If you do not use the flex recipes you will have to add this by hand. If you want to quickly check out how to use it you can find it in the README file. If you want to know more keep reading and we will get closer to the source ;)

Code

the code is publicly available on the bundles github page

Configuration

Let's dive into the code. First off for the Symfony bundles we need to add a services.xml configuration file inside the Resources/config directory.

<?xml version="1.0" encoding="UTF-8" ?>

<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <service id="codebuds_mattermost_publication.mattermost_publication"
                 class="CodeBuds\MattermostPublicationBundle\MattermostPublication" public="true"/>
        <service id="CodeBuds\MattermostPublicationBundle\MattermostPublication"
                 alias="codebuds_mattermost_publication.mattermost_publication" public="true"/>
    </services>
</container>

This looks weird at first but if you look closely you can see that the <service> lines are the reverse of one another. This will allow Symfony to autowire this bundle when it is used.

Then we need to add some configurable variables with some default values, in order to do this we need to create two PHP files inside the DependencyInjection directory.

# DependencyInjection/Configuration.php
<?php

namespace CodeBuds\MattermostPublicationBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder("mattermost_publication");
        $rootNode = $treeBuilder->getRootNode();

        $rootNode
            ->children()
                ->scalarNode("webhook_url")->defaultValue("http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx")->end()
                ->scalarNode("username")->defaultNull()->end()
                ->scalarNode("icon_url")->defaultNull()->end()
                ->scalarNode("channel")->defaultNull()->end()
            ->end();

        return $treeBuilder;
    }
}

To publish to Mattermost you need to create a incoming webhook inside of it :

Without this it will be impossible to send a message to it. The next three are optional and allow you to modify the bot that will publish (username and icon_url) or publish to a different channel inside the Mattermost team for which the incoming webhook has been set.

The next thing is to make sure these variables can be used within the bundle. To do this we need to create a DependencyInjection\Extension.

<?php

namespace CodeBuds\MattermostPublicationBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;

class MattermostPublicationExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
        try {
            $loader->load('services.xml');
        } catch (\Exception $exception) {
            var_dump($exception);
        }

        $configuration = $this->getConfiguration($configs, $container);

        $config = $this->processConfiguration($configuration, $configs);

        $definition = $container->getDefinition("codebuds_mattermost_publication.mattermost_publication");
        $definition->setArgument(0, $config['webhook_url']);
        $definition->setArgument(1, $config['username']);
        $definition->setArgument(2, $config['icon_url']);
        $definition->setArgument(3, $config['channel']);
    }
}

Here it will look for the XML configuration file and add a "definition". In order to do this it will look for the definition of codebuds_mattermost_publication.mattermost_publication, which is the id of our CodeBuds\MattermostPublicationBundle\MattermostPublication class, and add the variables to it.

This might seem like quite a few steps to take without having anything that actually does something but it will help us later on. Let's take a look at how to make things happen !

Message

The job of this bundle will be to publish a message so let's see what it is first in the Model directory

# Model/message.php
<?php

namespace CodeBuds\MattermostPublicationBundle\Model;

class Message
{
    private ?string $webhookUrl;

    private ?string $text;

    private ?string $username;

    private ?string $iconUrl;

    private ?string $channel;

    public function __construct()
    {
        $this->webhookUrl = null;
        $this->text = null;
        $this->username = null;
        $this->iconUrl = null;
        $this->channel = null;
    }

    # all the getters and setters ...

    public function toArray()
    {
        $array = [
            'webhook_url' => $this->getText(),
            'text' => $this->getText(),
            'username' => $this->getUsername(),
            'icon_url' => $this->getIconUrl(),
            'channel' => $this->getChannel()
        ];

        return array_filter($array, fn($var) => $var !== null);
    }
}

It takes the four variables we talked about earlier to customize the address we want to send our letter to and choose the postman that we want to deliver the message for us. It also has another variable, text, which of course is the content of the message we want to send.

As you can see it also has a toArray function that contains an array filter as the return. This filter will make sure that the array will not contain any variables that do not have a value. This is our message now let's see how to publish it !

Action

For MattermostPublication.php file that is the Controller of the bundle, let's start by the constructor and how it takes the configurable variables we just talked about.

private ?string $webhookUrl;

private ?string $username;

private ?string $iconUrl;

private ?string $channel;

public function __construct(?string $webhookUrl, ?string $username, ?string $iconUrl, ?string $channel)
{
    $this->webhookUrl = $webhookUrl;
    $this->username = $username;
    $this->iconUrl = $iconUrl;
    $this->channel = $channel;
}

Here we stated that when the class is created it will take 4 variables that are either null or a string. This might seem weird for the $webhookUrl because without a URL no message can be sent. We will look at that in a minute. Now that the postman is here let's see how he gets his job done.

/**
* @param Message|string $message
* @return void
* @throws TransportExceptionInterface
*/
public function publish($message)
{
    $message instanceof Message
        ?: $message = (new Message())->setText($message);

    $message->setIconUrl($message->getIconUrl() ?? $this->iconUrl);
    $message->setUsername($message->getUsername() ?? $this->username);
    $message->setChannel($message->getChannel() ?? $this->channel);
    $message->setWebhookUrl($message->getWebhookUrl() ?? $this->webhookUrl);

    $this->publishRequest($message);
}

The publish function will either take a string or the Message Model. This is to make it easier to publish a simple message without having to put it together on your end. So if someone decides to do this we create the message for him. In order to do this we check if the parameter is an actual Message model and thanks to ?: if this is not the case we will create one with the string that was passed to the function.

Then we want to add everything we need to configure the Message, inside the setters we use the ?? ternary operator to use the value that was on the Message if it exists, if not, use the default value that was set in the constructor. Once our message is ready to be sent we pass it to the next function, publishRequest.

/**
* @param Message $message
* @return void
* @throws TransportExceptionInterface
* @throws Exception
*/
private function publishRequest(Message $message)
{
    if ($message->getWebhookUrl() === null) {
        $errors[] = "No webhook URL set for message";
    }

    if ($message->getText() === null) {
        $errors[] = "No text set for message";
    }

    if (isset($errors)) {
        $message = array_reduce($errors, function ($carry, $error) {
            $carry .= "Error: {$error} ";
            return $carry;
        });
        throw new Exception($message);
    }

    $request = HttpClient::create()
        ->request(
        'POST',
        $message->getWebhookUrl(),
        [
            'headers' =>
            [
                'Content-Type' => 'application/json',
            ],
            'json' => $message->toArray()
        ]
    );

    if ($request->getStatusCode() !== 200) {
        throw new Exception("Publication failed, verify the channel and the settings for the webhook");
    };
}

First of we will check if the message has a place to go to and something to say inside of it. If this is not the case we will create errors. We will then check if any errors were set and if this is the case make sure to stop trying to publish and inform the person that his message wasn't complete by throwing an Exception.

If there is enough information to try and send the message we will use the Symfony HttpClient to do so. Here is where the message toArray function is used to make sure only the fields that contain information will be sent to the Mattermost server. If everything goes well the Mattermost server will reply 200. If this is not the case it can be because the incoming webhooks URL was wrong or because we tried to send it to a channel we are not allowed to go to. If that happens we will throw another Exception to inform the sender that something went wrong.

This is it for how the bundle works and why we made it the way it is. To make the configuration a bit faster and easier we have also added a Symfony Flex recipe, let's look at how that is made (spoiler there is not much to see ;) )

Flex Recipes

Symfony Flex recipes will handle a lot of the things that used to have to be done manually when adding a composer package to your project. It can create new files (like package configuration files) or add to existing ones (like the default .env file) for example. There are many official recipes you can take a look at on the public GitHub the contains them and for your own bundles you can clone the recipes contrib repository. We will not get too much into detail about the recipes on this post but take a look at what we have added for this bundle.

Adding your directory

When you clone the recipes-contrib repository you will see many directories, at the root we will first off all add a directory with our Packagist username codebuds. then we need the name of our bundle mattermost-publication-bundle and the version of the bundle the recipe is for 0.3.

codebuds/
 ├── mattermost-publication-bundle/
 │   ├── 0.3/

This is where we will put all the files (and directories) we need. First of a manifest.json file that contains the information about our bundle.

{
    "bundles": {
        "CodeBuds\\MattermostPublicationBundle\\MattermostPublicationBundle": ["all"]
    },
    "copy-from-recipe": {
        "config/": "%CONFIG_DIR%/"
    },
    "env": {
        "MATTERMOST_WEBHOOK_URL": "http://{your-mattermost-site}/hooks/xxx-generatedkey-xxx"
    }
}

The bundles bit is just information about the bundle that this recipe is related to. Next is copy-from-recipe this tells the recipe from which directory from within it you want to copy files to the Symfony project into which your bundle is installed and where to copy them to. Finally the env part allows you to add some default information in the global .env file of the project.

Adding the default configuration file

So here we can see we want to copy files from config/ to the projects %CONFIG_DIR%/. So to auto generate a default configuration for the packages we will add the config/packages/mattermost_publication.yaml file to our recipe :

mattermost_publication:
    webhook_url: '%env(MATTERMOST_WEBHOOK_URL)%'
    # You can also set the default channel, username and icon_url
    # for more information you can checkout the bundle documentation
    # https://github.com/codebuds33/mattermost-publication-bundle/blob/master/README.md

And that is all ! Now you can make a pull request from your forked repository and if it gets accepted the next person to download your bundle into his project will have these default files automatically added !