How to create service bundles for a Symfony application
By: Diego Macrini
Tags: PHP, Web Development, Symfony, Backend Development, Backend

How to create service bundles for a Symfony application

Creating a module for a PHP backend application might be easier than you think. This step-by-step guide shows how to do it. Diego Macrini Jan 4·17 min read

This article presents easy-to-follow steps to create a module for PHP apps. The module will be a collection of services that can be added to any backend app project built on the Symfony framework. We’ll keep the framework dependencies to a minimum and ensure that the services work without it.

The article is the first in a series about building modular backend applications with service bundles. The goal of the article is to provide a practical presentation that’s focused on the standard steps to create services, bundle them as a module, test the module, and distribute the module using a private or public repository. The other articles in the series will explore advanced concepts and unconventional solutions.

We’ll only assume basic familiarity with PHP and Composer. Every necessary step is numbered and can be cut and pasted without changes. Applying all the terminal commands and coding examples in proper order should result in a working module and its installation into a backend app.

Creating a module for a backend application

Our goal is to create a few services and then bundle them together as a single module that can be installed into an app built on the PHP Symfony framework. We also want the bundle to autowire into its host app. That means that the bundle will register its services with the dependency injection mechanism of the app.

In our context, a service is a PHP class. That’s not to say that all classes of the project are services. We just mean that each service will be defined in terms of a class. Service bundles can be created within an app, but we aren’t interested in doing that. We’re only interested in creating bundles that are installed via composer into a host app. In Composer terminology, the host app is the root project, and the source files of bundles are added to its vendor folder.

For this exercise, we’ll build a reporting module that will take a text input from a user or external system, and use it to make a custom-formatted Word document.

(1) Let’s create a project from scratch and name the project report-maker :

$ mkdir report-maker && cd report-maker && composer init

The last command will start an interactive console to ask you basic questions. The most important answer to give is the “type”, which must be set to “symfony-bundle”.

...

Package Type: symfony-bundle

...

We now have a composer package. What we want to do next is make sure that the package becomes a bundle of services that can be “wired” automatically into a host app. Or in other words, that the simple action of adding the bundle into an app is enough for the app to use its functionality.

(2) We need to install some packages to implement our solution (this step can also be done as part of the interactive init command). For the production version, we just need the PHPWord package.

$ composer require phpoffice/phpword:dev-develop

(3) We also need some of Symfony components for testing purposes and to enable the error-checking and completion of our code editor (e.g. VSCode). The needed components are: DependencyInjection, Config, HttpKernel, and Yaml. We can do all that with a single command line:

$ composer require symfony/dependency-injection symfony/config symfony/http-kernel symfony/yaml --dev

Note: Remember to add a trailing —-dev to the commands since the packages are only needed for testing (the host app should have them already).

(4) We can now create the folder structure that we’ll be using. We need four folders:

$ mkdir config tests src src/DependencyInjection

Namespace

The namespace of our module will be: vendor\Module\ReportMaker . We recommend placing all modules under vendor\Module because the intent is to have a company-wide registry of modules for all apps.

Note: vendor is a placeholder for a company’s name or similar identifier. For example, in my case, vendor would be Proximify .

Data model

(5) We only have one type of data entity in our example project. We’ll call it Document .

<?php

// src/Document.php namespace vendor\Module\ReportMaker; class Document

{

private $id;

private $text; public function getText(): string

{

return $this->name;

} public function setText(string $name): self

{

$this->name = $name;

return $this;

}

}

Service definiton

(6) We already have a document generator in PHPWord. We just need to code a data persister and a document formatter. In the future, the data persister might be implemented with a database so we will use the conventions of Doctrine ORM and name it DocumentRepository .

<?php

// src/DocumentRepository.php namespace vendor\Module\ReportMaker; class DocumentRepository

