Image for post
Image for post

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.

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.

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.

$ mkdir report-maker && cd report-maker && composer init
...
Package Type: symfony-bundle
...
$ composer require phpoffice/phpword:dev-develop
$ composer require symfony/dependency-injection symfony/config   symfony/http-kernel symfony/yaml --dev
$ 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.

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;
}
}
<?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);
}
}

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

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.

# 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

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.

<?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 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.

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

}

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.

<?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']);
}
}

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:

  • converting the camel-case text to its underscore-separated equivalent; and
  • lower-casing the resulting text.

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.

"autoload": {
"psr-4": {
"vendor\\Module\\ReportMaker\\": "src"
}
},
$ 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.

<?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";
$ 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.

<?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);
}
}
# 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

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.

$id = $container->get('app.document.db')->newDocument($text);
$container->get('app.document.formatter')->format($id, $path);
$maker = $container->get('app.document.maker');
$id = $maker->newDocument($text);
$maker->format($id, $path);
$ 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.

# .gitignore/vendor/
$ git init && git add . && git commit -m "First commit" 
$ git remote add origin remote-repo-url && git push -u origin master

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.

$ 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.

$ composer config repositories.0 vcs remote-repo-url
$ composer config repositories.0 path '../report-maker'
{
"extra": {
"branch-version": "1.0-dev"
}
}

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.

# config/packages/report_maker.yamlreport_maker:
storageDir: ./out
$ 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.

$ composer require doctrine/annotations
<?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);
}
}
# config/routes/report-maker.yaml
report-maker:
resource: '@ReportMaker'
$ composer require symfony/mime
$ php -S localhost:8000 -t public
$ symfony server:start
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.

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store