How to create a Symfony 5 bundle
Symfony Bundles
A bundle can be considered essentially a plugin for a Symfony project; it can wrap any functionality found in a Symfony application including configuration, controllers, routes, services, event listeners, templates, etc.
Until recent versions of Symfony (v4 I think off the top of my head?) the recommended way to build your application was to encapsulate it inside a bundle. We no longer do this, but bundles are still useful for common functionality you'd like to share across multiple projects.
In this tutorial, we're going to create a bundle for Symfony 5 which allows someone building an application to easily configure some common HTTP headers relating to security. Once the user has installed our bundle, they need only set a few options in a simple YAML file and the relevant headers will be added automatically to all their application's responses.
To follow this tutorial, you will need PHP (at least v7.4), Composer and of course a Symfony app to test your bundle against. This tutorial assumes prior working knowledge of PHP, Composer and Symfony.
The headers we'll be adding via our bundle are Content-Security-Policy
, X-Frame-Options
, Strict-Transport-Security
and X-Content-Type-Options
.
Setting up a new bundle
Older versions of Symfony had a command line helper to generate a bundle skeleton. Sadly this is no longer around, so we're going to create a bundle from scratch.
Create a new project directory SecurityHeadersBundle
to work in and inside that directory, create the following empty files and folder structure:
SecurityHeadersBundle/
├─ src/
│ ├─ SecurityHeadersBundle.php
│ ├─ DependencyInjection/
│ ├─ EventSubscriber/
│ ├─ Resources/
│ │ ├─ config/
│ │ │ ├─ services.yaml
├─ composer.json
This is our bundle skeleton.
Describe the Security Headers bundle
Open up composer.json
in your favourite IDE/editor and add the following, obviously replacing my details with yours:
{
"name": "dwgebler/security-headers-bundle",
"description": "Set security headers for Symfony apps through middleware",
"type": "symfony-bundle",
"require": {
"php": ">=7.4",
"symfony/framework-bundle": "^5.2"
},
"autoload": {
"psr-4": {
"Gebler\\SecurityHeadersBundle\\": "src/"
}
},
"license": "MIT",
"authors": [
{
"name": "David Gebler",
"email": "me@davegebler.com"
}
]
}
Now head on over to your terminal/command line and run composer install
from the project directory. This will install the dependencies and create the vendor/
directory inside your project directory.
Open up SecurityHeadersBundle.php
and add the following code:
<?php
namespace Gebler\SecurityHeadersBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* SecurityHeadersBundle
*/
class SecurityHeadersBundle extends Bundle
{
}
Congratulations! You've now created a bundle which can be installed and activated in a Symfony project. That was easy!
If you have an existing Symfony project (just create one from the skeleton if nothing else), add a reference to your new bundle source in composer.json
like so:
"repositories": [
{"type": "path", "url": "/path/to/SecurityHeadersBundle"}
],
...
"require": {
...
"dwgebler/security-headers-bundle": "dev-master",
}
And then in your Symfony app's config/bundles.php
, add the following line to the bundles array:
Gebler\SecurityHeadersBundle\SecurityHeadersBundle::class => ['all' => true],
Make our bundle do something
Our bundle works and can be added to a Symfony app, but it's functionally empty. Let's add an event subscriber to listen to the kernel.response event and add a header to the response.
We'll need to create a few files to do this - and I must stress naming is important here. Symfony does a little bit of magic in respect of loading bundles and configuration of services, so these file names are not suggestions, they are mandatory.
Inside src/EventSubscriber
directory, create a new file called ResponseSubscriber.php
with the following content. This is our event subscriber (not quite the same as an event listener in Symfony terminology).
<?php
namespace Gebler\SecurityHeadersBundle\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ResponseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
KernelEvents::RESPONSE => [
['addSecurityHeaders', 0],
],
];
}
public function addSecurityHeaders(ResponseEvent $event)
{
$response = $event->getResponse();
$response->headers->set('X-Header-Set-By', 'My custom bundle');
}
}
Great! This subscriber listens to the kernel.response
event, which is dispatched just before a response is sent, and sets a header X-Header-Set-By
so we can check in the browser and ensure our bundle is working.
But we're not done yet. If we re-run our application, Symfony won't know anything about this class or that it's an event subscriber. We still need to create a couple more files.
In src/Resources/config
, open up the empty services.yaml
file you created earlier and drop in the following:
services:
gebler_security_headers.response_subscriber:
class: Gebler\SecurityHeadersBundle\EventSubscriber\ResponseSubscriber
tags:
- { name: kernel.event_subscriber }
Now in src/DependencyInjection/
create a file called SecurityHeadersExtension.php
and add the following:
<?php
namespace Gebler\SecurityHeadersBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
/**
* SecurityHeadersExtension
*/
class SecurityHeadersExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yaml');
}
}
This file tells Symfony to load up and parse our services.yaml
when our bundle is initialized, which in turn registers our ResponseSubscriber
service class and allows Symfony to configure it as an event subscriber.
With these files now in place, in your sample Symfony app, you should be able to run something like composer update dwgebler/security-headers-bundle:dev-master
to sync up the new bundle source code and check the headers from a
request in the browser, or using a tool like Postman:
Awesome! We've created a bundle, installed it on an app (an old local copy of my blog in this case) and it's successfully adding a header to all the page responses!
Now we've made our bundle functional, the next step is to make it useful.
Make our bundle configurable
It's cool that we're adding a custom header to responses using our bundle, but now we want to replace X-Header-Set-By
with some of those security headers we talked about.
To start with, we could just replace the line $response->headers->set('X-Header-Set-By', 'My custom bundle')
with some other lines like:
$response->headers->set('X-Frame-Options`', 'deny');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
$response->headers->set('Content-Security-Policy', 'script-src \'self\'');
And this would be fine and dandy in terms of getting the headers set, but what we really want is for users of our bundle to be able to configure the values for these headers and choose which headers to include in some nice, easy way.
The best way would be for them to be able to modify a security_headers.yaml
file in their normal Symfony packages config directory. We also don't want them to have to faff around setting the exact header value strings - some like X-Frame-Options
may be simple enough, but
others like Content-Security-Policy
are a nightmare to get right.
For example, a CSP header of script-src 'self'
is pretty useless for my blog; in order to function correctly my CSP would have to look more like this:
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com https://kit.fontawesome.com; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; img-src *; font-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; connect-src 'self'; frame-src 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content
What we're going to do is allow our user to configure these kind of values in a more intuitive way, using YAML. So let's help them do that in our bundle.
Bundle configuration in the Symfony app
If we were creating a bundle and publishing it, we'd create a Flex recipe to copy the skeleton configuration file to the application's config/packages
directory. For now, in your Symfony app, just create a new file in that directory called
security_headers.yaml
. It should look like this:
security_headers:
frames: sameorigin
sniff_mimes: false
https:
required: true
subdomains: true
preload: true
content:
default: self
scripts:
- self
- https://www.googletagmanager.com
- https://kit.fontawesome.com
styles:
- self
- https://fonts.googleapis.com
styles_inline: true
upgrade_insecure: true
This is not comprehensive by any means, but it's easy to expand on in your own bundles which need to read config once you understand how it all works.
Now, in the src/DependencyInjection/
directory, create a new file called Configuration.php
and add the following code:
<?php
/**
* Configuration class.
*/
namespace Gebler\SecurityHeadersBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
/**
* Configuration
*/
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('security_headers');
$treeBuilder->getRootNode()
->children()
->scalarNode('frames')->end()
->scalarNode('sniff_mimes')->end()
->arrayNode('https')->children()
->booleanNode('required')->end()
->booleanNode('subdomains')->end()
->booleanNode('preload')->end()
->end()
->end()
->arrayNode('content')->children()
->scalarNode('default')->end()
->scalarNode('upgrade_insecure')->end()
->scalarNode('styles_inline')->end()
->arrayNode('scripts')->scalarPrototype()->end()->end()
->arrayNode('styles')->scalarPrototype()->end()->end()
->end()
->end();
return $treeBuilder;
}
}
You can see we're defining the structure of our config; we also have the config from the application's YAML file available as a normalised array in the SecurityHeadersExtension
class, but defining it this way
allows Symfony to manage merging the config when different values are provided for different environments, dev, prod etc. as well as giving us a sensible way to set defaults and optional parameters (though we haven't done that here).
Symfony configuration reference contains more details about how to configure the tree builder.
Load config from the configuration file
Now we just need to load the merged configuration inside our bundle. Open up src/DependencyInjection/SecurityHeadersExtension.php
again and edit the load
function so it looks like the following:
public function load(array $configs, ContainerBuilder $container)
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yaml');
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
foreach ($config as $key => $value) {
$container->setParameter('security_headers.' . $key, $value);
}
}
This will allow our event subscriber to grab the parameters from the user's security_headers.yaml
.
Update our event subscriber
The last piece is to read these parameters inside our event subscriber and use them to construct the response headers we want to send. There are a few ways of doing this; for simple service parameters (bools, scalars and the like) you would
usually inject them in to the constructor of your service and reference the parameters inside your services.yaml
, like you do with parameters in a Symfony application.
Because we have quite a complex parameter set, we're going to grab from the container via a parameter service, which is what we'll inject in to our event subscriber. Worth pointing out this is not really a best practice, but it'll do for the sake of example, particularly as it shows you how to manually wire in other service classes your bundles may use as dependencies.
Head back to src/EventSubscriber/ResponseSubscriber.php
and add a constructor to the class:
public function __construct(ParameterBagInterface $parameterBag)
{
$this->parameterBag = $parameterBag;
}
You'll also want to add the parameterBag
property to the class and import the interface from its namespace. Your file should end up looking like this:
<?php
namespace Gebler\SecurityHeadersBundle\EventSubscriber;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ResponseSubscriber implements EventSubscriberInterface
{
private ParameterBagInterface $parameterBag;
public function __construct(ParameterBagInterface $parameterBag)
{
$this->parameterBag = $parameterBag;
}
public static function getSubscribedEvents()
{
return [
KernelEvents::RESPONSE => [
['addSecurityHeaders', 0],
],
];
}
public function addSecurityHeaders(ResponseEvent $event)
{
$response = $event->getResponse();
$response->headers->set('X-Header-Set-By', 'My super custom security bundle');
}
}
In our src/Resources/config/services.yaml
, we also need to make an update:
services:
gebler_security_headers.response_subscriber:
class: Gebler\SecurityHeadersBundle\EventSubscriber\ResponseSubscriber
arguments:
$parameterBag: "@parameter_bag"
tags:
- { name: kernel.event_subscriber }
Now the moment we've been waiting for...we're ready to read these parameters in the event subscriber and construct some headers!
Edit the addSecurityHeaders()
function; take out the X-Header-Set-By
line and replace with the following:
public function addSecurityHeaders(ResponseEvent $event)
{
$frameOptions = $this->parameterBag->get('security_headers.frames');
$mimeSniffing = $this->parameterBag->get('security_headers.sniff_mimes');
$https = $this->parameterBag->get('security_headers.https');
$csp = $this->parameterBag->get('security_headers.content');
$strictTransport = '';
$contentSecurityPolicy = "default-src '{$csp['default']}'";
if ($https['required']) {
$strictTransport = 'max-age=63072000';
if ($https['subdomains']) {
$strictTransport .= '; includeSubDomains';
}
if ($https['preload']) {
$strictTransport .= '; preload';
}
}
if ($csp['upgrade_insecure']) {
$contentSecurityPolicy .= "; upgrade-insecure-requests";
}
if ($csp['styles_inline']) {
$contentSecurityPolicy .= "; style-src-attr 'unsafe-inline'";
}
$contentSecurityPolicy .= "; script-src";
foreach ($csp['scripts'] as $src) {
if ($src === "self") {
$src = "'self'";
}
$contentSecurityPolicy .= " ".$src;
}
$contentSecurityPolicy .= "; style-src";
foreach ($csp['styles'] as $src) {
if ($src === "self") {
$src = "'self'";
}
$contentSecurityPolicy .= " ".$src;
}
$response = $event->getResponse();
if ($mimeSniffing === false) {
$response->headers->set('X-Content-Type-Options', 'nosniff');
}
$response->headers->set('X-Frame-Options`', $frameOptions);
$response->headers->set('Strict-Transport-Security', $strictTransport);
$response->headers->set('Content-Security-Policy', $contentSecurityPolicy);
}
That's it! If you're running dev-master
of your bundle inside a sample Symfony app, update the bundle installation and check the headers coming through in your browser:
Improving the bundle
Hopefully this tutorial has given you a solid start in how to create a reusable Symfony 5 bundle from scratch. There are of course a few things left to do before we have a bundle we could distribute in the wild:
- There are many more
Content-Security-Policy
options and permutations to deal with in our event subscriber. - We didn't set any defaults or deal with missing/optional configuration for the bundle.
- We haven't written any tests.
- We injected a parameter bag rather than the arguments specific to the bundle.
- We'd need to write a Flex recipe so Symfony users can install our bundle in their apps easily with Composer.
You can download the source code for our demo bundle on my GitHub.
Until next time 😀️
Comments
All comments are pre-moderated and will not be published until approval.
Moderation policy: no abuse, no spam, no problem.
Very nice tutorial, well exaplained! Thanks from Italy :)
The following code in composer.json seemed was not actually working, would you like to give a more accurate example? for example, link to your github repository at "git@github.com:dwgebler/symfony-5-headers-bundle.git".
"repositories": [
{"type": "path", "url": "/path/to/SecurityHeadersBundle"}
],
...
"require": {
...
"dwgebler/security-headers-bundle": "dev-master",
}
Editor's reply: You can't composer install
this bundle, it's not distributed, it's just meant for following the tutorial. You would need to have the repo cloned locally and edit the path to whatever directory it's in on your local machine. Thanks.
I want to say Thank You. I didnt find many Tutorials for this Topic and you discribe well.
Recent posts
Re-examining this famous puzzle of probability and explaining why our intuitions aren't correct.
musings
Keep your database data secure by selectively encrypting fields using this free bundle.
php
Buy this advertising space. Your product, your logo, your promotional text, your call to action, visible on every page. Space available for 3, 6 or 12 months.
Learn how to build an extensible plugin system for a Symfony application
php
The difference between failure and success isn't whether you make mistakes, it's whether you learn from them.
musings coding
Recalling the time I turned down a job offer because the company's interview technique sucked.
musings
I am not sure where you’re getting your info, but good topic. I needs to spend some time learning much more or understanding more. Thanks for fantastic info I was looking for this information for my mission.