Like many other CMS, Contao’s functionality can easily be extended by installing extensions from third parties. This article will explain the basics on how to create an extension of your own - for others to use or just yourself.
As Contao itself is just a Symfony bundle that’s loaded to your Symfony application or Contao Managed Edition, writing your own bundle is very similar to writing a regular Symfony bundle. To learn more about bundles in general, you can read the respective Symfony documentation first.
Within this documentation, the terms package, bundle and extension are often
used interchangably. For Composer, everything is a package, while a symfony-bundle
or a contao-bundle
is a specific type of package. Contao bundles are referred
to as extensions within the Contao universe.
This article guides you through the necessary steps of creating an extension. It reflects the minimum amount of basic configuration that has to be done in order to be able to install such an extension in your Contao installation.
Instead of creating the groundwork manually, you can also kickstart the development of your extension by having a look at the official Contao 4 skeleton bundle. You can also clone it and then make the necessary adjustments that are individual to your own extension as outlined in its README. It might still be useful though to go through all the steps of this guide for a better understanding on how it all works together.
When creating an extension, the following objectives are relevant:
composer.json
vendor/
The first thing you do is usually to decide on a name for the extension and its
package. For the package name, the usual convention is to use the vendor name identical
to your organization’s Git account name, plus the name of the extension
in kebab case, prefixed with contao-
, e.g.: somevendor/contao-example-bundle
.
When starting an extension from scratch (i.e. you do not even have a remote Git repository set up yet for your extension), you first create a folder for the source of your extension. This can be anywhere in your file system, as it will be later on installed via Composer.
Within the previously created folder, you initialize a new composer.json
, which
you can do with the composer init
command. During generation, set the package
type to contao-bundle
, as mentioned in the Your First Extension guide. Also
choose the right SPDX for your license. During the interactive generation you
can also already define your dependencies. At the very least you should require
the Contao version, i.e. the version of the contao/core-bundle
for which you are
developing.
$ composer init
Welcome to the Composer config generator
This command will guide you through creating your composer.json config.
Package name (<vendor>/<name>) [user/contao-example-bundle]: somevendor/contao-example-bundle
Description []:
Author [User <user@example.com>, n to skip]: n
Minimum Stability []:
Package Type (e.g. library, project, metapackage, composer-plugin) []: contao-bundle
License []: LGPL-3.0-or-later
Define your dependencies.
Would you like to define your dependencies (require) interactively [yes]? yes
Search for a package: contao/core-bundle
Enter the version constraint to require (or leave blank to use the latest version): ^4.13 || ^5.0
Search for a package:
Would you like to define your dev dependencies (require-dev) interactively [yes]? no
Add PSR-4 autoload mapping? Maps namespace "Somevendor\ContaoExampleBundle" to the entered relative path. [src/, n to skip]:
{
"name": "somevendor/contao-example-bundle",
"type": "contao-bundle",
"require": {
"contao/core-bundle": "^4.13 || ^5.0"
},
"license": "LGPL-3.0-or-later",
"autoload": {
"psr-4": {
"Somevendor\\ContaoExampleBundle\\": "src/"
}
}
}
Do you confirm generation [yes]? yes
Would you like the vendor directory added to your .gitignore [yes]? yes
Would you like to install dependencies now [yes]? no
PSR-4 autoloading configured. Use "namespace Somevendor\ContaoExampleBundle;" in src/
Include the Composer autoloader with: require 'vendor/autoload.php';
Now it is time to set up your actual development structure. Typically, you will have
a src/
folder containing all the sources of your extension, and a test/
folder
for tests (if any). This is a common setup, though you are free to choose a different
one (e.g. no src/
and test/
subfolder, starting with the namespace folders directly).
Next you will choose a top-level namespace and extension related subnamespace for
your extension, e.g. Somevendor\ContaoExampleBundle
. Using the PSR-4 Autoloading Standard
the src/
folder will be mapped as the namespace base folder for that namespace
in the autoloading part of your extension’s composer.json
. This has already been added by composer init
, if you
confirmed this:
{
"name": "somevendor/contao-example-bundle",
"type": "contao-bundle",
"require": {
"contao/core-bundle": "^4.13 || ^5.0"
},
"license": "LGPL-3.0-or-later",
"autoload": {
"psr-4": {
"Somevendor\\ContaoExampleBundle\\": "src/"
}
}
}
Now we can include the (still empty) extension into a Contao installation. Since
this is still just a local directory (and not publicly available via a Git repository),
we will have to define this “repository” manually in the root composer.json
of
the Contao installation:
{
"repositories": {
"somevendor/contao-example-bundle": {
"type": "path",
"url": "/path/to/your/extension/directory"
}
}
}
The url
can be either an absolute path (starting with /
or a drive letter under Windows) or a path relative to your
Contao installation.
In the require
part we can then request the installation of our extension, using
the defined package name and dev-main
as the version, as this is the default
branch alias that Composer will use, if nothing else is defined in the composer.json
or via Git.
{
"require": {
"somevendor/contao-example-bundle": "dev-main"
}
}
When running a composer update
, Composer will now symlink the given path into the
vendor directory of the Contao installation and everything is ready to go. You
can now continue developing within vendor/somevendor/contao-example-bundle
.
Now it is time to do some ground work for the extension:
composer.json
for the Contao Manager Plugin.Creating the bundle class is simple enough. The name of the bundle class can be freely chosen - typically it will have the same name as your top-level subnamespace, or even a combination of your complete top-level namespace. For example:
// src/ContaoExampleBundle.php
namespace Somevendor\ContaoExampleBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ContaoExampleBundle extends Bundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}
In this example we also override the getPath
method in order to take advantage of the
recommended bundle structure where there is no src/Resources/
folder anymore.
Starting with Symfony 6 (Contao 5) you can instead extend from Symfony\Component\HttpKernel\Bundle\AbstractBundle
and omit the getPath
method, as the new AbstractBundle
already includes this.
// src/ContaoExampleBundle.php
namespace Somevendor\ContaoExampleBundle;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class ContaoExampleBundle extends AbstractBundle
{
}
The bundle class can otherwise be empty, but could contain additional bundle configurations (see Symfony’s documentation on how to create bundles).
Next up we create a Manager Plugin within our extension, so that our bundle can be automatically loaded by a Contao Managed Edition instance. The following plugin will load our bundle after the Contao Core Bundle (since the order of execution matters for certain things like DCA or translation adjustments).
// src/ContaoManager/Plugin.php
namespace Somevendor\ContaoExampleBundle\ContaoManager;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Contao\CoreBundle\ContaoCoreBundle;
use Somevendor\ContaoExampleBundle\ContaoExampleBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): array
{
return [
BundleConfig::create(ContaoExampleBundle::class)
->setLoadAfter([ContaoCoreBundle::class]),
];
}
}
In order to expose the plugin to the Managed Edition we need to reference it in
the extension’s composer.json
:
{
"extra": {
"contao-manager-plugin": "Somevendor\\ContaoExampleBundle\\ContaoManager\\Plugin"
}
}
After running composer update
, the Contao Manager Plugin will load this bundle
in the Contao Managed Edition. This will have no real effect yet, since
the extension is still pretty empty.
Within the extension development is largely the same as developing for the application. One of the differences is that we need to take care of loading our services and routes for example ourselves.
While the Contao Managed Edition (and also Symfony Skeleton Applications) will load certain YAML files automatically for your application, an extension or bundle will have to load the service configuration itself. The details are described in the Symfony documentation. Starting with Symfony 6 (used by Contao 5) there are two different ways.
since 5 As noted previously, starting with Symfony 6 (Contao 5) you can extend your bundle class from AbstractBundle
.
There you can also use the loadExtension
method
to directly load your service configuration.
// src/ContaoExampleBundle.php
namespace Somevendor\ContaoExampleBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class ContaoExampleBundle extends AbstractBundle
{
public function loadExtension(
array $config,
ContainerConfigurator $containerConfigurator,
ContainerBuilder $containerBuilder,
): void
{
$containerConfigurator->import('../config/services.yaml');
}
}
You can also create an extension class
in the DependencyInjection
namespace which handles loading of your service definitions. The class name of the needs to
be the same as the bundle name, with Bundle
replaced by Extension
, if present.
// src/DependencyInjection/ContaoExampleExtension.php
namespace Somevendor\ContaoExampleBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class ContaoExampleExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
(new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')))
->load('services.yaml')
;
}
}
This will not work automatically if your bundle class already extends from AbstractBundle
. If you wish to use this
extension class, you will need to implement the getContainerExtension()
method in your bundle class and instantiate
this extension class manually.
// src/ContaoExampleBundle.php
namespace Somevendor\ContaoExampleBundle;
use App\DependencyInjection\ContaoExampleExtension;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class ContaoExampleBundle extends AbstractBundle
{
public function getContainerExtension(): ?ExtensionInterface
{
return new ContaoExampleExtension();
}
}
Now services can be registered as usual in your config/services.yaml
. The following example would enable
autowire and autoconfigure by default for all registered services. It
will also automatically register every PHP class in your src/
folder as a service. This will enable you to create
services on-the-fly and use PHP attributes for tagging your services.
services:
_defaults:
autowire: true
autoconfigure: true
Somevendor\ContaoExampleBundle\:
resource: ../src
exclude: ../src/{ContaoManager,DependencyInjection}
Keep in mind that Symfony recommends to explicitly configure services in public and reusable bundles. Within the Contao ecosystem of extensions as bundles issues are unlikely to occur, but depending on your use-case explicit service configuration might be required.
In order to define routes within this extension for a Contao Managed Edition, the
Manager Plugin of the extension needs to provide the routing configuration. This
is done by implementing the RoutingPluginInterface
in the Manager Plugin, as described
in the documentation.
// src/ContaoManager/Plugin.php
namespace Somevendor\ContaoExampleBundle\ContaoManager;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
class Plugin implements RoutingPluginInterface
{
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel)
{
return $resolver
->resolve(__DIR__.'/../../config/routes.yaml')
->load(__DIR__.'/../../config/routes.yaml')
;
}
}
This will load the routing configuration located under config/routes.yaml
of this extension.
# config/routes.yaml
somevendor.contao_example_bundle.controller:
resource: ../src/Controller
type: attribute
If you only need one format (e.g. attribute
) and have all your controllers in the same place (e.g. src/Controller
),
you may save yourself the additional config file:
// src/ContaoManager/Plugin.php
namespace Somevendor\ContaoExampleBundle\ContaoManager;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
class Plugin implements RoutingPluginInterface
{
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel)
{
return $resolver
->resolve(__DIR__.'/../Controller', 'attribute')
->load(__DIR__.'/../Controller')
;
}
}
When ready, create a remote repository, e.g. on GitHub. You can initialize Git in your extension directly in vendor and push the code to that repository.
user@somemachine ~/www/contao/vendor/somevendor/contao-example-bundle
$ git init
$ git add --all
$ git commit -m "initial commit"
$ git remote add origin git@github.com:somevendor/contao-example-bundle.git
$ git push origin main
Then the package can be published on the public Packagist by submitting the URL to the repository at packagist.org/packages/submit, assuming you already created an account. In order for Packagist to automatically update the information about your package, you need to implement any of the solutions offered here. For more information about publishing extensions within the Contao ecosystem, have a look at the dedicated article.
Once the package has been published to the public Packagist, the extension’s repository
can actually be removed from the root composer.json
of the Contao installation.
When requiring dev-main
(or any dev-
branch) of the extension, composer will
actually check out the code from the Git repository instead. This enables you to
push any changes you make back to the origin branch using your SSH key.