{

private $storageDir; public function __construct(string $storageDir)

{

$this->storageDir = $storageDir; if (!file_exists($storageDir))

mkdir($storageDir);

} public function getDocument(int $id): Document

{

$path = $this->storageDir . "/$id.txt"; if (!file_exists($path))

throw new \Exception("Cannot find the document"); return (new Document())->setText(file_get_contents($path));

} public function newDocument(string $data): ?int

{

$id = time();

$path = $this->storageDir . "/$id.txt"; if (file_exists($path))

throw new \Exception("Could not add new document"); return file_put_contents($path, $data) ? $id : null;

}

}

(7) Coding the document formatter is easy thanks to the great PHPWord library.

<?php

// src/DocumentFormatter.php namespace vendor\Module\ReportMaker; use \PhpOffice\PhpWord\PhpWord;

use \PhpOffice\PhpWord\Style\Font;

use \PhpOffice\PhpWord\IOFactory; class DocumentFormatter

{

private $repo; public function __construct(DocumentRepository $repo)

{

$this->repo = $repo;

} public function format(int $id, string $path): void

{

$text = $this->repo->getDocument($id)->getText(); $fontStyle = new Font();

$fontStyle->setBold(true);

$fontStyle->setName('Tahoma');

$fontStyle->setSize(13); $phpWord = new PhpWord();

$section = $phpWord->addSection();

$el = $section->addText($text);

$el->setFontStyle($fontStyle); $objWriter = IOFactory::createWriter($phpWord, 'Word2007');

$objWriter->save($path);

}

}

Nice! We got all the services that we need for our example project.

Creating a bundle of services

A bundle of services must comply with the expectations of the host app. In order to do that, we are going to need one configuration file and three classes organized as shown below.

report-maker

├─ config

│ └─ services.yaml

└─ src

├─ ...

├─ ReportMakerBundle.php

└─ DependencyInjection

├─ ConfigurationSchema.php

└─ ReportMakerExtension.php

It might seem like too much overhead for a simple module, but each file has a purpose, and the setup scales well as a project grows in complexity.

Service declarations

The host app has an object know as service container provided by the Symfony framework. The object is an instance of the class ContainerBuilder , which is provided by the DependencyInjection component. The service container stores both services and parameters.

(8) We will collect the configuration options for the service container in a Yaml file and later load it into the service container. We will also customize some options programmatically based on app-level config values.

# config/services.yaml parameters:

# nothing to declare services:

vendor\Module\ReportMaker\DocumentRepository:

arguments: [''] vendor\Module\ReportMaker\DocumentFormatter:

autowire: true app.document.db:

alias: vendor\Module\ReportMaker\DocumentRepository

public: true app.document.formatter:

alias: vendor\Module\ReportMaker\DocumentFormatter

public: true

The configuration above declares no service parameters and two private services. In addition, it declares two public aliases, one for each service. The aliases are prefixed with ‘app.’ to avoid name conflicts with Symfony’s aliases. The additional dot levels, app.x.y , are our way to group the services by category for pure organizational purposes.

Note: private services can be used as dependencies during autowire, but can’t be explicitly requested from a DI container object. It makes sense to use class names as identifiers because that enables autowiring based on matching them with type hints from function argument. In contrast, the public aliases are meant to help us request the services via simple names.

Only the DocumentFormatter services was declared as “auto-wirable” above. That key tells the DI mechanism to use type-hinting on the constructor’s arguments of the class and pass the appropriate values to it, automagically.

The service DocumentRepository can’t be autowired because it expects a string argument with a folder path. The magic of autowiring is not as powerful as to guess what that value should be. Nor can we at this point since it depends on the specific host app where the bundle will be installed. To address that, we declare that the array of argument values needs only one value equal to the empty string. The value here is meant as a placeholder for the storageDir path. We will set the proper value programatically once we have access to app-level configs.

Defining a configuration schema

