Contao Schema Manager erweitern: Eigene Datenquellen anbinden

  • 8 min
  • Written von Markus Milkereit
Codekarte mit zwei Feldern: links eine PHP-Klasse mit TypeDefinition 'example.equipment' (Felder name und capacity) und RelationshipDefinition 'example.manufacturer'; rechts die services.yaml mit der Zeile autoconfigure: true und dem Hinweis, dass keine Änderung am Kern nötig ist.
Die Registrierung in einem Bild: ein Typ mit Feldern und eine Beziehung in PHP, dazu eine services.yaml mit autoconfigure.

Seit Version 1.4 kannst du die Schema.org-Ausgabe des Contao Schema Managers um Daten aus eigenen Bundles erweitern, per versionierter API, ohne Fork und ohne SQL in Kerntabellen. Hier siehst du, wie du Typen, Beziehungen und eine Datenquelle anbindest und wo die Grenzen liegen.

Was ist die Extension API des Contao Schema Managers und wer braucht sie?

Die Extension API ist eine versionierte Schnittstelle, über die andere Bundles eigene Daten in den Schema-Graphen des Schema Managers einbringen. Sie ist seit Version 1.4.0 vom 22. Oktober 2026 verfügbar und liegt im Namespace VHUG\SchemaManagerBundle\Extension\V1. Die Details stehen in der API-Dokumentation.

Gedacht ist sie für dich, wenn du ein eigenes Bundle pflegst: Immobilien, Stellenanzeigen, Geräte, Kataloge. Deren Daten sollen im JSON-LD auftauchen, ohne dass du den Schema Manager forkst. Kennst du ihn noch nicht, lies zuerst die Ankündigung. Wie strukturierte Daten grundsätzlich wirken, steht im Artikel zu Schema Markup.

Warum reicht der Kern des Schema Managers nicht aus?

Die Kern-Erweiterung kennt ihre eigenen Entitäten, etwa Organisation, Personen, Services, Orte (vor allem für Events), Seiten, News und Events. Was ihr fehlt, sind Daten aus anderen Bundles mit eigenen Tabellen: keine Gerätedatenbank, kein Immobilienbestand. Das Ziel der API: Dein Bundle bleibt die Quelle der Daten, der Kern kümmert sich um den Rest.

Das heißt konkret: Dein Bundle beschreibt, welche Typen, Felder und Beziehungen es hat, und liefert Datensätze auf Anfrage, während Identität, Graph-Aufbau und Serialisierung im Kern bleiben, damit du dich weder um eindeutige IDs noch um das Ausgabeformat kümmern musst. Du schreibst nie SQL in Core-Tabellen.

Wie ist die Extension API aufgebaut?

Du implementierst bis zu sechs Interfaces, und weil eine Klasse mehrere davon tragen darf, bleibt der Aufwand überschaubar. Markiert werden sie über Autoconfiguration mit je einem Tag. Der Kern sammelt sie ein, prüft ihre Metadaten beim Kompilieren, baut den Graphen und serialisiert das JSON-LD. Mapper können dabei keine beliebigen Referenzen, Kontexte oder Nachbar-Entitäten ausgeben.

Drei weitere Interfaces rufst du nur auf: BindingManagerInterface, EntityEditorInterface und SourceInvalidatorInterface. Die Kompilierung prüft die Metadaten und bricht bei Fehlern ab. Doppelte Schlüssel, unbekannte Endpunkte oder fehlende Services fallen also schon dort auf, nicht erst in der Ausgabe.

Architekturdiagramm in zwei Flächen. Links Dein Bundle mit sechs Interfaces und ihren Tags, von SourceAdapterInterface bis PayloadMigrationInterface, darüber die eigene Datenquelle. Rechts der Schema Manager mit Identität, Graph-Aufbau, Serialisierung und JSON-LD-Ausgabe. Dazwischen die öffentliche API V1, drei Interfaces zum Aufrufen und ein Hinweis, dass der Kern die Identität besitzt.
Wer macht was: Dein Bundle liefert Typen, Beziehungen und Quellen, der Kern besitzt Identität, Graph und Serialisierung.

