Extending the Contao Schema Manager with your own data

  • 8 min
  • Written by Markus Milkereit
Code card with two panels: on the left a PHP class with a TypeDefinition 'example.equipment' (fields name and capacity) and a RelationshipDefinition 'example.manufacturer'; on the right the services.yaml with autoconfigure: true and the note that no core changes are needed.
Registration in one picture: a type with fields and a relationship in PHP, plus a services.yaml with autoconfigure.

Since version 1.4, you can extend Contao structured data with records from your own bundles: the Schema Manager takes them through a versioned API, with no fork and no SQL into core tables. Here's how to connect types, relationships and a data source, and where the limits are.

What is the Contao Schema Manager Extension API, and who needs it?

The Extension API is a versioned interface that lets other bundles contribute their own data to the Schema Manager's schema graph. It has been available since version 1.4.0 of 22 October 2026 and lives in the namespace VHUG\SchemaManagerBundle\Extension\V1. The details are in the API documentation.

It's meant for you if you maintain a bundle of your own: real estate, job listings, equipment, catalogues. Their data should show up in the JSON-LD without you forking the Schema Manager. If you don't know it yet, read the announcement first. How structured data works in general is covered in the article on schema markup.

Why isn't the Schema Manager core enough?

The core extension knows its own entities, such as organisation, people, services, locations (mainly for events), pages, news and events. What it lacks is data from other bundles with their own tables: no equipment database, no property inventory. The API's goal: your bundle stays the source of the data, and the core takes care of the rest.

In practice your bundle describes which types, fields and relationships it has, and returns records on request, while identity, graph assembly and serialization stay in the core, so you don't have to worry about unique IDs or the output format. You never write SQL into core tables.

How is the Extension API built?

You implement up to six interfaces, and because one class may carry several of them the effort stays manageable. Autoconfiguration marks each one with a tag. The core collects them, validates their metadata at compile time, builds the graph and serializes the JSON-LD. Mappers cannot emit arbitrary references, contexts or sibling entities.

Three more interfaces you only call: BindingManagerInterface, EntityEditorInterface and SourceInvalidatorInterface. Compilation validates the metadata and aborts on errors. Duplicate keys, unknown endpoints or missing services therefore show up there, not in the output.

Architecture diagram in two areas. On the left Your bundle with six interfaces and their tags, from SourceAdapterInterface to PayloadMigrationInterface, with your own data source in front. On the right the Schema Manager with identity, graph assembly, serialization and JSON-LD output. In between the public API V1, three interfaces you call, and a note that the core owns identity.
Who does what: your bundle supplies types, relationships and sources, the core owns identity, graph assembly and serialization.

The six interfaces at a glance

  • Source​Adapter​Interface

    Finds records in your source with findCandidates() and resolves them to immutable snapshots with resolve().

  • Type​Provider​Interface

    Declares types with types(), including fields, Schema.org types, relationships and schemaVersion.

  • Relationship​Provider​Interface

    Declares relationships between types with relationships(), including endpoints, cardinality and property.

  • Entity​Type​Mapper​Interface

    Translates the resolved fields into the properties of a node with map().

  • Entity​Type​Validator​Interface

    Checks an entity with validate() and reports violations.

  • Payload​Migration​Interface

    Carries stored supplementary data from one schemaVersion to the next.

One tag per 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

What does a minimal provider look like?

The example bundle examples/extension in the repository shows a provider for fictional equipment. It is synthetic and never auto-registered: you import its services.yaml only in a development application, after setting up the PSR-4 namespace SchemaManagerExample (example bundle).

The excerpts below come from it, only line-wrapped for readability. Always check the names against the version you have installed.

Registration through autoconfiguration

With autoconfigure: true, Symfony recognises the interfaces and sets the tags itself. One class may carry several interfaces: the ExampleProvider is type provider, relationship provider and mapper in one. Providers are declarative services without required constructor arguments, while the adapter may inject repositories and routing services.

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

A type with typed fields

A type has a namespaced key such as example.equipment, and its Schema.org type here is ProductModel. The field capacity is a measurement made of value, unit and basis, in the example a rated power in kW. The three parts stay separate because a bare value says little: for a living area, say, only the basis (for example DIN 277) makes the value comparable. Field keys are local identifiers, and changing a label never retypes an existing identity.

// ExampleProvider.php (excerpt)
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'],
    );
}

Relationships and mapper

The fourth parameter of TypeDefinition, here self::class, is the service ID of the mapper. properties lists the predicates the mapper may emit. You declare relationships one direction at a time, there is no automatic inverse. allowCycles: false forbids cycles for example.related.

// ExampleProvider.php (excerpt, same class)
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);
}

Connecting the data source

The SourceAdapter connects your data to the graph. key() and describe() are pure metadata and must not depend on constructor state. findCandidates() returns safe summaries for search, 50 per page by default and 100 at most, without binding or publishing anything. resolve() returns either an immutable snapshot (a point-in-time copy of the fields) or a status such as missing, unpublished or forbidden, in which case it carries no facts.

Binding creates a draft. You link an existing identity explicitly, never by name or URL matching. Publishing continues through the normal Contao editing workflow.

The sketch below uses the real method names from the example adapter. EquipmentRepository stands for your own repository and isn't part of the API.

// EquipmentSourceAdapter.php (sketch, not copied from the example bundle)
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
    {
        // type keys, source-owned global fields, source-owned localized fields
        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,
        ));
    }
}

How do identity and authority of a source work?

Every record is identified by a SourceReference made of adapterKey, installationScope and the immutable subjectKey. Each entity has exactly one authoritative source. Its fields stay read-only even when empty, and there is no write-back API.

