Ein Kunde ruft an: Er kommt nicht ins Kundenportal. Benutzername vergessen. Die E-Mail-Adresse weiß er, das Passwort auch – nur den vor drei Jahren vergebenen Benutzernamen nicht. Solche Anrufe kosten Zeit, jedes Mal. Die naheliegende Lösung: den Benutzernamen abschaffen und die E-Mail-Adresse zur Anmeldung nutzen.

Kurz gesagt: Das geht in Contao ohne Erweiterung. Zwei Hooks setzen die E-Mail automatisch als Benutzernamen, die DCA sperrt das Feld im Backend. Wichtig sind dabei die Punkte, die man leicht übersieht – Normalisierung, Eindeutigkeit und die Frage, ob das Sperren im Backend tatsächlich sicher ist. Genau darum geht es in diesem Beitrag: um die Umsetzung und um die Härtung.

Zielsetzung

In Contao sollen Mitglieder sich mit ihrer E-Mail statt einem separaten Benutzernamen anmelden. Bei Registrierung wird die E-Mail automatisch als Username gespeichert. Im Backend bleibt das Feld sichtbar, ist aber schreibgeschützt.

Keine Notwendigkeit für Erweiterungen wie

Die Umsetzung erfolgt Core-konform und bleibt update-sicher.

Registrierung: E-Mail automatisch als Benutzername setzen

config/services.yaml

services:
  App\EventListener\RegistrationUsernameListener:
    tags:
      - { name: contao.hook, hook: createNewUser, method: onCreateNewUser }
      - { name: contao.hook, hook: updatePersonalData, method: onUpdatePersonalData }

src/EventListener/RegistrationUsernameListener.php

<?php

namespace App\EventListener;

use Contao\Database;
use Contao\FrontendUser;
use Contao\Module;

final class RegistrationUsernameListener
{
    public function onCreateNewUser(int $memberId, array $data, ?Module $module = null): void
    {
        if (empty($data['email'])) {
            return;
        }

        $email = mb_strtolower(trim((string) $data['email']));

        Database::getInstance()
            ->prepare('UPDATE tl_member SET username=? WHERE id=?')
            ->execute($email, $memberId);
    }

    public function onUpdatePersonalData(FrontendUser $member, array $data, Module $module): void
    {
        if (!isset($data['email']) || '' === trim((string) $data['email'])) {
            return;
        }

        $email = mb_strtolower(trim((string) $data['email']));

        Database::getInstance()
            ->prepare('UPDATE tl_member SET username=? WHERE id=?')
            ->execute($email, $member->id);
    }
}

Backend: Feld sperren und absichern

contao/dca/tl_member.php

<?php

$GLOBALS['TL_DCA']['tl_member']['fields']['username']['eval']['readonly'] = true;
$GLOBALS['TL_DCA']['tl_member']['fields']['username']['eval']['tl_class'] = trim(($GLOBALS['TL_DCA']['tl_member']['fields']['username']['eval']['tl_class'] ?? '') . ' username-locked');
$GLOBALS['TL_DCA']['tl_member']['fields']['username']['save_callback'][] = [\App\EventListener\DataContainer\UsernameGuard::class, 'forceEmailAsUsername'];
$GLOBALS['TL_DCA']['tl_member']['fields']['email']['save_callback'][] = [\App\EventListener\DataContainer\UsernameGuard::class, 'syncUsernameFromEmail'];

src/EventListener/DataContainer/UsernameGuard.php

<?php

namespace App\EventListener\DataContainer;

use Contao\DataContainer;
use Contao\Database;

final class UsernameGuard
{
    public function forceEmailAsUsername($value, DataContainer $dc)
    {
        if (!$dc->id) {
            return $value;
        }

        $row = Database::getInstance()
            ->prepare('SELECT email FROM tl_member WHERE id=?')
            ->execute($dc->id);

        $email = mb_strtolower(trim((string) $row->email));

        return $email ?: $value;
    }

    public function syncUsernameFromEmail($value, DataContainer $dc)
    {
        $email = mb_strtolower(trim((string) $value));

        if ($dc->id && '' !== $email) {
            Database::getInstance()
                ->prepare('UPDATE tl_member SET username=? WHERE id=?')
                ->execute($email, $dc->id);
        }

        return $email;
    }
}

Stylesheet: Backend-Styles

config/config.yml

contao:
    backend:
        custom_css:
            - files/theme/css/backend.css

files/theme/css/backend.css

#ctrl_username[readonly],
.tl_text.username-locked[readonly] {
  cursor: not-allowed;
}

Backend: Sprachtext

contao/languages/de/tl_member.php

<?php
$GLOBALS['TL_LANG']['tl_member']['username'][1] = 'Der Benutzername wird automatisch aus der E-Mail gesetzt.';

