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
17 min readJan 4, 2021

--

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.yamlparameters:
# 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 ReportMakerBundle 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'\n";

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

$ php tests/simple-app.php

Black-boxing the services

Our bundle does what it’s meant to do, but it feels as if we ended up exposing its inner workings to the app. It doesn’t feel like a blackbox with inputs and outputs. The app itself requires some invocation logics that, one could argue, belongs to the module.

(16) We can create a wrapper service by defining one more class named ReportMaker and abstract out the logic of the module with it.

<?php 
// src/ReportMaker.php
namespace vendor\Module\ReportMaker;class ReportMaker
{
private $repo;
private $formatter;
function __construct(
DocumentRepository $repo,
DocumentFormatter $formatter)
{
$this->repo = $repo;
$this->formatter = $formatter;
}
function newDocument(string $text): int
{
return $this->repo->newDocument($text);
}
function format(int $id, string $path)
{
$this->formatter->format($id, $path);
}
}

Now, we can just offer this class to the app as our only public service. All other services in the module are used only by our blackbox logic.

(17) We have to declare the final set of service and visibilities in our config/services.yaml. We can replace its previous contents with:

# config/services.yamlparameters:
# nothing to declare
services:
vendor\Module\ReportMaker\DocumentRepository:
arguments: ['']
vendor\Module\ReportMaker\DocumentFormatter:
autowire: true
vendor\Module\ReportMaker\ReportMaker:
autowire: true
app.document.maker:
alias: vendor\Module\ReportMaker\ReportMaker
public: true

The ReportMaker is also set to autowire and so other services in other bundles could declare constructor’s arguments like ReportMaker $maker in order to use the service without needing any additional code.

Testing the blackbox approach

The testing code is slightly different now because there is only one service that can be requested from the container. Since the test code is long, it is clearer to mention what must be changed in it.

(18) In tests/simple-app.php, replace the two lines below…

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

with these three lines:

$maker = $container->get('app.document.maker');
$id = $maker->newDocument($text);
$maker->format($id, $path);

What’s amazing is that we didn’t have to change anything in the initialization of the bundle even though some dependencies, such as the storageDir argument rely on our custom wiring. Through the power of metalanguage processing (via class and method reflection), the dependency injection mechanism is able to construct instances of ReportMaker, DocumentRegistry and DocumentFormatter and pass them along to the appropriate arguments.

(19) Run the test one more time with

$ php tests/simple-app.php

Creating a repository

We can conclude the bundle project by creating a repository for it and uploading it to GitHub.

(20) Create a .gitignore file and to exclude the vendor folder from the repository.

# .gitignore/vendor/

(21) Add the files in your new local repository. This stages them for the first commit. Commit the files that you’ve staged in your local repository.

$ git init && git add . && git commit -m "First commit" 

(22) While not necessary for the exercise, we might want to create a remote git repository and link it with our local repo. Given the URL of a remote repo, remote-repo-url, we can link it to the local repo with:

$ git remote add origin remote-repo-url && git push -u origin master

For example, remote-repo-url might be https://github.com/vendor/repo.

Installing the bundle into an app

For this step, we need an actual Symfony application. The app’s project must be created in a folder outside of our bundle’s project.

(23) Let’s build an app from a generic skeleton and name it my-backend.

$ cd .. && composer create-project symfony/skeleton my-backend
$ cd my-backend

Using private repositories

We want to install the bundle into the app using Composer. Since it’s not a public package, we have to add the private repository to the composer.json file of the backend app.

The Composer CLI has a config command to edit settings under the “config” property in a composer.json file (local or global), as well as several other root-level properties in a local composer.json (run composer config --list for the full list of editable properties).

(24) We have two options, we can use our local repository or, if we have one, a remote one. If you created a remote repository at remote-repo-url, then you can add it with the special CLI arguments to modify repositories:

$ composer config repositories.0 vcs remote-repo-url

Otherwise, you can add a path to a local repository with

$ composer config repositories.0 path '../report-maker'