Use the business ID from your own system as subjectKey if imports recreate rows. Row IDs, page IDs, aliases and localized homes stay separate things. Translations share one subject, and one row can expose several explicit subjects.

Automatic sources, new in 1.4.0

New in 1.4.0 are automatic sources: with SourceDefinition(automatic: true) you declare that every fact and relationship belongs to the original record. You persist the public ID there and return it through SourceSnapshot(publicIdentity: ...). You adopt already published IDs through an explicit migration and never allocate replacements. For new and copied records you generate fresh IDs.

We checked automatic sources, among other places, on the live site of our client OKAL: 449 automatic sources, unchanged public IDs, 251 public source pages, 72 public news articles and 31 relationship graphs for show homes.

Automatic rows are absent from the manual editors, and the Schema Manager rejects supplementary writes to them. Existing adapters stay at automatic: false and keep their editing workflow. Set automatic: true only if every field of your types really belongs to the source, otherwise leave it off: supplementary fields such as notes in the example above would be ignored at runtime.

// Sketch, derived from the signatures of SourceDefinition and SourceSnapshot
// automatic: true only if ALL fields and relationships of the type belong to the source.
// If even one field does not (a supplementary one, say), leave automatic off.
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(): return the ID stored on your original record
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(), // placeholder for your relationship values
    publicIdentity: $row->publicId,
);

What happens when a source fails, and how does the cache stay fresh?

If a provider or adapter is missing, the Schema Manager outputs nothing in that case (fail closed). Stored identities and payloads are kept and come back when you reinstall, and no automatic cleanup deletes anything. Following relationships is limited to one supporting step and 100 identities.

For the cache, you call SourceInvalidatorInterface::changed() after a committed change to one source record, or adapterChanged() after an import, routing or configuration batch. If you hold an outer transaction, invalidate after its commit. Visibility deadlines such as nextVisibilityChange clamp the HTTP cache lifetime.

Version 1 deliberately invalidates one shared extension tag instead of keeping a reverse-dependency index. That's the conservative choice: all schema responses carry it, including missing or unpublished ones.

// Sketch: clear the cache after a committed change
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
    {
        // single record, after the commit
        $this->invalidator->changed(new SourceReference('example.inventory', $site, $subjectKey));
    }

    public function afterImport(): void
    {
        // import, routing or configuration batch
        $this->invalidator->adapterChanged('example.inventory');
    }
}

How do versions and migrations work?

Every type carries a schemaVersion. If it doesn't match the stored data, the Schema Manager reports migration_required and emits no stale payload. You register consecutive PayloadMigrationInterface classes and run them first as a dry run, then with --apply.

The dry run writes nothing. --apply checks version and revision atomically, validates the transformed payloads, preserves identity and source authority, and invalidates the output. A migration never runs on render. Renames of type keys or relationship keys need a separately reviewed migration. The code sketches in this post are derived from the real signatures, meant as illustration and were not executed by us.

// Sketch: supplementary field capacity becomes ratedCapacity in version 2
// Also change the FieldDefinition key in the type to 'ratedCapacity'
// and set schemaVersion: 2 in the TypeDefinition.
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

Where are the limits of the Extension API?

Version 1 is deliberately narrow. The console for extension data is administrator-only, and we ship neither project adapters nor an industry catalogue. The example bundle is synthetic. The list below names the limits one by one.

  • Console for administrators only
    Integrating bundles can offer their own permission-aware workflows for other users through the public interfaces.
  • No project adapters, no industry catalogue
    You write types, relationships and adapters yourself. Creating, binding and publishing entities stays manual in v1.
  • Pending vocabulary is off by default
    Pending predicates need a pending relationship declaration and the parameter schema_manager.extension.allow_pending=true. You can approve single relationship keys through schema_manager.extension.approved_pending_relationships.
  • Valid vocabulary is no rich-result promise
    A property fitting the Schema.org vocabulary says nothing about rich results. We promise no rankings or AI citations, the evidence is thin, as our article on schema markup explains.
  • No write-back
    There is no write-back or override API. Rendering triggers neither source writes nor AI calls.

Where do you start?

Install version 1.4 or newer, apply Contao's additive database updates and rebuild the cache. Then read the example bundle and the tests: the repository ships composer test and tests/extension-integration.php, which you run in an installed application.

The API documentation is on GitHub, the package on Packagist. Features and installation of the extension itself are on the extension page.

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

Frequently asked questions about the Extension API

  • No. You register Symfony services with the tags of the public interfaces in the Extension\V1 namespace. Classes under Internal, existing models and emitters are implementation details. SQL into core tables is not intended.

  • No. There is no write-back or override API. Fields your source owns stay read-only, even when empty. The Schema Manager stores supplementary fields as its own typed values, and automatic sources don't get those either.

  • The Schema Manager outputs nothing in that case (fail closed): the output is omitted, while stored identities and payloads are kept. If you reinstall the bundle, the identities come back. There is no automatic destructive cleanup.

  • Raise the type's schemaVersion and register a PayloadMigrationInterface class. Run schema-manager:extension:migrate ENTITY_ID as a dry run first, then with --apply. Renames of type keys or relationship keys need a separately reviewed migration.

  • Yes, but only deliberately. The default is off. You need a pending relationship declaration and schema_manager.extension.allow_pending=true, or you approve single keys through schema_manager.extension.approved_pending_relationships.

The full API documentation is on GitHub, features and installation of the extension are on the extension page.

Markus Milkereit

Founder & Lead Developer

Writes about accessibility, maintainable front-ends and the unglamarous parts of running a website well.

Get in touch