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:

Screenshot showing injected HTTP header

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:

Screenshot showing headers injected by bundle

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:

  1. There are many more Content-Security-Policy options and permutations to deal with in our event subscriber.
  2. We didn't set any defaults or deal with missing/optional configuration for the bundle.
  3. We haven't written any tests.
  4. We injected a parameter bag rather than the arguments specific to the bundle.
  5. 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

Add a comment

All comments are pre-moderated and will not be published until approval.

You can write in _italics_ or **bold** like this.

Danilo Wednesday 23 February 2022, 15:48

Very nice tutorial, well exaplained! Thanks from Italy :)

Nicolas WU Monday 15 November 2021, 09:19

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.

Sandro Sunday 03 October 2021, 19:46

I want to say Thank You. I didnt find many Tutorials for this Topic and you discribe well.

Recent posts


Tuesday 12 April 2022, 21:40

Ever wondered the best way to do encryption in PHP? This tutorial shows you how!

php

Tuesday 29 March 2022, 18:47

...the AI trained on open source which writes code for you!

php coding

Sunday 06 March 2022, 01:03

...when world affairs and programming collide.

musings

Friday 28 January 2022, 23:46

...just add water! Spin up an Apache server running your choice of PHP version with this free helper.

php

Wednesday 06 October 2021, 21:43

Learn how to send and receive messages between PHP and ActiveMQ with Stomp protocol.

php