Die sechs Interfaces im Überblick

  • Source​Adapter​Interface

    Findet Datensätze deiner Quelle mit findCandidates() und löst sie mit resolve() zu unveränderlichen Snapshots auf.

  • Type​Provider​Interface

    Deklariert mit types() Typen samt Feldern, Schema.org-Typen, Beziehungen und schemaVersion.

  • Relationship​Provider​Interface

    Deklariert mit relationships() Beziehungen zwischen Typen, samt Endpunkten, Kardinalität und Property.

  • Entity​Type​Mapper​Interface

    Übersetzt mit map() die aufgelösten Felder in Properties eines Knotens.

  • Entity​Type​Validator​Interface

    Prüft mit validate() eine Entität und meldet Verstöße.

  • Payload​Migration​Interface

    Überführt gespeicherte Zusatzdaten von einer schemaVersion zur nächsten.

Ein Tag pro Interface

Interface Tag
SourceAdapterInterface schema_manager.source_adapter
TypeProviderInterface schema_manager.type_provider
RelationshipProviderInterface schema_manager.relationship_provider
EntityTypeMapperInterface schema_manager.type_mapper
EntityTypeValidatorInterface schema_manager.type_validator
PayloadMigrationInterface schema_manager.payload_migration

Wie sieht ein minimaler Provider aus?

Das Beispiel-Bundle examples/extension im Repository zeigt einen Provider für fiktive Geräte. Es ist synthetisch und wird nie automatisch registriert: Du importierst die services.yaml nur in einer Entwicklungsumgebung, nachdem du den PSR-4-Namespace SchemaManagerExample eingerichtet hast (Beispiel-Bundle).

Die folgenden Auszüge stammen daraus, nur für die Lesbarkeit umgebrochen. Prüfe die Namen immer gegen die installierte Version.

Registrierung per Autoconfiguration

Mit autoconfigure: true erkennt Symfony die Interfaces und setzt die Tags selbst. Eine Klasse darf mehrere Interfaces tragen: Der ExampleProvider ist Type-Provider, Relationship-Provider und Mapper in einem. Provider sind deklarative Services ohne Pflichtargumente, der Adapter darf dagegen Repositories und Routing injizieren.

# services.yaml
services:
    SchemaManagerExample\ExampleProvider:
        autoconfigure: true
    SchemaManagerExample\ExampleSourceAdapter:
        autoconfigure: true
        arguments:
            $stateFile: '%kernel.project_dir%/var/schema-example-source.json'

Ein Typ mit typisierten Feldern

Ein Typ trägt einen namespaced Schlüssel wie example.equipment, ein Schema.org-Typ ist hier ProductModel. Das Feld capacity ist eine Messgröße aus Wert, Einheit und Basis, im Beispiel eine Leistung in kW. Die drei Teile stehen getrennt, weil ein Wert allein nichts aussagt: Bei einer Wohnfläche etwa macht erst die Basis (zum Beispiel DIN 277) den Wert vergleichbar. Feldschlüssel sind lokale Bezeichner, Labels ändern nie die Identität eines bestehenden Eintrags.

// ExampleProvider.php (Auszug)
use VHUG\SchemaManagerBundle\Extension\V1 as Api;

public function types(): iterable
{
    yield new Api\TypeDefinition(
        'example.equipment',
        'Example equipment',
        ['ProductModel'],
        self::class,
        fields: [
            new Api\FieldDefinition('name', required: true),
            new Api\FieldDefinition('description', localized: true, widget: 'textarea'),
            new Api\FieldDefinition('capacity', 'measurement', widget: 'measurement'),
            new Api\FieldDefinition('notes', widget: 'textarea'),
        ],
        relationships: ['example.manufacturer', 'example.related'],
        properties: ['name', 'description', 'additionalProperty'],
    );
}

