Contao - wie geht es heute?

Ein Leitfaden für moderne Best Practices in der Entwicklung mit Contao

Sebastian Zoglowek

  • Nickname: zoglo (GitHub, Forum, Slack, überall)
  • Fullstack-Entwickler
  • Erste Eindrücke seit Contao 4.8 (2019)
  • Contao Core-Entwickler seit Februar 2025
  • Oveleon GbR
  • #Darkmode

Agenda

  1. Warum überhaupt?
  2. Service Container anstatt $GLOBALS
    • Service Tagging in Contao
  3. Permissions mit Votern
  4. Events statt Hooks
  5. Rector (und weitere Tools)
  6. Rector Regeln schreiben

Warum?

  • Symfony seit Contao 4 und seit Jahren etabliert
  • Das Rad nicht neu erfinden
  • Standards nutzen

Das News-Bundle


class ModuleNewsReader extends ModuleNews
{

  public function generate()
	{
		if (TL_MODE == 'BE')
		{
			$objTemplate = new BackendTemplate('be_wildcard');

			$objTemplate->wildcard = '### NEWS READER ###';
			$objTemplate->title = $this->headline;
			$objTemplate->id = $this->id;
			$objTemplate->link = $this->name;
			$objTemplate->href = 'contao/main.php?do=themes&table=tl_module&act=edit&id=' . $this->id;

			return $objTemplate->parse();
		}

		// Return if no news item has been specified
		if (!$this->Input->get('items'))
		{
			return '';
		}

		$this->news_archives = $this->sortOutProtected(deserialize($this->news_archives));

		// Return if there are no archives
		if (!is_array($this->news_archives) || count($this->news_archives) < 1)
		{
			return '';
		}

		return parent::generate();
	}

Das News-Bundle (als schlechtes Beispiel)

News-Bundle Commit 2008 Der erste commit

Best Practices - seit 2016

2016 - Contao 4 Extension Development

(David Greminger)

Video zum Konferenzvortrag

2017 - Extension from scratch in Contao 4

(Christian Schiffler)

Video zum Konferenzvortrag

Zwei verwaiste Plugins als Beispiel

Image graveyard with two plugins

Verfügbar?
Klar!

https://github.com/contao-graveyard/stylepicker

https://github.com/contao-graveyard/googlemaps

Stylepicker4ward

dlh_googlemaps

Stylepicker before Maps before

Manual installation: copy all files to system/modules/

Step 1

Blind umschreiben in ein Contao-Manager-Bundle

Step 2: Use Rector

Person waiting Rector console

Zack feddig

"Re(fa)ctored"

Stylepicker4ward

dlh_googlemaps

Stylepicker after Stylepicker after

Vortrag zu Ende

Vortrag zu Ende ... ?

Must refactor

Service Container anstatt $GLOBALS

$GLOBALS

Teil der Superglobals - interne Variablen, die überall verfügbar sind

Assoziatives Array, das Referenzen auf alle Variablen enthält

Service Container

Ein PHP-Objekt, welches für die Instanziierung von Objekten bzw. Services zuständig ist.

Es weiß über seine Abhängigkeiten Bescheid, da alles (vor-)konfiguriert

https://symfony.com/doc/current/components/dependency_injection.html

Services

  • PHP Objekte
  • Stateless
  • Sollen austauschbar sein
  • Werden konfiguriert in der "services.yaml"
  • Per Default nur eine Instanz / Singleton
  • Kann man testen

Database::getInstance()


class Database
{
  protected function __construct()
	{
		$this->resConnection = System::getContainer()->get('database_connection');

		if (!\is_object($this->resConnection)) {
			throw new \Exception(\sprintf('Could not connect to database (%s)', $this->error));
		}
	}

  public static function getInstance()
  {
    return static::$objInstance ??= new static();
  }
          

Vorher


$objContent = Database::getInstance()
  ->prepare('SELECT type,pid FROM tl_content WHERE id=?')
  ->execute($id)
;

$id = $objContent->pid;
$cond = $objContent->type;
          

Nachher


class StylePickerController extends AbstractBackendController
{
  public function __construct(
    private readonly Connection $connection // Datenbank Service
  ) {}

