With some basic knowledge of the Symfony bundle system and Sulu’s extension capabilities, web developers have everything they need to start writing bundles for Sulu CMS. This two-part tutorial will guide you through the first steps of setting up the necessary folder structure, distributing and installing a bundle, and creating a custom React UI component for a new content type. Ready to dive in? Let's get started!
How does Sulu CMS manage bundles?
At its core, Sulu CMS is a basic Symfony application that can be extended with regular Symfony bundles. In fact, many of the features that we value in Sulu are already structured in bundles. This makes the core repository a handy resource for developers seeking real-world code examples to spark ideas for their own custom bundles. These bundles can come fully equipped to deliver all the functionality of a complete Sulu installation, such as rendering entities in lists or forms, registering custom routes, extending the backend UI with custom javascript/react components, and much more. Besides the Sulu core repository, you can also get code inspiration from the list of third-party Sulu bundles or by searching for „sulu-bundle“ on Packagist.
Goal
In the first part of this tutorial, we will bootstrap a new bundle that provides a content type called „video“. Inspired by the Statamic video fieldtype, this content type allows editors to insert and preview an external video link (e.g. from YouTube, Vimeo or an HTML5 player) in an inline player in the Sulu page form. The bundle also includes a custom Twig filter method that converts the link into an embeddable iFrame URL, eliminating the need to explain to clients on how to extract a video ID parameter from a YouTube URL. On our way we will learn how to install and debug the bundle locally.
Prerequisites
This tutorial requires some basic knowledge of Symfony, a running instance of version Sulu 2.5 or later, and Composer and Node.js installed. If you are not already familiar with Symfony bundles, please read the "Best Practices for Reusable Bundles" guide, as it contains important information about naming and directory structure conventions - pitfalls you definitely want to avoid!
Setting up the bundle structure
We will choose the short name SuluVideoBundle
which resolves to the bundle alias sulu_video
and the composer package title <vendor>/sulu-video-bundle
. Of course, you should replace <vendor>
with a more meaningful name.
With these names at hand, create a new directory where your bundle code will be located. Enter the directory and run composer init
to start the composer.json configuration wizard. When asked for a "Package Type", enter symfony-plugin
to make the bundle visible to Symfony Flex. When asked to "Add PSR-4 autoload mapping", press enter to confirm the default value /src
.
Finally, install all the dependencies for a minimal bundle setup*:
composer require sulu/sulu:^2.5 symfony/config:^7.0 symfony/dependency-injection:^7.0 symfony/framework-bundle:^7.0 jackalope/jackalope-doctrine-dbal:^2.0
*Note that at the time of writing, explicitly requiring jackalope-doctrine-dbal was necessary to avoid composer errors. In the upcoming Sulu 3 release you may be able skip this bundle.
Next, create a new file src/SuluVideoBundle.php
, paste in the following code and adjust the "Robole" part of the namespace if you wish:
<?php
declare(strict_types=1);
namespace Robole\SuluVideoBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class SuluVideoBundle extends Bundle {}
This file is the entry point for each Symonfy bundle. Although it remains empty at the moment, it can be useful to control the bundle lifecycle events or to load service container extensions. Such an extension, which we will create shortly, loads and provides information about the resources that we want to expose to the Symfony host application. These could be configurations, services, controllers, commands, forms or, in our case, Sulu content types provided by yaml or xml files. To load the service definition for our bundle, paste the following code into src/DependencyInjection/SuluVideoExtension.php
:
<?php
declare(strict_types=1);
namespace Robole\SuluVideoBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
class SuluVideoExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.xml');
}
}
Note how the term “Bundle“ has been replaced with "Extension“ – yet another requirement for Symfony to auto-detect the extension file. If you prefer to choose another class name or namespace, make sure to explicitly return an instance of your extension via the getContainerExtension
method in the SuluVideoBundle
class.
Good to know: Bundles that are intended to be configurable by their host application can ship a Configuration.php
along with the extension class to define a configuration scheme. For the sake of simplicity, we will skip this step, but it could serve as an entry point for later customisation of our video bundle, for example to allow or disallow video urls from specific domains from within the host application.
Load custom content type and Twig functions
Finally, it is time to define all the services that we want to load into Sulu in src/Resources/config/services.xml
:
<?xml version="1.0" encoding="utf-8"?>
<container
xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="sulu_video_bundle.content.type.video"
class="Robole\SuluVideoBundle\Content\Types\Video">
<tag name="sulu.content.type" alias="video" />
<tag name="sulu.content.export" format="1.2.xliff" translate="true" />
</service>
<service id="sulu_video_bundle.twig_extension"
class="Robole\SuluVideoBundle\Twig\VideoTwigExtension">
<tag name="twig.extension" />
</service>
</services>
</container>
The key here is to give each service an ID so that we can reference it in other parts of our application. By tagging the services, they become discoverable and can be loaded by other services living outside our bundle.
Next, let's add a simple ContentType class to src/Content/Types/Video.php
:
<?php
namespace Robole\SuluVideoBundle\Content\Types;
use Sulu\Component\Content\SimpleContentType;
class Video extends SimpleContentType
{
public function __construct()
{
parent::__construct('video', '');
}
}
As we do not need any parameters, nor do we need to perform any transformation on the data passed to the (yet to be developed) video input field, we can leave this class empty. Finally, let's provide the Twig extension class in src/Twig/VideoTwigExtension.php
:
<?php
namespace Robole\SuluVideoBundle\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class VideoTwigExtension extends AbstractExtension
{
public function getFunctions()
{
return [
new TwigFunction('video_embed_url', [$this, 'getVideoEmbedUrl']),
];
}
public function getVideoEmbedUrl(string $url): string
{
// ... the full code will be discussed in the next part of this tutorial
return $url;
}
}
The idea behind this function is to transform a regular YouTube or Vimeo link into an iFrame-friendly url.
Register and install the bundle locally
Assuming that our host Sulu application is closely located to the bundle folder, we can now install the bundle. The easiest way for doing so is to register the bundle as a repository in the composer.json
. To do so run the command:
composer config repositories.0 path '../sulu-video-bundle-directory'
Now install the bundle regularly which will create a symlink to the bundle folder in the vendor directory:
composer require <vendor>/sulu-video-bundle:@dev
If everything went well, the bundle will appear as a dependency in composer.json and should have been auto-added to bundles.php
by Symfony flex. However, composer may(!) also complain about different dependencies between the Sulu installation and your bundle - for example, if the former one has not yet been migrated to Symfony 7. In this case extend the composer.json of your bundle for every critical package (e.g. "symfony/config": "^6.0 | ^7.0"
) and try to install it again in the Sulu directory.
Finally, you can confirm that the bundle was installed successfully by running:
php bin/console config:dump-reference
This should print the bundle name alongside the extension alias sulu_video
. In order to ensure that the new content type is detected by Sulu, you may run:
bin/adminconsole sulu:content:types:dump