Beziehungen und Mapper

Der vierte Parameter von TypeDefinition, hier self::class, ist die Service-ID des Mappers. properties listet die Prädikate, die der Mapper ausgeben darf. Beziehungen deklarierst du pro Richtung einzeln, eine automatische Gegenrichtung gibt es nicht. allowCycles: false verbietet Zyklen bei example.related.

// ExampleProvider.php (Auszug, gleiche Klasse)
public function relationships(): iterable
{
    yield new Api\RelationshipDefinition(
        'example.manufacturer',
        'Manufacturer',
        ['example.equipment'],
        ['Organization'],
        property: 'manufacturer',
        multiple: false,
    );
    yield new Api\RelationshipDefinition(
        'example.related',
        'Related equipment',
        ['example.equipment'],
        ['example.equipment'],
        allowCycles: false,
    );
}

public function map(Api\ResolvedEntity $entity, Api\MappingContext $context): Api\NodeContribution
{
    $properties = array_intersect_key($entity->fields, array_flip(['name', 'description']));
    if ($measure = $entity->fields['capacity'] ?? null) {
        $properties['additionalProperty'] = $context->measurement(
            $measure['value'],
            $measure['unit'],
            $measure['basis'],
        );
    }

    return new Api\NodeContribution($properties);
}

Die Datenquelle anbinden

Der SourceAdapter verbindet deine Daten mit dem Graphen. key() und describe() sind reine Metadaten und dürfen nicht vom Zustand des Konstruktors abhängen. findCandidates() liefert sichere Kurzfassungen für die Suche, standardmäßig 50 pro Seite, höchstens 100, ohne etwas zu binden oder zu veröffentlichen. resolve() liefert entweder einen unveränderlichen Snapshot (eine Momentaufnahme der Felder) oder einen Status wie missing, unpublished oder forbidden, dann ohne Fakten.

Wenn du eine Quelle an eine Entität bindest, entsteht zunächst ein Entwurf. Eine bestehende Identität verknüpfst du ausdrücklich, nie per Namens- oder URL-Abgleich. Veröffentlicht wird weiter im normalen Contao-Redaktionsablauf.

Die Skizze unten nutzt die echten Methodennamen aus dem Beispiel-Adapter. EquipmentRepository steht für dein eigenes Repository und gehört nicht zur API.

// EquipmentSourceAdapter.php (Skizze, nicht aus dem Beispiel-Bundle kopiert)
use VHUG\SchemaManagerBundle\Extension\V1 as Api;

final class EquipmentSourceAdapter implements Api\SourceAdapterInterface
{
    public function __construct(private readonly EquipmentRepository $equipment) {}

    public function key(): string
    {
        return 'example.inventory';
    }

    public function describe(): Api\SourceDefinition
    {
        // Typ-Schlüssel, Felder der Quelle (global), Felder der Quelle (lokalisiert)
        return new Api\SourceDefinition(['example.equipment'], ['name'], ['description']);
    }

    public function findCandidates(Api\CandidateQuery $query): Api\CandidatePage
    {
        if (!$query->context->authorized) {
            return new Api\CandidatePage([]);
        }

        $items = [];
        foreach ($this->equipment->search($query->search, $query->limit) as $row) {
            $items[] = new Api\Candidate(
                new Api\SourceReference($this->key(), $query->context->site, (string) $row->id),
                $row->name,
                'example.equipment',
                $row->revision,
            );
        }

        return new Api\CandidatePage($items);
    }