  // ...
  $objContent = $this->connection->fetchAssociative(
    'SELECT type, pid FROM tl_content WHERE id = ?', [$id]
  );

  $id = $objContent['pid'] ?? null;
  $condition = $objContent['type'] ?? null;
        

Service-Tagging

und wie das Contao CMS es für Entwickler vereinfacht

Beispiel eines Content-Elements

Warum sollte ich meine Inhaltselemente umschreiben?

  • Fragment Controller
  • Seit Symfony 2.2 (aber auch in 2.0)
  • Fragmente = Schnipsel
  • Caching von Schnipseln auf der Page (ESI)
  • Mit Twig viel mehr möglich :)

Services müssen in der services.yaml definiert werden


services:
    _defaults:
        autoconfigure: true

    ContaoGraveyard\GoogleMapsBundle\Controller\ContentElement\GoogleMapsController:
        tags:
            -
                type: dlh_googlemaps
                name: contao.content_element
                category: media
                template: ce_dlh_googlemaps_default
        

Geht das nicht einfacher?

Attributes!

(früher auch über Annotations)

  • AsFrontendModule
  • AsContentElement
  • AsPage
  • AsPickerProvider
  • AsCronJob
  • AsHook
  • AsInsertTag
  • AsBlockInsertTag
  • AsInsertTagFlag
  • AsCallback

#[AsContentElement]


#[AsContentElement(
    type: 'dlh_googlemaps',
    category: 'media',
    template: 'ce_dlh_googlemaps_default',
)]
class GoogleMapsController extends AbstractContentElementController
{
          

Und nun?


services:
    _defaults:
        autoconfigure: true

    #ContaoGraveyard\GoogleMapsBundle\Controller\ContentElement\GoogleMapsController: ~
    ...ContentElement\GoogleMapsController: ~

    # Yes, it's invalid, I know
          

Aber was ist mit Autowiring!?

Man looking at autowire being nice

Klar, kannst du machen!


services:
    _defaults:
        autoconfigure: true
        autowire: true

    ContaoGraveyard\GoogleMapsBundle\:
        resource: '../src/'
        exclude: '../src/{Event,Model,Whatever.php}'
          

Aber nicht in public bundles!

Mr. Codestyle wants to talk to you

Leo not allowing autowiring

Public Bundles kennen die Services in deinem Container nicht (in der Theorie)

In der App hat man Kontrolle über alles

Permissions und Voter

Warum?