Our DocumentRepository service needs to be given a path to a writable folder where it can store documents. Since that path must be decided by the host app, we will declare it as an app-level config option. In fact, we don’t just want to declare it, we also want to make it mandatory and throw an error if it’s not given. We also want the error message to be descriptive so the app developers know what we expect from them. While all that sounds like a lot of work, we can rely on the Config component for most of it.

(9) The only thing that we have to do regarding declaration of app-level options is to define a config schema for them. That makes sense because we are the only ones that know what the services in the bundle expect from the app’s configs. The standard practice is to define configuration values in terms of a schema tree, which is a scary looking XML document object model.

<?php

// src/DependencyInjection/ConfigSchema.php namespace vendor\Module\ReportMaker\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder;

use Symfony\Component\Config\Definition\ConfigurationInterface; class ConfigSchema implements ConfigurationInterface

{

public function getConfigTreeBuilder()

{

$treeBuilder = new TreeBuilder('report-maker');



$rootNode = $treeBuilder->getRootNode(); $treeBuilder->getRootNode()

->fixXmlConfig('storage')

->children()

->scalarNode('storageDir')->isRequired()->end()

->end(); return $treeBuilder;

}

}

The code above declares a storageDir option and makes it mandatory by setting it as a required value. The “report-maker” label is the alias of our bundle. It is automatically generated from the name of the bundle’s entry point class defined next. This topic is discussed further in a later section

Important: the config schema is for the settings of the bundle as a whole. It’s up to us to decide whether a configuration value must be passed down to a particular service. App-level configs are high-level settings that need not have a direct correspondence with implementation-level arguments.

The bundle’s entry-point class

Our bundle needs a loader class that the host app can use to learn what services are provided and register them with its service container.

Several helper method are implemented under the assumption that the class name of the bundle’s entry point follows a specific naming pattern of the form src/{BundleName}Bundle . By following the pattern, we will avoid having to write extra code.

(10) As long as we follow the naming conventions, the following empty class is the only code that’s needed for the bundle’s entry point. The main information in it is just the class name, which will be used by helper methods to infer other needed class names.

<?php

// src/ReportMakerBundle.php namespace vendor\Module\ReportMaker; use \ Symfony\Component\HttpKernel\Bundle\ Bundle; class ReportMaker Bundle extends Bundle

{



}

This class is the entry point for the host app because the app will instantiate it and call the method Bundle::getContainerExtension() . In turn, that method will return an object of an Extension class that we are yet to define. Next, the load() method of the Extension class will be called by the app. The method will create an instance of the ConfigSchema class that we defined earlier. Hence, the invocation chain starts with the ReportMakerBundle class.

The DI extension class

In order to register services with the host app, our bundle must provide an Extension class for the app’s Dependency Injection (DI) mechanism. The host app has an object know as service container of type ContainerBuilder . Our Extension class will allow the bundle to register its services with the service container.

The base Extension class that we will use implements the expected DI’s ExtensionInterface but does not implement its abstract load() method. That’s left for us to do. The load() method will be called by the host app and is expected to process configuration options from both the bundle’s project and the app’s project. The app-level options will be given in a $configs array that we can validate using the ConfigSchema defined earlier. The local configuration files of the bundle must be loaded by our own code.

The method Bundle::getContainerExtension() in our bundle class assumes that the extension class, if provided, will be named {BundleName}Extension and be located at src/DependencyInjection . So we will place an extension class with that name in that folder.

(11) Our goal is to load the list of services and register them with the service container of the host app. The list of services is saved in our own config/services.yaml . In addition, we want to process the app-level config options and pass the mandatory storageDir value to the service that needs it.

<?php

// src/DependencyInjection/ReportMakerExtension.php namespace vendor\Module\ReportMaker\DependencyInjection; use Symfony\Component\Config\FileLocator;

use Symfony\Component\DependencyInjection\ContainerBuilder;

use Symfony\Component\HttpKernel\DependencyInjection\Extension;

use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

use vendor\Module\ReportMaker\DocumentRepository; class ReportMakerExtension extends Extension