    public function resolve(Api\SourceReference $source, Api\ReadContext $context): Api\SourceResolution
    {
        if ($source->installationScope !== $context->site) {
            return new Api\SourceResolution('forbidden');
        }

        $row = $this->equipment->find($source->subjectKey);
        if ($row === null) {
            return new Api\SourceResolution('missing');
        }
        if (!$row->isPublished() && $context->purpose !== 'preview') {
            return new Api\SourceResolution('unpublished');
        }

        return new Api\SourceResolution('available', new Api\SourceSnapshot(
            $source,
            $row->revision,
            'example.equipment',
            ['name' => $row->name],
            ['description' => $row->description($context->language)],
            $row->url,
        ));
    }
}

Wie funktionieren Identität und Autorität einer Quelle?

Jeder Datensatz wird über eine SourceReference identifiziert, bestehend aus adapterKey, installationScope und dem unveränderlichen subjectKey. Pro Entität gibt es genau eine autoritative Quelle. Ihre Felder bleiben schreibgeschützt, auch wenn sie leer sind, und zurück in die Quelle schreibt der Schema Manager nie.

Nimm als subjectKey die fachliche ID aus deinem System, wenn Importe Zeilen neu anlegen. Zeilen-IDs, Seiten-IDs, Aliase und lokalisierte Startseiten bleiben getrennte Dinge. Übersetzungen teilen sich einen Gegenstand (subjectKey), eine Zeile kann mehrere ausdrückliche Gegenstände haben.

Automatische Quellen, neu in 1.4.0

Neu in 1.4.0 sind automatische Quellen: Mit SourceDefinition(automatic: true) erklärst du, dass jeder Fakt und jede Beziehung dem Originaldatensatz gehört. Die öffentliche ID speicherst du dort und gibst sie über SourceSnapshot(publicIdentity: ...) zurück. Bereits veröffentlichte IDs übernimmst du über eine ausdrückliche Migration, du vergibst nie Ersatz-IDs. Für neue und kopierte Datensätze vergibst du frische IDs.

Geprüft haben wir die automatischen Quellen unter anderem auf der Live-Seite unseres Kunden OKAL: 449 automatische Quellen, unveränderte öffentliche IDs, 251 öffentliche Quellseiten, 72 öffentliche News-Artikel und 31 Beziehungsgraphen für Musterhäuser.

Automatische Zeilen fehlen in den manuellen Editoren, ergänzende Schreibzugriffe lehnt der Schema Manager ab. Vorhandene Adapter bleiben bei automatic: false und behalten ihren Bearbeitungsablauf. Setze automatic: true nur, wenn wirklich jedes Feld deiner Typen der Quelle gehört, sonst lässt du es aus: Ergänzende Felder wie notes aus dem Beispiel oben würden zur Laufzeit ignoriert.

// Skizze, abgeleitet aus den Signaturen von SourceDefinition und SourceSnapshot
// automatic: true nur, wenn ALLE Felder und Beziehungen des Typs der Quelle gehören.
// Gehört auch nur ein Feld nicht dazu (etwa ein ergänzendes), lass automatic aus.
public function describe(): Api\SourceDefinition
{
    return new Api\SourceDefinition(
        typeKeys: ['example.equipment'],
        ownedGlobalFields: ['name', 'capacity', 'notes'],
        ownedLocalizedFields: ['description'],
        ownedRelationships: ['example.manufacturer', 'example.related'],
        automatic: true,
    );
}

// in resolve(): die auf deinem Originaldatensatz gespeicherte ID zurückgeben
new Api\SourceSnapshot(
    source: $source,
    revision: $row->revision,
    typeKey: 'example.equipment',
    globalFields: ['name' => $row->name, 'capacity' => $row->capacity, 'notes' => $row->notes],
    localizedFields: ['description' => $row->description($context->language)],
    url: $row->url,
    relationships: $row->relationships(), // Platzhalter für deine Beziehungswerte
    publicIdentity: $row->publicId,
);

Was passiert bei Ausfällen, und wie bleibt der Cache aktuell?