Sicherheitscheck: was diese Lösung richtig macht – und was noch fehlt

Eine Lösung, die den Benutzernamen an die E-Mail koppelt, berührt die Anmeldung – den sensibelsten Teil einer Website. Vier Punkte entscheiden über die Qualität. Zwei davon sind in der oben gezeigten Umsetzung bereits gelöst; zwei muss man selbst nachziehen.

1. Erzwingen statt nur sperren – hier bereits gelöst

Ein Feld mit 'readonly' => true zu versehen, verhindert die Eingabe im Formular. Ein manipulierter POST könnte den Wert aber trotzdem mitschicken. Genau deshalb hängt in der gezeigten Lösung zusätzlich ein save_callback am Feld: UsernameGuard::forceEmailAsUsername() liest die E-Mail des Datensatzes und gibt sie zurück – unabhängig davon, was im Formular stand. Ein zweiter Callback am E-Mail-Feld (syncUsernameFromEmail()) hält beide Werte synchron. Das ist die richtige Architektur: Die Sperre im Backend ist Komfort, der Callback ist die Sicherheit.

2. Normalisieren – hier ebenfalls gelöst

„Max.Muster@Beispiel.de" und „max.muster@beispiel.de" sind dieselbe Person, aber zwei Zeichenketten. Ohne Vereinheitlichung entstehen doppelte Konten. Die Listener verwenden durchgängig mb_strtolower(trim(...)) – bei der Registrierung, bei der Profiländerung und im Backend. Auch die Datenbankzugriffe laufen über prepare()/execute(), sind also gegen SQL-Injection abgesichert.

3. Eindeutigkeit in der Datenbank erzwingen – das gehört noch dazu

Solange die Eindeutigkeit nur im Formular geprüft wird, kann sie umgangen werden: zwei gleichzeitige Registrierungen, ein Import, ein Skript. Der Riegel gehört in die Datenbank:

-- Zuerst prüfen, ob es bereits Dubletten gibt
SELECT LOWER(email) AS mail, COUNT(*) AS anzahl
FROM tl_member
GROUP BY LOWER(email)
HAVING anzahl > 1;

-- Wenn sauber: Eindeutigkeit absichern
ALTER TABLE tl_member
  ADD UNIQUE INDEX idx_member_email (email);

4. Kollisionen aus dem Altbestand – der unterschätzte Fall

Kritisch wird es bei gewachsenen Mitgliederdaten: Wenn Mitglied A den alten Benutzernamen „info@firma.de" trägt und Mitglied B genau diese E-Mail-Adresse hat, existieren nach der Umstellung zwei Konten mit demselben Anmeldenamen. Vor dem Livegang lohnt deshalb ein Blick:

-- Kollisionen zwischen bestehenden Benutzernamen und E-Mail-Adressen
SELECT m1.id AS username_id, m1.username,
       m2.id AS email_id,    m2.email
FROM tl_member m1
JOIN tl_member m2
  ON LOWER(m1.username) = LOWER(m2.email)
WHERE m1.id <> m2.id;

Und zwei Dinge, die man im Kopf behalten sollte

  • Die Funktion „Passwort vergessen" darf nicht verraten, ob eine Adresse registriert ist. Contao verhält sich hier korrekt und meldet immer denselben Text – wer eigene Formulare baut, sollte das beibehalten.
  • Eine E-Mail-Adresse ist öffentlich bekannt und damit ein halber Anmeldename. Für Bereiche mit erhöhtem Schutzbedarf gehört die Zwei-Faktor-Authentifizierung dazu – Contao bringt sie mit.

Stand 2026: Die gezeigte Umsetzung funktioniert unverändert unter Contao 5.3 und 5.7. Die Hooks createNewUser und updatePersonalData sind weiterhin Teil des Kerns.

Fazit: E-Mail-Login ohne Erweiterung – aber mit Härtung

Der Anruf vom Anfang – „Ich komme nicht ins Portal“ – verschwindet mit dieser Umstellung aus dem Alltag: Niemand vergisst seine E-Mail-Adresse. Wenige Zeilen Code stellen Registrierung, Profiländerung und Backend-Pflege auf E-Mail-Login um – core-konform, updatesicher und ohne eine einzige Erweiterung.

Der eigentliche Qualitätsunterschied liegt allerdings nicht im Umschalten, sondern in der Härtung: save_callbacks statt bloßem readonly, konsequente Normalisierung, ein UNIQUE-Index in der Datenbank und der Kollisions-Check im Altbestand. Wer diese vier Punkte abhakt, hat nicht nur einen bequemeren Login – sondern einen sichereren.

Zurück zur Blog-Übersicht