Contao does a lot out of the box: pages, articles, news, events, forms, members and a solid permissions system. Most client requests can be met by configuring what is already there. Sometimes they can't, and that is when we write an extension.
We've built around 30 of them, and they share roughly the same structure. A small showroom finder makes a good example, so that's what the code samples use.
An extension is a bundle
Since Contao 4, an extension is a Symfony bundle: a package of PHP code, configuration and templates that the application loads. It is installed with Composer, PHP's package manager, either from a public package or from a private repository. The showroom finder is laid out like this:
showroom-finder-bundle/
composer.json
config/
services.yaml
contao/
config/config.php
dca/tl_showroom.php
languages/en/tl_showroom.php
templates/content_element/showroom_finder.html.twig
src/
ContaoManager/Plugin.php
Controller/ContentElement/ShowroomFinderController.php
Migration/ShowroomRegionMigration.php
ShowroomFinderBundle.php
The contao/ folder holds the parts Contao itself reads: back-end configuration, DCA files, language strings and templates. The src/ folder holds ordinary PHP classes.
composer.json
Start with the package definition. The type line is the one people get wrong.
{
"name": "acme/showroom-finder-bundle",
"type": "contao-bundle",
"require": {
"php": "^8.1",
"contao/core-bundle": "^5.3"
},
"require-dev": {
"contao/manager-plugin": "^2.0"
},
"autoload": {
"psr-4": { "Acme\\ShowroomFinderBundle\\": "src/" }
},
"extra": {
"contao-manager-plugin": "Acme\\ShowroomFinderBundle\\ContaoManager\\Plugin"
}
}
"type": "contao-bundle" tells the Contao Manager this package is an extension. The extra.contao-manager-plugin key points at the class that registers it. Treat the version constraints as a promise, and only claim the PHP and Contao versions you have actually tested. When I updated an older extension recently, the job was mostly making it run cleanly on PHP 8.1 through 8.4 and saying so in this file.
The Manager Plugin and the bundle class
The plugin tells the Contao Manager which bundles to load, and in what order:
namespace Acme\ShowroomFinderBundle\ContaoManager;
use Acme\ShowroomFinderBundle\ShowroomFinderBundle;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): array
{
return [
BundleConfig::create(ShowroomFinderBundle::class)
->setLoadAfter([ContaoCoreBundle::class]),
];
}
}
setLoadAfter matters more than it looks. If your extension overrides something from another extension, Isotope for example, list that bundle too, or your override may load first and lose. The bundle class itself only needs to load the service configuration:
class ShowroomFinderBundle extends AbstractBundle
{
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$container->import('../config/services.yaml');
}
}
With autowire and autoconfigure switched on in services.yaml, Symfony registers your controllers and migrations automatically from their PHP attributes and interfaces, so there are no long service lists to maintain.
Describing the data in a DCA
The DCA (Data Container Array) is a PHP array that describes a table: its fields, how they appear in the back end, how records are listed, and the SQL definition of each column. Contao builds the database schema from it.
use Contao\DataContainer;
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_showroom'] = [
'config' => [
'dataContainer' => DC_Table::class,
'enableVersioning' => true,
'sql' => ['keys' => ['id' => 'primary', 'postal' => 'index']],
],
'list' => [
'sorting' => ['mode' => DataContainer::MODE_SORTABLE, 'fields' => ['name']],
'label' => ['fields' => ['name', 'city'], 'format' => '%s, %s'],
],
'palettes' => [
'default' => '{title_legend},name,city,postal;{geo_legend},lat,lng',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'name' => [
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
// city, postal, lat, lng follow the same pattern
],
];
A one-line entry in contao/config/config.php adds the table to the back-end menu, and running vendor/bin/contao-console contao:migrate creates or updates the table.
Content elements and modules as controllers
In Contao 5, content elements and front-end modules are controllers registered with a PHP attribute. A content element is something an editor places inside an article. A front-end module is usually placed in the page layout or referenced from an article.
#[AsContentElement(category: 'miscellaneous')]
class ShowroomFinderController extends AbstractContentElementController
{
public function __construct(private readonly ShowroomRepository $showrooms)
{
}
protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response
{
$postal = trim((string) $request->query->get('postal', ''));
$template->set('postal', $postal);
$template->set('results', $postal ? $this->showrooms->nearest($postal, 10) : []);
return $template->getResponse();
}
}
The element type, showroom_finder, comes from the class name, and the template name follows from the type. A front-end module works the same way with #[AsFrontendModule], AbstractFrontendModuleController and a ModuleModel.
The Twig template
Contao 5 renders new elements with Twig. The template extends Contao's base so the wrapper markup, headline and CSS classes match every other element on the site:
{% extends '@Contao/content_element/_base.html.twig' %}
{% block content %}
<form method="get">
<label for="postal-{{ data.id }}">Postal code</label>
<input id="postal-{{ data.id }}" name="postal" value="{{ postal }}">
<button type="submit">Find a showroom</button>
</form>
{% if results %}
<ul>
{% for showroom in results %}
<li>{{ showroom.name }}, {{ showroom.city }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
Twig escapes output by default, and a site can override this template from its own templates/ folder without touching the extension. The input also has a real label. If the accessible markup is built into the extension, every site that installs it starts from a good baseline.
Migrations
Schema changes come from the DCA. Data changes, such as moving values from an old field into a new one, belong in a migration. Contao runs migrations during contao:migrate, and each one decides for itself whether it still needs to run:
class ShowroomRegionMigration extends AbstractMigration
{
public function __construct(private readonly Connection $connection)
{
}
public function shouldRun(): bool
{
$schema = $this->connection->createSchemaManager();
if (!$schema->tablesExist(['tl_showroom'])) {
return false;
}
$columns = $schema->listTableColumns('tl_showroom');
return isset($columns['state']) && !isset($columns['region']);
}
public function run(): MigrationResult
{
$this->connection->executeStatement("ALTER TABLE tl_showroom ADD region varchar(64) NOT NULL default ''");
$this->connection->executeStatement('UPDATE tl_showroom SET region = state');
return $this->createResult(true);
}
}
Pay attention to shouldRun(). It must return false once the work is done, or the migration runs forever. I have stepped through upgrades where migrations looped because classes they depended on no longer existed. The fix there was a small replacement bundle that supplied the missing pieces, plus a written note of every fix for the next upgrade.
Should it be an extension at all?
Configure the core when:
- the need is really content, which content elements, layouts and the form builder already handle
- a well-maintained extension already exists
- a template override in the site's
templates/folder is enough
Build an extension when:
- you need your own data with its own back-end screens
- the logic should be reusable across several sites
- data has to come in from another system, on a schedule or on demand
- a feature has to survive upgrades, rather than living as a hack in a template
Our own list shows the pattern: rep and showroom finders, member imports from other systems, a certification test-results import, a guard that bans visitors generating floods of 404 errors, Isotope product sorting and grouped filtering, related products that update when a shopper switches product variant, a member password-reset email module that runs on both Contao 5.3 and 5.7, and site search that shows images from pages, news and products together.
Each one gets its own repository with a README, and we develop and test on a local copy of the real site. We take a backup before installing on live. Installs run as the site's own user, never as root, so caches are built with the right permissions. The Contao developer documentation covers every piece above in more depth.