Implementing Strategy Pattern with Symfony

Matija_Cerovec.jpg
symfony-and-strategy-pattern.jpg

The strategy design pattern is a behavioral software design pattern that lets you define a family of algorithms, encapsulates them, and selects one from the pool for use during runtime. The algorithms are mutually substitutable.

By using design patterns, you can make your code more flexible, reusable, and maintainable. In this blog, I will show how to use strategy design pattern on Symfony by real-world case. Symfony is one of the most popular PHP web application frameworks and it is widely represented in our company.

1.jpg
UML class diagram for the strategy design pattern

In the above UML(Unified Modeling Language) class diagram, the Context class defines the interface of interest to clients, maintains a reference to one of the Strategy objects and works with this object only via the Strategy interface. The Strategy interface is used by Context to call an algorithm defined by the concrete strategies which make Context independent of how an algorithm is implemented. The concrete strategy classes implement the Strategy interface (encapsulate an algorithm).

Requirements

To not install any additional packages, I will run and test our code in a console application. Run this command to create a new Symfony application.

                                

composer create-project symfony/skeleton my_project_name

I will cover code with few unit tests, so you can install the newest phpunit (it requires PHP >= 7.3):

                                composer require --dev phpunit/phpunit ^9
                            

Subject

Let’s say that you have an application where you want to add a new feature: enable users to add their social networks. You would implement a screen where the user can enter the URL of his social network account and then hit the Save button.

If everything goes well, a new social network would appear on the user’s profile page with logo, type and URL.

3_2022-01-14-151616_muup.png
The list of user’s social networks

In this blog, I will not implement either the UI components or the web controller. I will focus on a service that determines the type of social network based on the provided URL. To keep this example lite, the service will be called from a CLI command ( that command imitates the controller).

I will also add unit tests for the service so I can be sure that it won’t break anything after refactoring.

First solution

Service supports the following social networks: Facebook, LinkedIn and Twitter. For each social network you have (else)if statement which tries to match a URL with a specific pattern and if a match is successful, it returns the type of social network. You can see one if condition, regex pattern and hard-coded return value per social network.

                                class SocialUrlParser
{
    public function getType(string $url): string
    {
        if (preg_match('/(?:(?:http|https):\/\/)?(?:www.)?facebook.com/', $url)) {
            $type = 'facebook';
        } elseif (preg_match('/(?:(?:http|https):\/\/)?(?:www.)?linkedin.com/', $url)) {
            $type = 'linkedin';
        } elseif (preg_match('/(?:(?:http|https):\/\/)?(?:www.)?twitter.com/', $url)) {
            $type = 'twitter';
        } else {
            throw new \RuntimeException('invalid_social_network_url');
        }

        return $type;
    }
}
                            


The command which uses the service is pretty simple: it takes the URL as an argument, delegates work to the service and prints social network type (if it is determined).

                                class DetermineSocialNetworkCommand extends Command
{
    protected static $defaultName = 'app:determine-social-network';

    private $socialUrlParser;

    public function __construct(SocialUrlParser $socialUrlParser)
    {
        parent::__construct();

        $this->socialUrlParser = $socialUrlParser;
    }

    protected function configure(): void
    {
        $this
            ->setDescription('Determines a social network based on entered URL.')
            ->addArgument('url', InputArgument::REQUIRED)
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $url = $input->getArgument('url');

        $type = $this->socialUrlParser->getType($url);

        $output->writeln($type);

        return Command::SUCCESS;
    }
}
                            
output1_2022-01-14-151953_lbgi.png
Output of the command

And here is the test class. You can see two tests, one covers all supported types and the other covers cases when you don’t have a URL match. For the first test, use a cool thing called data provider.

                                final class SocialUrlParserTest extends TestCase
{
    /**
     * @dataProvider socialNetworkProvider
     */
    public function testItReturnsCorrectType(string $url, ?string $expectedType): void
    {
        $parser = new SocialUrlParser();

        $this->assertSame($expectedType, $parser->getType($url));
    }

    public function testItThrowsExceptionForInvalidUrl(): void
    {
        $parser = new SocialUrlParser();

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('invalid_social_network_url');

        $parser->getType('https://q.agency');
    }

