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.

Creating a module for a backend application

$ 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

Data model

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

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

report-maker
├─ config
│ └─ services.yaml
└─ src
├─ ...
├─ ReportMakerBundle.php
└─ DependencyInjection
├─ ConfigurationSchema.php
└─ ReportMakerExtension.php

Service declarations

# 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

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

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

}

The DI extension class

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

Autoloading

"autoload": {
"psr-4": {
"vendor\\Module\\ReportMaker\\": "src"
}
},
$ composer install

Testing the service bundle

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

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

$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

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

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

Using private repositories

$ 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

# config/packages/report_maker.yamlreport_maker:
storageDir: ./out
$ composer require vendor/report-maker:dev-master

Adding a route for testing

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

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