  • Legacygründe: man hat immer alle Berechtigungen
  • Und verweigert danach
  • Nicht mehr selber Berechtigungen schreiben
  • Lass es das Framework machen
  • C R U D

Wie ging es damals?

DO NOT CHANGE THIS ORDER!


/**
 * Initialize the controller.
 *
 * 1. Import user
 * 2. Call parent constructor
 * 3. Authenticate user
 * 4. Load language files
 * DO NOT CHANGE THIS ORDER!
 */
public function __construct()
{
    $this->import(BackendUser::class, 'User');
    $this->import('Database');

    $this->User->authenticate();

    $this->loadLanguageFile('default');
    $this->loadLanguageFile('modules');
}
          

onLoadCallback!


$GLOBALS['TL_DCA']['tl_dlh_googlemaps'] = [
    'config' => [
        // ...
        'onload_callback' => [
            ['tl_dlh_googlemaps', 'checkPermission'],
        ],
    ],
    //...

public function checkPermission(): void
{
    if ($this->User->isAdmin) {
        return;
    }
    // Set root IDs
    if (!is_array($this->User->dlh_googlemapss) || $this->User->dlh_googlemapss === []) {
        $root = [0];
    } else {
        $root = $this->User->dlh_googlemapss;
    }
    $GLOBALS['TL_DCA']['tl_dlh_googlemaps']['list']['sorting']['root'] = $root;
    // Check permissions to add Maps
    if (!$this->User->hasAccess('create', 'dlh_googlemapsp')) {
        $GLOBALS['TL_DCA']['tl_dlh_googlemaps']['config']['closed'] = \true;
    }
          

// Check current action
switch (Input::get('act')) {
    case 'create':
    case 'select':
        // Allow
        break;
          

    case 'edit':
        // Dynamically add the record to the user profile
        if (!in_array(Input::get('id'), $root, true)) {
            $arrNew = $this->Session->get('new_records');
            if (is_array($arrNew['tl_dlh_googlemaps']) && in_array(Input::get('id'), $arrNew['tl_dlh_googlemaps'], true)) {
                // Viel Code um Berechtigungen auf Gruppenepene upzudaten
                // ...
            }
        }
        // no break;
    case 'copy':
    case 'delete':
    case 'show':
        if (!in_array(Input::get('id'), $root, true) || (Input::get('act') === 'delete' && !$this->User->hasAccess('delete', 'dlh_googlemapsp'))) {
            System::getContainer()->get('monolog.logger.contao')->log(
                LogLevel::ERROR,
                'Not enough permissions to ' . Input::get('act') . ' Map ID "' . Input::get('id') . '"',
                [
                    'contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR),
                ],
            );
            $this->redirect('contao/main.php?act=error');
        }
        break;
          

case 'editAll':
    case 'deleteAll':
    case 'overrideAll':
        $session = $this->Session->getData();
        if (Input::get('act') === 'deleteAll' && !$this->User->hasAccess('delete', 'dlh_googlemapsp')) {
            $session['CURRENT']['IDS'] = [];
        } else {
            $session['CURRENT']['IDS'] = array_intersect($session['CURRENT']['IDS'], $root);
        }
        $this->Session->setData($session);
        break;

    default:
        if ((string) Input::get('act') !== '') {
            System::getContainer()->get('monolog.logger.contao')->log(
                LogLevel::ERROR,
                'Not enough permissions to ' . Input::get('act') . ' Maps',
                [
                    'contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR),
                ],
            );
            $this->redirect('contao/main.php?act=error');
        }
        break;
}
          

Voters

This feature is available in Contao 4.7 and later.

This feature is available in Contao 4.10 and later.

This feature is available in Contao 4.12 and later.

This feature is available in Contao 5.0 and later.

Jetzt aber!


class GoogleMapsAccessVoter extends AbstractDataContainerVoter
{
    public function __construct(private readonly AccessDecisionManagerInterface $accessDecisionManager)
            {}

    protected function getTable(): string
    {
        return 'tl_dlh_googlemaps';
    }

    protected function hasAccess(TokenInterface $token, CreateAction|DeleteAction|ReadAction|UpdateAction $action): bool
    {
        if (!$this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE . '.dlh_googlemaps'])) {
            return false;
        }

        return match (true) {
            $action instanceof CreateAction => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapsp'], 'create'),
            $action instanceof ReadAction,
            $action instanceof UpdateAction => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapss'], (int) $action->getCurrentId()),
            $action instanceof DeleteAction => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapss'], (int) $action->getCurrentId())
                && $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapsp'], 'delete'),
        };
    }
}
          

Permissions mit CRUD erledigt

Jetzt erstmal löschen!

Setting permissions in the group Overview of operations for a record

Oh... Forbidden

Es fehlen die Button-Callbacks ...

Forbidden

Dafür gibt es jetzt den OperationsBuilder

Diff of deleting operations and using the operations builder

    'label' => [
        'fields' => ['title'],
        'format' => '%s',
        'label_callback' => ['tl_dlh_googlemaps', 'listRecords'],
    ],
    'global_operations' => [
        'all',
    ],
    'operations' => [
        'edit',
        'children',
        'copy',
        'delete',
        'show',
    ],
],
          

* Seit Contao 5.5 kann man die Defaults auch weglassen