    public function socialNetworkProvider(): array
    {
        return [
            ['https://www.facebook.com/teste...', 'facebook'],
            ['https://www.linkedin.com/in/te...', 'linkedin'],
            ['https://twitter.com/tester', 'twitter'],
        ];
    }
}
                            

Let’s run tests.

output3_2022-01-14-152534_okxw.png
Result: all tests passed

Although the service does its work I am not quite satisfied. I don’t like those if-else blocks, hard-coded regex patterns and return values.

Service knows about the details of each social network and every time a new social network is added, you need to change the service and make it bigger and messier with extra “elseif” block. Let’s improve service with the use of a strategy design pattern.

Using Strategy

First, let’s define the Strategy interface. getRegexRule() method will provide a regex pattern specific to a particular social network while getName() returns a type of social network.

                                

interface SocialNetworkRule

{

public function getRegexRule(): string;

public function getName(): string;

}

Then you have concrete strategies: one class per social network. Here is an example for “Facebook”.

                                final class Facebook implements SocialNetworkRule
{
    public const NAME = 'facebook';

    public function getRegexRule(): string
    {
        return '/(?:(?:http|https):\/\/)?(?:www.)?facebook.com/';
    }

    public function getName(): string
    {
        return self::NAME;
    }
}
                            

You can add classes for other social networks in the same way - they only differ in regex pattern and value of NAME constant. Now refactor the SocialUrlParser service (the Context class in terms of the design pattern):

                                final class SocialUrlParser
{
    private $socialNetworks = [];

    public function setSocialNetworks(SocialNetworkRule ...$socialNetworks): void
    {
        $this->socialNetworks = $socialNetworks;
    }

    public function getType(string $url): string
    {
        /** @var SocialNetworkRule $socialNetwork */
        foreach ($this->socialNetworks as $socialNetwork) {
            $regex = $socialNetwork->getRegexRule();
            if (preg_match($regex, $url)) {
                return $socialNetwork->getName();
            }
        }

        throw new \RuntimeException('invalid_social_network_url');
    }
}
                            

The Context provides a setter and a way to choose one of the concrete strategies based on a match between their regex pattern and given URL. After that, work is delegated to the Strategy object:

Let’s add following code into services.yaml file:

                                App\Service\SocialUrlParser:
        calls:
            - method: setSocialNetworks
              arguments:
                  - '@App\Service\Facebook'
                  - '@App\Service\Twitter'
                  - '@App\Service\Linkedin'
                            

You can use setter injection to inject concrete strategies into our Context class. The good news is that you don’t need to change our command. Let’s check if it works.

output2_2022-01-14-153543_xkgq.png
Output of the command is the same as before

Now re-run tests.

output4_2022-01-14-153729_jgzp.png
Result - failed tests

Oops, three tests failed. It is because I didn’t inject any Strategy to SocialUrlParser (socialNetworks property is an empty array).

Let’s fix that:

                                final class SocialUrlParserTest extends TestCase
{
    /**
     * @dataProvider socialNetworkProvider
     */
    public function testItReturnsCorrectType(string $url, ?string $expectedType): void
    {
        $parser = new SocialUrlParser();
        $socialNetworks = [
            new Facebook(),
            new Linkedin(),
            new Twitter(),
        ];
        $parser->setSocialNetworks(...$socialNetworks);

        $this->assertSame($expectedType, $parser->getType($url));
    }

    public function testItThrowsExceptionForInvalidUrl(): void
    {
        $parser = new SocialUrlParser();
        $socialNetworks = [
            new Facebook(),
            new Linkedin(),
            new Twitter(),
        ];
        $parser->setSocialNetworks(...$socialNetworks);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('invalid_social_network_url');

        $parser->getType('https://q.agency');
    }

    public function socialNetworkProvider(): array
    {
        return [
            ['https://www.facebook.com/teste...', Facebook::NAME],
            ['https://www.linkedin.com/in/te...', Linkedin::NAME],
            ['https://twitter.com/tester', Twitter::NAME],
        ];
    }
}
                            
output5_2022-01-14-154102_qmfm.png
Result - now all tests are “green” again

Final words

Concrete strategies in this example are not so complex (they only return a type of social network) but you could have different variants of an algorithm where each of them requires a couple of specific services and without using a strategy pattern you would probably end up with one huge service with a lot of dependencies.

Any change to one of the algorithms would affect that service, increasing the chance of creating bugs. In such a case, I would recommend thinking about the strategy pattern.