Note: Path repositories work similarly to regular repositories. Composer can infer the version of a package requirement by the branch or tag that is currently checked out. If that does not work, the version can be explicitly defined in the package’s composer.json file under a special “extra” property, as shown below

{
"extra": {
"branch-version": "1.0-dev"
}
}

If the version cannot be resolved by these means, Composer assumes that it is dev-master.

“The local package will be symlinked if possible, in which case the output in the console will read Symlinking from ../../packages/my-package”.

Installing the service bundle

If we try to install our bundle into the host app, we will get an error from the Flex plugin because we made the storageDir parameter mandatory, and that is not defined in our app’s project yet.

Symfony projects have several configuration files. The config/services.yaml file is to set parameters for locally defined bundles, which is not what we are using. Our bundle is an external dependency, and that type of settings go under the config/packages folder. Since the config files in that folder are merged, the name of the file doesn’t really matter. What matters is that the parameters are set under a property equal to the package name.

(23) Configure the mandatory parameters for the bundle before installing it.

# config/packages/report_maker.yamlreport_maker:
storageDir: ./out

(24) The final step that we have been working towards is to install the bundle into the app.

$ composer require vendor/report-maker:dev-master

Adding a route for testing

It’s satisfying to conclude with a working app. The minimum amount of work that we need for that is to define a route and a controller method that uses the ReportMaker to receive text as a request parameter and respond with a document file. We will follow the standard routing instructions.

(24) In order to define routes within PHP files, we have to include the annotations package, which works with both PHP 8 attributes and Doctrine annotations.

$ composer require doctrine/annotations

The package has a public Flex recipe that sets appropriate defaults under config/routes/annotations.yaml.

(25) Create the route and the controller (method) for it. We won’t need to generate URLs for any page linking to this endpoint, but for good practice, we will still name the route as “reports”.

<?php
// src/Controller/Reports.php
namespace App\Controller;use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use vendor\Module\ReportMaker\ReportMaker;
class Reports
{
/**
* @Route("/reports", name="reports")
*/
function makeReport(Request $req, ReportMaker $maker): Response
{
// Set and create the output directory
$outDir = './out';
if (!file_exists($outDir))
mkdir($outDir);
// Define the file name and contents
$index = count(scandir($outDir));
$path = "$outDir/document$index.docx";
$text = $req->query->get('text');
$id = $maker->newDocument($text);
$maker->format($id, $path);
// Respond with a file
return new BinaryFileResponse($path);
}
}

It’s satisfying to note that the object $maker will be created by the framework (i.e. the ServiceContainer) and passed to the controller method automatically. That’s dependency injection in action!

Note: The Reports controller class can be placed under a src/Controller folder in the bundle’s project instead of the app’s. If we do that, we have to let the app know that it’s there. We can do that by adding the following report-maker.yaml to the config/routes folder of the app.

# config/routes/report-maker.yaml
report-maker:
resource: '@ReportMaker'

(25) Since we are responding with a binary file, we should be setting the MIME type in the Content-Disposition response header. We can avoid doing that be installing one extra service that, if available, will be used by the Symfony framework when needing to infer a MIME type from a file.

$ composer require symfony/mime

(26) Load the app and enjoy it by starting the php built-in web server on the public folder of the app’s project.

$ php -S localhost:8000 -t public

or, if the symfony CLI is installed,

$ symfony server:start

Then, simply open a web browser at

http://localhost:8000/report?text=We_are_done!

Where do we go from here?

There are two interesting paths to explore further. One is to see what happens if we don’t follow the naming conventions of Symfony for our bundle classes. I don’t know about you, but I find some of those names to be a bit odd. Moreover, it seems that renaming a bundle would trigger the renaming of several classes and also edits to several, hardcoded strings. We might be able to do better. That topic is discussed in a follow up article.

Another upcoming article in the series considers implementing a more realistic solution in terms of a backend app with an API, a database, and a complete testing pipeline.

That is it for now. Please let me know in the comments if you found this guide useful.

--

--

Diego Macrini

CEO/CTO @Proximify. Specialized in research information systems.