Kaum löscht man paar Zeilen Code

Hat man das Feature implementiert

Operations with greyed out permissions

Events statt Hooks!

Aber warum?

  • Hooks greifen auf Globals zu (deprecated)
  • ... ich, Ich, ICH! ICH MUSS ZUERST! (Priority)
  • Events kannst du "decoraten"! (überschreiben)
  • Events sind Objekte

Als Hook


if (isset($GLOBALS['TL_HOOKS']['stylepicker4ward_getFilter']) && is_array($GLOBALS['TL_HOOKS']['stylepicker4ward_getFilter'])) {
    foreach ($GLOBALS['TL_HOOKS']['stylepicker4ward_getFilter'] as $callback) {
        System::importStatic($callback[0]);
        $result = $this->{$callback[0]}->{$callback[1]}($table, $id);
        if (is_array($result)) {
            [$table, $layout, $section, $condition] = $result;
            break;
        }
    }
}
          

Als Event


$event = new GetStylePickerFilterEvent($table, (int) $id);
$this->eventDispatcher->dispatch($event);

$layout = $event->getLayout();
$section = $event->getSection();
$condition = $event->getCondition();
          

Und darauf kannst du hören (EventListener)


#[AsEventListener]
class StylePickerEventListener
{
    public function __invoke(GetStylePickerFilterEvent $event): void
    {
        if ($event->getTable() !== 'my_table') {
            return;
        }

        $event->setLayout(213);
        $event->setCondition('foobar');
        $event->setSection('your_section');
    }
}
          

Rector (und andere Tools)

Übersicht

Meldet Fehler Fixed Fehler
Coding standard PHP_Codesniffer PHP-CS-Fixer, ECS
Logik PHPStan, Psalm Rector

Coding Standard

Modifiziert Code und ist Token-Basiert (Lexing und Tokenisierung)

PHPToken oder `token_get_all`


$source = <<<'code'

          

T_OPEN_TAG
T_WHITESPACE
T_CLASS
T_WHITESPACE
T_STRING
T_CONST
T_WHITESPACE
T_STRING
T_LNUMBER
          

Static analyzers

Basierend auf dem AST (Abstract Syntax Tree) -> Seit PHP 7

Geparsed über PHPParser
(by Nikita Popov / nikic)


            return $this->foo && self::bar();```
          

PhpParser\Node\Stmt\Return_
└─ expr: PhpParser\Node\Expr\BinaryOp\BooleanAnd
   ├─ left:  PhpParser\Node\Expr\PropertyFetch
   |         ├─ var:  PhpParser\Node\Expr\Variable
   |         └─ name: PhpParser\Node\Identifier
   └─ right: PhpParser\Node\Expr\StaticCall
             ├─ class: PhpParser\Node\Name
             ├─ name:  PhpParser\Node\Identifier
             └─ args:  array()
          

Instant Upgrade Tools

RectorPHP
(by Tomas Votruba)


class Foo extends \Controller
{
    public function bar()
    {
        $GLOBALS['TL_DCA']['tl_content']['palettes']['foo'] = '{foo_legend},foo;{bar_legend:hide},bar;{baz_legend:hide},baz';
    }
}
          
Abstract Syntax Tree of a DCA Beispiel
Contao-Rector Beispiel

rector.php



return RectorConfig::configure()
    ->withPhpSets(
        php83: true,
    )
    ->withAttributesSets(
        symfony: true,
        doctrine: true,
    )
    ->withSets([
        ContaoLevelSetList::UP_TO_CONTAO_53,
        ContaoSetList::ANNOTATIONS_TO_ATTRIBUTES,
    ])
    ->withPaths([
        __DIR__ . '/contao',
        __DIR__ . '/src',
    ])
    ->withImportNames(removeUnusedImports: true)
    ->withParallel()
;
          

Rector Regeln schreiben (optional)

Note to yourself, open the IDE

Fragerunde

Danke fürs Zuhören!