Fehlt ein Provider oder Adapter, gibt der Schema Manager in diesem Fall nichts aus (fail closed). Gespeicherte Identitäten und Payloads bleiben erhalten und kehren nach einer Neuinstallation zurück, und ein automatisches Aufräumen löscht nichts. Das Verfolgen von Beziehungen ist auf einen unterstützenden Schritt und 100 Identitäten begrenzt.

Für den Cache rufst du SourceInvalidatorInterface::changed() nach einer abgeschlossenen Änderung einer Quelle auf, oder adapterChanged() nach einem Import-, Routing- oder Konfigurationsstapel. Hältst du eine äußere Transaktion, invalidierst du nach deren Commit. Sichtbarkeitsfristen wie nextVisibilityChange begrenzen die HTTP-Cache-Dauer.

Version 1 invalidiert bewusst ein gemeinsames Tag der Erweiterung statt eines Rückwärts-Index. Das ist konservativ gedacht: Alle Schema-Antworten tragen es, auch fehlende oder unveröffentlichte.

// Skizze: Cache nach einer abgeschlossenen Änderung leeren
use VHUG\SchemaManagerBundle\Extension\V1\SourceInvalidatorInterface;
use VHUG\SchemaManagerBundle\Extension\V1\SourceReference;

final class EquipmentSaveListener
{
    public function __construct(private readonly SourceInvalidatorInterface $invalidator) {}

    public function afterSave(string $site, string $subjectKey): void
    {
        // einzelner Datensatz, nach dem Commit
        $this->invalidator->changed(new SourceReference('example.inventory', $site, $subjectKey));
    }

    public function afterImport(): void
    {
        // Import-, Routing- oder Konfigurationsstapel
        $this->invalidator->adapterChanged('example.inventory');
    }
}

Wie funktionieren Versionen und Migrationen?

Jeder Typ trägt eine schemaVersion. Passt sie nicht zu den gespeicherten Daten, meldet der Schema Manager migration_required und gibt keine veraltete Payload aus. Du registrierst aufeinanderfolgende PayloadMigrationInterface-Klassen und führst sie erst als Trockenlauf, dann mit --apply aus.

Der Trockenlauf schreibt nichts. --apply prüft Version und Revision atomar, validiert die umgeformten Payloads, erhält Identität und Quellen-Autorität und invalidiert die Ausgabe. Eine Migration läuft nie beim Rendern. Umbenennungen von Typ- oder Beziehungsschlüsseln brauchen eine eigene, geprüfte Migration. Die Codeskizzen in diesem Beitrag sind aus den echten Signaturen abgeleitet, zur Illustration gedacht und von uns nicht ausgeführt.

// Skizze: Zusatzfeld capacity wird in Version 2 zu ratedCapacity
// Dazu im Typ den FieldDefinition-Schlüssel auf 'ratedCapacity' ändern
// und in der TypeDefinition schemaVersion: 2 setzen.
use VHUG\SchemaManagerBundle\Extension\V1 as Api;

final class EquipmentCapacityRename implements Api\PayloadMigrationInterface
{
    public function typeKey(): string
    {
        return 'example.equipment';
    }

    public function fromVersion(): int
    {
        return 1;
    }

    public function toVersion(): int
    {
        return 2;
    }

    public function global(array $values): array
    {
        if (array_key_exists('capacity', $values)) {
            $values['ratedCapacity'] = $values['capacity'];
            unset($values['capacity']);
        }

        return $values;
    }

    public function localized(array $values, string $language): array
    {
        return $values;
    }
}
php vendor/bin/contao-console schema-manager:extension:migrate ENTITY_ID
php vendor/bin/contao-console schema-manager:extension:migrate ENTITY_ID --apply

Wo liegen die Grenzen der Extension API?