{

function load(array $configs, ContainerBuilder $container)

{

$configDir = new FileLocator(__DIR__ . '/../../config'); // Load the bundle's service declarations

$loader = new YamlFileLoader($container, $configDir);

$loader->load('services.yaml'); // Apply our config schema to the given app's configs

$schema = new ConfigSchema();

$options = $this->processConfiguration($schema, $configs); // Set our own "storageDir" argument with the app's configs

$repo = $container->getDefinition(DocumentRepository::class);

$repo->replaceArgument(0, $options['storageDir']);

}

}

Let’s analyze the code above so that we understand what’s being done and have the confidence to adapted it to other cases.

The first five code lines are generic and probably won’t change at all for other projects. It’s worth noting that YamlFileLoader() sets the $container as a construction argument and after, its own load method is invoked to add our service declarations into the $container object.

The application of our config schema might also seem a bit odd. It all starts with the $configs argument, which is an array or arrays that’s provided unprocessed. It contains all the config arrays that were addressed to our bundle in all config files of the host app. The processConfiguration() method merges that array of arrays into a single array and validates its values according to the configuration schema that we defined in our ConfigurationSchema class.

Finally, the last two lines are very specific to our project. You might remember that we had set a placeholder value for the storageDir argument in our config/service.yaml . Well, now we have access to that value from the app’s configs, so we can set it into the service that needs is. It’s the first argument of the service’s constructor function, so its index is 0.

Setting an alias for the service bundle

A bundle is required to have an alias that’s a single lowercase word. The Extension::getAlias() method infers a compliant alias from the short name of the class by:

removing the “Extension” suffix;

converting the camel-case text to its underscore-separated equivalent; and

lower-casing the resulting text.

In our case, the ReportMakerExtension class gets the alias report_maker .

Autoloading

We have been implicitly assuming that all our classes would autoload but we didn’t configure that aspect of Composer yet. Unfortunately, there is no easy CLI command to do it.

(12) Edit the composer.json file of the project, and add the following autoload definition after the line “type”: “symfony-bundle”,

"autoload": {

"psr-4": {

"vendor\\Module\\ReportMaker\\": "src"

}

},

The exact order of keys in that files is not important. The suggested location is only mean to ensure that the trailing comma in the text above is appropriate.

(13) Activate the new project configuration by running

$ composer install

Testing the service bundle

In step #3 of this guide we installed a number of dev packages. Those packages help in two ways. First, they enable code editors (e.g. VSCode) to offer features like error highlighting and auto-completion. Second, they allow us to recreate the host app’s environment in our tests.

(14) The part of a host app that we need is its service container. Fortunately, the app’s service container is just an object of type ContainerBuilder , so that’s super easy to replicate.

<?php

// tests/simple-app.php use Symfony\Component\DependencyInjection\ContainerBuilder;

use vendor\Module\ReportMaker\DependencyInjection\ReportMakerExtension; require_once __DIR__ . '/../vendor/autoload.php'; /** @var array<array> app-level configuration options */

$configs = [['storageDir' => __DIR__ . '/db']]; // Build the service container

$container = new ContainerBuilder();

$extension = new ReportMakerExtension();

$extension->load($configs, $container);

$container->compile(); // Set and create the output directory

$outDir = __DIR__ . '/out'; if (!file_exists($outDir))

mkdir($outDir); // Define the file name and contents

$index = count(scandir($outDir));

$path = "$outDir/document$index.docx";

$text = 'Hello world!'; // Use the services in the bundle

$id = $container->get('app.document.db')->newDocument($text);

$container->get('app.document.formatter')->format($id, $path); echo "The document is at '$path'

";

The test above doesn’t do much. It just creates a new document, formats its and prints out the path to the output Word file. In that code, we are requesting the services explicitly by their alias. In a real app, the services will be injected into other services from other bundles by the DI mechanism.

(15) Run the test with