Lastly, open any existing xml template in your Sulu application and link your newly added video content type:
<property name="youtube_video" type="video" mandatory="true">
<meta>
<title lang="en">YouTube video</title>
</meta>
</property>
Clear the Sulu cache, reload the backend and navigate to the page that includes the template from above. If everything went correctly, Sulu will complain about a not being able to render the field. Oh no, why that? Wouldn't we just be fine with, say, a simple text input field at this moment?

The answer is simple: The Sulu backend is decoupled into an API and a React-based SPA. Therefore, every content type implicitly requires a React view that renders the input field(s) and validates or transforms the data inserted by backend users before saving it in the database. This extra step may appear tiresome, but it's definitely worth it!
Recap: Key Lessons for Creating a New Bundle in Sulu CMS
- Leverage Symfony Best Practices
Understanding and adhering to Symfony's bundle conventions (e.g., naming and folder structures) is crucial to ensure compatibility and maintainability. Refer to the "Best Practices for Reusable Bundles" guide for foundational knowledge. - Use Sulu’s Extension System Effectively
Sulu extends Symfony's functionality by enabling bundles to integrate seamlessly into its ecosystem. Properly register services and tag them (e.g.,sulu.content.type
) to make your bundle's features discoverable and usable within Sulu's backend. - Prepare for Decoupled Backend Requirements
Sulu’s backend relies on an API and a React-based SPA. Custom content types require both Symfony service definitions and React UI components to render input fields and handle user interactions effectively. Plan for this decoupled architecture when building your bundle.
Outlook
In the next part of this tutorial, which will be released in February 2025, we will learn some basics about Sulu's FieldRegistry, core UI components and the underlying bundle system in order to handcraft a custom UI component for our new content type.
Want to stay updated about Sulu tutorials and news? Follow the robole team on mastodon or Instagram.