Version 1 ist bewusst schmal gehalten. Die Konsole für Erweiterungsdaten ist nur für Administratoren da, und wir liefern weder Projekt-Adapter noch einen Branchenkatalog mit. Das Beispiel-Bundle ist synthetisch. Die Liste unten nennt die Grenzen im Einzelnen.

  • Konsole nur für Administratoren
    Integrierende Bundles können über die öffentlichen Interfaces eigene, rechte-bewusste Abläufe für andere Nutzer anbieten.
  • Keine Projekt-Adapter, kein Branchenkatalog
    Du schreibst Typen, Beziehungen und Adapter selbst. Entitäten anlegen, binden und veröffentlichen bleibt in v1 manuell.
  • Pending Vocabulary ist standardmäßig aus
    Pending Predicates (noch nicht freigegebene Prädikate) brauchen eine Pending-Beziehungsdeklaration und den Parameter schema_manager.extension.allow_pending=true. Einzelne Beziehungsschlüssel lassen sich über schema_manager.extension.approved_pending_relationships freigeben.
  • Gültiges Vokabular ist keine Rich-Result-Garantie
    Dass ein Property zum Schema.org-Vokabular passt, sagt nichts über Rich Results. Ranking oder KI-Zitate versprechen wir nicht, die Belege sind dünn, wie unser Artikel zu Schema Markup zeigt.
  • Kein Zurückschreiben
    Es gibt keine Möglichkeit, in die Quelle zurückzuschreiben oder ihre Werte zu überschreiben. Beim Rendern fallen weder Quellen-Schreibzugriffe noch KI-Aufrufe an.

Wo fängst du an?

Installiere Version 1.4 oder neuer, führe die additiven Datenbank-Updates von Contao aus und baue den Cache neu auf. Danach lies das Beispiel-Bundle und die Tests: Das Repository bringt composer test sowie tests/extension-integration.php mit, das du in einer installierten Anwendung ausführst.

Die API-Dokumentation liegt auf GitHub, das Paket auf Packagist. Funktionen und Installation der Erweiterung selbst stehen auf der Erweiterungsseite.

composer require vonheldenundgestalten/contao-schema-manager:^1.4 --with-all-dependencies

Häufige Fragen zur Extension API

  • Nein. Du registrierst Symfony-Services mit den Tags der öffentlichen Interfaces im Namespace Extension\V1. Klassen unter Internal, bestehende Models und Emitter sind Implementierungsdetails. SQL in Kerntabellen ist nicht vorgesehen.

  • Nein. Zurückschreiben oder Überschreiben ist nicht vorgesehen. Felder, die deine Quelle besitzt, bleiben schreibgeschützt, auch wenn sie leer sind. Ergänzende Felder speichert der Schema Manager als eigene typisierte Werte, bei automatischen Quellen entfallen auch diese.

  • Der Schema Manager gibt in diesem Fall nichts aus (fail closed): Die Ausgabe entfällt, gespeicherte Identitäten und Payloads bleiben erhalten. Installierst du das Bundle wieder, kehren die Identitäten zurück. Ein automatisches, zerstörerisches Aufräumen gibt es nicht.

  • Erhöhe die schemaVersion des Typs und registriere eine PayloadMigrationInterface-Klasse. Führe schema-manager:extension:migrate ENTITY_ID zuerst als Trockenlauf aus, dann mit --apply. Umbenennungen von Typ- oder Beziehungsschlüsseln brauchen eine eigene, geprüfte Migration.

  • Ja, aber nur bewusst. Die Voreinstellung ist aus. Du brauchst eine Pending-Beziehungsdeklaration und schema_manager.extension.allow_pending=true, oder du gibst einzelne Schlüssel über schema_manager.extension.approved_pending_relationships frei.

Die vollständige API-Dokumentation liegt auf GitHub, Funktionen und Installation der Erweiterung auf der Erweiterungsseite.

Markus Milkereit

Gründer und leitender Entwickler

Schreibt über Barrierefreiheit, wartungsfreundliche Frontends und die weniger glamourösen Aspekte eines erfolgreichen Website-Betriebs.

Kontakt aufnehmen