Create a service on the fly on a symfony third party bundle
Tags: symfony, php, bundle
Published: 2023-05-26 16:46:45

When developing a symfony thrid party bundle, we could need to create services based on certain conditions. In this case, we'll have to achieve it using our bundle extension class instead of defining service on the services file.

Let's see a simple example. Imagine we are creating a bundle which can connect to redis using many ways depending on its configuration:

- *Case 1*: Bundle configuration defines host and port
- *Case 2*: Bundle configuration defines uri connection
- *Case 3*: Bundle configuration defines redis sock path

Following the cases we've just defined, we're going to create a service which will connect to redis taking account them. Our service will look like this:

```php
class RedisWrapper {

    public function __construct(
        public readonly Predis\Client $client
    ){ }

    // .......
}
```

### The configuration class

The configuration class defines the bundle configuration parameters using the _Symfony\Component\Config\Definition\Builder\TreeBuilder_ class. It remains under de _DependencyInjection_ folder which must be located in your bundle root dir.

Let's see how it looks like:

```php

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

class MyBundleConfiguration implements ConfigurationInterface
{

    public function getConfigTreeBuilder(): TreeBuilder
    {
        $tb = new TreeBuilder('ict_api_one_endpoint');

        $tb
            ->getRootNode()
            ->children()
            ->scalarNode('host')
                ->defaultNull()
            ->end()
            ->scalarNode('port')
                ->defaultValue(6379)
            ->end()
            ->scalarNode('uri')
                ->defaultNull()
            ->end()
            ->scalarNode('sock_path')
                ->defaultNull()
            ->end()
        ;
        return $tb;
   }
}
```
Method _getConfigTreeBuilder_ returns the bundle configuration as a _TreeBuilder_ object. As we can see, all parameters are optional. The configuration for each of the cases would be the following:

#### Case 1
```yaml
my_bundle_name:
   host: 195.230.65.145
   port: 6379
```

#### Case 2
```yaml
my_bundle_name:
   uri: 'tcp://195.230.65.145:6379'
```

#### Case 3
```yaml
my_bundle_name:
   sock_path: '/path/to/my.sock'
```

You can explore more about configurations [here](https://symfony.com/doc/current/components/config/definition.html)

Now, its time to create the service according to the last possible configs.

### The extension file

The extension file loads the services and parameters which bundle exposes to projects where it is installed. As configuration class, extension class also remains under _DependencyInjection_ folder.

Normally it starts by loading the container using the bundle services file (normally located under _Resources/config_ folder) and then loading the configuration according to the configured parameters values.

Let's see how it looks like

```php
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;

class IctApiOneEndpointExtension extends Extension
{

    /**
     * @throws \Exception
     */
    public function load(array $configs, ContainerBuilder $container): void
    {
        $loader = new XmlFileLoader($container, new FileLocator(dirname(__DIR__).'/Resources/config'));
        $loader->load('services.xml');

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

        if(!empty($config['host']) && !empty($config['port'])) {
            $container
                ->register('my.redis_client', Predis\Client::class)
                ->addArgument(['scheme' => 'tcp', 'host' => $config['host'], 'port' => $config['port']])
            ;
        }
        else if(!empty($config['uri'])) {
            $container
                ->register('my.redis_client', Predis\Client::class)
                ->addArgument($config['uri'])
            ;
        }
        else{
            if(empty($config['sock_path'])) {
                throw new \RuntimeException('Missing arguments for loading bundle');
            }

            $container
                ->register('my.redis_client', Predis\Client::class)
                ->addArgument(['scheme' => 'unix', 'host' => $config['sock_path']])
            ;
        }

        $container
            ->register('my.redis_wrapper', RedisWrapper::class)
            ->addArgument(new Reference('my.redis_client'))
        ;

    }
}
```

Let's explore extension code step by step:

```php
$loader = new XmlFileLoader($container, new FileLocator(dirname(__DIR__).'/Resources/config'));
$loader->load('services.xml');

$configuration = new IctApiOneEndpointConfiguration();
$config = $this->processConfiguration($configuration, $configs);
```
This is as all extension classes usually start. It simply loads container from xml or yaml services file. Then, it processes bundle configuration from the project installer configured values.

```php
if(!empty($config['host']) && !empty($config['port'])) {
   $container
      ->register('my.redis_client', Predis\Client::class)
      ->addArgument(['scheme' => 'tcp', 'host' => $config['host'], 'port' => $config['port']])
   ;
}
```

If _host_ and _port_ parameters are not empty, it registers a service from _Predis\Client_ class passing as an argument an array with:

- *scheme*: Normally tcp
- *host*: Value of host configuration parameter
- *port*: Value of port configuration parameter

```php
else if(!empty($config['uri'])) {
    $container
       ->register('my.redis_client', Predis\Client::class)
       ->addArgument($config['uri'])
    ;
}
```

If _uri_ parameter is not empty, then we register the same service but passing as an argument the redis uri connection.

```php
else{
    if(empty($config['sock_path'])) {
        throw new \RuntimeException('Missing arguments for loading bundle');
    }

    $container
       ->register('my.redis_client', Predis\Client::class)
       ->addArgument(['scheme' => 'unix', 'host' => $config['sock_path']])
    ;
}
```
As last option, if _sock_path_ parameter is empty a _\RuntimeExeption_ is thrown since there are no more options by which register the connection. Otherwise, we register the service but passing as an argument an array with:

- *scheme*: unix
- *host*: _sock_path_ value

```php
$container
    ->register('my.redis_wrapper', RedisWrapper::class)
    ->addArgument(new Reference('my.redis_client'))
;
```

Finally, we register the _RedisWrapper_ service adding as an argument a _Reference_ to _my.redis_client_

Thus, we allow developers to configure this bundle giving then 3 options:

- Using a host and a port
- Using a uri connection
- Using a sock path

> If you read [predis docs](https://github.com/predis/predis) you will see at "_Connecting to Redis_" section that _Predis\Client_ class can accept as an argument the three cases we've just seen in this post.

You can see a similar case in my [secrets bundle](https://github.com/icolomina/secrets)

> If you enjoy my content and like the Symfony framework, consider reading my book: [Building an Operation-Orie8nted Api using PHP and the Symfony Framework: A step-by-step guide](https://amzn.eu/d/3eO1DDi)