TypoGraph 

Extension Key

typograph

Package name

digicademy/typograph

Version

main

Language

en

Author

Frodo Podschwadek, Digital Academy, Academy of Sciences and Literature | Mainz

License

This document is published under the Creative Commons BY 4.0 license.

Rendered

Fri, 24 Apr 2026 14:39:11 +0000

TYPO3 extension for providing access to TYPO3 database table data via a GraphQL endpoint. Under the bonnet, it uses graphql-php.

You shold be sufficiently familiar with the basic concepts of GraphQL to use this extension. For example, you need to know how to create GraphQL schemas. If you want to learn more about GraphQL or need a refresher, have a look at the GraphQL docs.


Introduction 

Motivation, features and limits of this extension.

Installation 

How to install this extension.

Configuration 

Configuring simple and nested queries.

Pagination 

Using paginated queries.

Sorting 

Custom sorting of query results.

Custom resolvers 

Root fields that do not fit the default tableMapping dispatch.

Example setup 

Set up example tables, schemas and configuration with a CLI command.

Introduction 

TypoGraph provides a middleware for a GraphQL endpoint. This endpoint accepts valid GraphQL requests for related data and creates a GraphQL response for the client.

Performance Characteristics 

TypoGraph implements a DataLoader pattern to prevent N+1 query problems:

  • Batch Loading: All related records are fetched in a single optimised query per relation type
  • Request-Scoped Caching: Each record is loaded only once per GraphQL request
  • Field Selection: Only fields requested in the GraphQL query are fetched from the database
  • Order Preservation: Related records are returned in the same order as stored (respects MM table sorting)

For example, querying 100 research disciplines with related entries for experts in these disciplines from a research information database results in only two database queries: one query for all taxonomies and one query for all unique disciplines referenced by those taxonomies.

Origins 

This extension has been developed by the Digital Academy of the Academy of Sciences and Literature | Mainz while refactoring on the research information system Portal Kleine Fächer, which provides detailed information on minor subjects at German universities and other institutions of higher education. The examples in this documentation still reflect this initial context.

AI Note 

The initial basic extension design has been completely done by humans. However, developers of this extension use Claude Code (Sonnet 4.5, 4.6; Opus 4.6) to streamline routine tasks, upgrades, improve code quality etc. All changes depending on AI (as far as we are aware) are confirmed by a qualified human software developer before merged into the main branch.

Installation 

Install the extension via Composer:

Installation with Composer
composer r digicademy/typograph
Copied!

See also Installing extensions, TYPO3 Getting started.

Once you have configured the TypoGraph extension, you request GraphQL data from your TYPO3 installation via the path /graphql.

Configuration 

TypoGraph is configured via the TYPO3 site configuration file (config/sites/<site-identifier>/config.yaml), under a top-level typograph: key.

  1. schemaFiles: Define the locations of the GraphQL schema files to use (more about schema files below). It makes sense for them to be located somewhere in the Resources/Private folder of your sitepackage but can really be located anywhere in your filesystem as long as TYPO3 can access that location.
  2. tableMapping: The TypoGraph extension fetches data for its responses from your database tables and therefore needs to know which type gets data from which table. This is configured as <type name>: <table name> pairs.

Here is an example for an application that uses the types Taxonomy, Discipline and Expert. For better maintainability, the schema is split into four files, one for the Query schema and one for each type schema (all of this could also be the content of one single file). These are listed in schemaFiles. Note that the TypoGraph resolver will concatenate the content of the schema files in the order they are listed in the configuration.

A GraphQL request will provide one or more type names. The data for these types can come from any TYPO3 database table, even though you will usually want to re-use the tables serving your conventional Extbase domain models. For the TypoGraph resolver to know which table to query for which entity, you need to provide a list of pairs of entity names and table names, as seen below.

config/sites/<site-identifier>/config.yaml
typograph:

  # Schema files
  schemaFiles:
    - 'EXT:sitepackage/Resources/Private/Schemas/Query.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Taxonomy.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Discipline.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Expert.graphql'

  # Types to tables mapping
  tableMapping:
    taxonomies: tx_myextension_domain_model_taxonomies
    disciplines: tx_myextension_domain_model_disciplines
    experts: tx_myextension_domain_model_experts
Copied!

However, most of the time you will want the option to query relations between entries from different tables, or data types. For this to happen, you need to configure the relations between different tables for TypoGraph.

You may also need to post-process certain field values before they are returned to the client — for example, to resolve TYPO3 link-handling URIs (t3://...) inside RTE-managed HTML fields. This is done via field value transforms.

Configuring Relations 

When your GraphQL types reference other types (e.g., a Taxonomy has multiple Discipline objects), you need to configure how TypoGraph should resolve these relations from your database.

Why Relations Need Configuration 

Unlike scalar fields (strings, integers), object-type fields require the TypoGraph extension to:

  1. Identify which database column stores the relation reference
  2. Know which table to query for the related records
  3. Understand the storage format (single UID, comma-separated UIDs, or MM table)

Without explicit configuration, TypoGraph will log a warning and return null for unconfigured relations.

Relation Configuration Structure 

Relations are configured under the typograph.relations key in the site configuration, nested as <TypeName>: <fieldName>: <config>:

config/sites/<site-identifier>/config.yaml
typograph:
  relations:
    TypeName:
      fieldName:
        sourceField: database_column_name
        targetType: RelatedTypeName
        storageType: uid  # uid | commaSeparated | mmTable | foreignKey

        # Additional fields for mmTable storage type:
        mmTable: tx_some_mm_table
        mmSourceField: uid_local
        mmTargetField: uid_foreign
        mmSortingField: sorting

        # Additional fields for foreignKey storage type:
        foreignKeyField: column_in_target_table
Copied!

Configuration Fields 

Field Required Default Description
sourceField No Field name in snake_case Database column in the source table containing the relation reference (not used for foreignKey type)
targetType Yes <none> GraphQL type name of the related entity (must exist in tableMapping)
storageType No uid How the relation is stored: uid, commaSeparated, mmTable, or foreignKey
mmTable For mmTable only <none> Name of the MM (many-to-many) intermediary table
mmSourceField For mmTable only uid_local Column in MM table referencing source record
mmTargetField For mmTable only uid_foreign Column in MM table referencing target record
mmSortingField For mmTable only sorting Column in MM table for sorting order
foreignKeyField For foreignKey only <none> Column in target table that references the source record UID

Storage Types 

The following variants are available for the storageType field.

1. Single UID (uid) 

Use when a database column contains a single UID reference.

Database structure
tx_taxonomy table:
  uid: 1
  name: "Computer Science"
  main_discipline: 42  ← Single UID
Copied!
GraphQL schema
type Taxonomy {
  name: String
  mainDiscipline: Discipline
}
Copied!
config/sites/<site-identifier>/config.yaml
typograph:
  relations:
    Taxonomy:
      mainDiscipline:
        sourceField: main_discipline
        targetType: Discipline
        storageType: uid
Copied!

2. Comma-Separated UIDs (commaSeparated) 

Use when a database column contains multiple UIDs as a comma-separated string.

Database structure
tx_taxonomy table:
  uid: 1
  name: "Computer Science"
  disciplines: "12,45,78"  ← Comma-separated UIDs
Copied!
GraphQL schema
type Taxonomy {
  name: String
  disciplines: [Discipline]
}
Copied!
config/sites/<site-identifier>/config.yaml
typograph:
  relations:
    Taxonomy:
      disciplines:
        sourceField: disciplines
        targetType: Discipline
        storageType: commaSeparated
Copied!

3. MM Table (mmTable) 

Use for many-to-many relations stored via an intermediary MM table.

Database structure
tx_expert table:
  uid: 5
  name: "Dr. Smith"

tx_expert_discipline_mm table:
  uid_local: 5      ← References expert
  uid_foreign: 12   ← References discipline
  sorting: 1

tx_discipline table:
  uid: 12
  name: "Physics"
Copied!
GraphQL schema
type Expert {
  name: String
  disciplines: [Discipline]
}
Copied!
config/sites/<site-identifier>/config.yaml
typograph:
  relations:
    Expert:
      disciplines:
        targetType: Discipline
        storageType: mmTable
        mmTable: tx_expert_discipline_mm
        mmSourceField: uid_local
        mmTargetField: uid_foreign
        mmSortingField: sorting
Copied!

4. Foreign Key / Inverse Relation (foreignKey) 

Use when the target table has a foreign key column pointing back to the source record. This handles 'sloppy MM' scenarios where multiple target records can reference the same source record, potentially with duplicate data. While this ideally should not happen, sometimes you may have to work with legacy databases containing denormalized data, inverse relations where child records point to the parent, or just with cases where somebody couldn't be bothered to set up proper MM tables (every software project has at least one former team member like this).

Database structure
tx_taxonomy table:
  uid: 5
  name: "Applied Sciences"

tx_discipline table:
  uid: 1
  name: "Physics"
  discipline_taxonomy: 5  ← Foreign key pointing to taxonomy

tx_discipline table:
  uid: 2
  name: "Physics"          ← Same name, different record
  discipline_taxonomy: 5  ← Same taxonomy reference

tx_discipline table:
  uid: 3
  name: "Chemistry"
  discipline_taxonomy: 5  ← Another discipline for same taxonomy
Copied!
GraphQL schema
type Taxonomy {
  name: String
  disciplines: [Discipline]
}

type Discipline {
  name: String
}
Copied!
config/sites/<site-identifier>/config.yaml
typograph:
  relations:
    Taxonomy:
      disciplines:
        targetType: Discipline
        storageType: foreignKey
        foreignKeyField: discipline_taxonomy
Copied!

How it works: In this case, TypoGraph queries the target table (tx_discipline) with a WHERE discipline_taxonomy = <source-uid> query and returns all matching records (including duplicates).

Complete Configuration Example 

This is a complete example with multiple relation types:

config/sites/<site-identifier>/config.yaml
typograph:

  # Schema files
  schemaFiles:
    - 'EXT:pkf_website/Resources/Private/Schemas/Query.graphql'
    - 'EXT:pkf_website/Resources/Private/Schemas/Taxonomy.graphql'
    - 'EXT:pkf_website/Resources/Private/Schemas/Discipline.graphql'
    - 'EXT:pkf_website/Resources/Private/Schemas/Expert.graphql'

  # Root elements to tables mapping
  tableMapping:
    disciplines: tx_dmdb_domain_model_discipline
    experts: tx_academy_domain_model_persons
    taxonomies: tx_dmdb_domain_model_discipline_taxonomy

  # Relation configuration
  relations:

    Taxonomy:
      # Single UID relation
      mainDiscipline:
        sourceField: main_discipline
        targetType: Discipline
        storageType: uid

      # Comma-separated UIDs relation
      disciplines:
        sourceField: disciplines
        targetType: Discipline
        storageType: commaSeparated

      # Foreign key relation (inverse/legacy MM)
      relatedDisciplines:
        targetType: Discipline
        storageType: foreignKey
        foreignKeyField: discipline_taxonomy

    Expert:
      # MM table relation
      disciplines:
        targetType: Discipline
        storageType: mmTable
        mmTable: tx_academy_persons_discipline_mm
        mmSourceField: uid_local
        mmTargetField: uid_foreign
        mmSortingField: sorting
Copied!

GraphQL Query Example 

With the configuration above, you can now query nested relations:

GraphQL query
{
  experts {
    familyName
    givenName
    disciplines {
      name
    }
  }
}
Copied!

Field Value Transforms 

Field value transforms run on raw database values after they are fetched and before they are handed to the GraphQL layer. They allow site-specific post-processing of individual fields without leaking the processing logic into the schema or into client code.

The primary use case is resolving TYPO3 link-handling URIs (t3://page, t3://file, t3://url, mailto:, tel:) that RTE-managed fields such as tt_content.bodytext store verbatim in the database. In a normal Fluid render, lib.parseFunc_RTE resolves these to real URLs during page rendering. TypoGraph bypasses the TSFE-driven rendering pipeline, so without a transform the raw t3:// URIs would reach the client as-is.

Configuration Structure 

Transforms are configured under the typograph.fieldTransforms key in the site configuration, nested as <TypeName>: <fieldName>: <transformName>:

config/sites/<site-identifier>/config.yaml
typograph:
  fieldTransforms:
    TypeName:
      fieldName: transformName
Copied!

Keys follow GraphQL naming: TypeName matches a type in your schema (e.g. Content) and fieldName matches a field on that type (e.g. bodytext). Internally the resolver converts the field name to the corresponding database column name via camelCaseToLowerCaseUnderscored, the same conversion used everywhere else in TypoGraph.

Built-In Transforms 

Registering Custom Transforms 

To add your own transform, implement Digicademy\\TypoGraph\\Transformer\\TransformerInterface and register it under a short name in Configuration/Services.yaml:

Classes/Transformer/MyCustomTransformer.php
<?php

declare(strict_types=1);

namespace Vendor\Sitepackage\Transformer;

use Digicademy\TypoGraph\Transformer\TransformerInterface;
use Psr\Http\Message\ServerRequestInterface;

final class MyCustomTransformer implements TransformerInterface
{
    public function transform(mixed $value, ServerRequestInterface $request): mixed
    {
        // return $value unchanged when it is not of a type you handle
        if (!is_string($value)) {
            return $value;
        }
        // …your post-processing…
        return $value;
    }
}
Copied!
Configuration/Services.yaml (sitepackage)
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  Vendor\Sitepackage\:
    resource: '../Classes/*'

  # Extend the TransformerRegistry provided by TypoGraph with the
  # new transform under a short name. Keep the `typolinks` entry so
  # the built-in transform remains available alongside yours.
  Digicademy\TypoGraph\Transformer\TransformerRegistry:
    arguments:
      $transformers:
        typolinks: '@Digicademy\TypoGraph\Transformer\TypolinksTransformer'
        myCustom: '@Vendor\Sitepackage\Transformer\MyCustomTransformer'
Copied!

Transforms must:

  • Return the input unchanged for value types they do not handle.
  • Avoid throwing for unexpected inputs. The resolver logs and swallows exceptions so that one misbehaving transform does not abort the entire GraphQL response, but logging contributes noise; prefer a defensive early return.

Error Behaviour 

  • If a configured transform name has no entry in the registry, the resolver logs a warning and leaves the raw value in place. This is usually a typo in site configuration or a missing DI wiring.
  • If a transform throws, the resolver logs an error and leaves the raw value in place for the affected record.
  • If the current request is not attached (only possible when calling ResolverService::process() directly without a request), transforms are skipped entirely.

Pagination 

TypoGraph supports cursor-based pagination following the GraphQL Cursor Connections Specification.

How It Works 

When a root query field returns a Connection type (a type whose name ends with Connection), TypoGraph automatically applies cursor-based pagination. If the return type is a plain list (e.g. [Discipline]), the resolver behaves as before and no pagination is applied.

Cursors are opaque, base64-encoded strings. Clients should treat them as opaque tokens and never parse or construct them manually.

Schema Setup 

To enable pagination for a type, you need to define three types in your GraphQL schema and include the shared Pagination.graphql schema file provided by the TypoGraph extension.

1. Include the shared PageInfo type 

The Pagination.graphql file ships with TypoGraph and defines:

PageInfo schema
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
Copied!

Add it as the first entry in schemaFiles in the site configuration:

config/sites/<site-identifier>/config.yaml
typograph:
  schemaFiles:
    - 'EXT:typograph/Resources/Private/Schemas/Pagination.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Query.graphql'
    # ...
Copied!

2. Define Connection and Edge types for your entity 

Example pagination schema for the Expert type
type Expert {
  familyName: String
  givenName: String
}

type ExpertConnection {
  edges: [ExpertEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ExpertEdge {
  cursor: String!
  node: Expert!
}
Copied!

3. Define both a plain-list field and a Connection field in the Query type 

The recommended convention is to expose two root fields per entity: a plain-list field for unpaginated access and a *Connection field for paginated access. This preserves backwards compatibility for clients that do not need pagination.

Query schema for plain and paginated Expert type
type Query {
  experts(familyName: String): [Expert]
  expertsConnection(familyName: String, first: Int, after: String): ExpertConnection
}
Copied!

The first and after arguments on the Connection field are recognised as pagination arguments and are not turned into WHERE conditions. All other arguments (like familyName) continue to work as filters on both fields.

Both field names must be present in tableMapping.

Pagination Configuration 

Configure default and maximum page sizes in the site configuration:

config/sites/<site-identifier>/config.yaml
typograph:
  pagination:
    defaultLimit: 20
    maxLimit: 100
Copied!
Setting Default Description
defaultLimit 20 Page size when first is not provided
maxLimit 100 Upper bound for first; requests above this are clamped

Querying With Pagination 

Using the pagination arguments first and after, or last and before allows for fine-grained pagination that can be combined the the usual filter arguments.

First page
{
  expertsConnection(first: 10) {
    edges {
      cursor
      node {
        familyName
        givenName
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
Copied!
Next page
{
  expertsConnection(first: 10, after: "Y3Vyc29yOjQy") {
    edges {
      cursor
      node {
        familyName
        givenName
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      endCursor
    }
  }
}
Copied!
Combining filters with pagination
{
  expertsConnection(familyName: "Smith", first: 5) {
    edges {
      node {
        familyName
        givenName
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
Copied!

Response Structure 

A paginated response has this shape
{
  "data": {
    "expertsConnection": {
      "edges": [
        {
          "cursor": "Y3Vyc29yOjE=",
          "node": {
            "familyName": "Smith",
            "givenName": "Jane"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "hasPreviousPage": false,
        "startCursor": "Y3Vyc29yOjE=",
        "endCursor": "Y3Vyc29yOjE="
      },
      "totalCount": 42
    }
  }
}
Copied!
Field Description
edges Array of edge objects, each containing a cursor and a node (the actual entity)
pageInfo.hasNextPage true if more records exist after this page
pageInfo.hasPreviousPage true if an after cursor was provided (i.e. this is not the first page)
pageInfo.startCursor Cursor of the first edge in this page (null if empty)
pageInfo.endCursor Cursor of the last edge in this page (null if empty). Pass this as the after argument to fetch the next page.
totalCount Total number of matching records across all pages. Only queried from the database when this field is actually requested in the GraphQL selection.

Sorting 

TypoGraph supports custom sorting of query results via the ComparatorInterface. This allows external packages to inject locale-aware or domain-specific sorting logic without modifying TypoGraph itself.

How It Works 

Sorting is based on two components:

  1. A ComparatorInterface implementation injected via dependency injection. This defines how strings are compared (e.g. locale-aware collation).
  2. A sortBy argument on the GraphQL query that specifies which field to sort by.

When both are present, TypoGraph applies the comparator to sort result sets. When either is absent, results are returned in their default order (UID for connection queries, database default for plain lists).

Sorting Behaviour 

Sorting behaviour differs depending on the query type:

Plain list queries (e.g. [Expert])
The full result set is sorted by the comparator before being returned.
Connection queries (e.g. ExpertConnection)
Cursor-based pagination relies on UID ordering for stable pagination across pages. The comparator sorts records within the current page only after the database query and pagination slicing. This means the overall page boundaries are determined by UID order, but the records within each page are reordered by the comparator.

Using the sortBy Argument 

Clients specify which field to sort by using the sortBy argument on a query field. To enable this, add sortBy: String to the relevant query fields in your GraphQL schema:

Schema with sortBy argument
type Query {
  experts(sortBy: String): [Expert]
  expertsConnection(sortBy: String, first: Int, after: String): ExpertConnection
}
Copied!

The sortBy value should be the camelCase GraphQL field name (e.g. familyName). TypoGraph converts it to the snake_case database column name automatically.

Query with sort field
{
  experts(sortBy: "familyName") {
    familyName
    givenName
  }
}
Copied!

When sortBy is omitted, no comparator-based sorting is applied and results are returned in their default database order.

Nonexistent fields: If the sortBy value refers to a field that does not exist in the result records, sorting is silently skipped and the original order is preserved.

Implementing a Custom Comparator 

TypoGraph ships a ComparatorInterface with a single method:

EXT:typograph/Classes/Comparator/ComparatorInterface.php
namespace Digicademy\TypoGraph\Comparator;

interface ComparatorInterface
{
    public function compare(string $a, string $b): int;
}
Copied!

The method follows PHP's standard comparison contract: return a negative integer if $a < $b, zero if equal, or a positive integer if $a > $b.

To provide a custom comparator, create a class implementing this interface in your sitepackage or extension:

EXT:my_sitepackage/Classes/Comparator/LocalizedComparator.php
namespace Vendor\MySitepackage\Comparator;

use Digicademy\TypoGraph\Comparator\ComparatorInterface;

class LocalizedComparator implements ComparatorInterface
{
    private static array $collators = [];

    public function compare(string $a, string $b): int
    {
        if (!isset(self::$collators['de_DE'])) {
            self::$collators['de_DE'] = \Collator::create('de_DE');
        }

        return self::$collators['de_DE']->compare($a, $b);
    }
}
Copied!

Then register your implementation as the ComparatorInterface service in your extension's Services.yaml:

EXT:my_sitepackage/Configuration/Services.yaml
services:
  Digicademy\TypoGraph\Comparator\ComparatorInterface:
    class: Vendor\MySitepackage\Comparator\LocalizedComparator
Copied!

TypoGraph's ResolverService accepts the comparator as an optional constructor dependency. When no implementation is registered, the parameter defaults to null and no custom sorting is applied.

Complete Example 

Given this schema and configuration:

Schema
type Query {
  experts(sortBy: String): [Expert]
  expertsConnection(sortBy: String, first: Int, after: String): ExpertConnection
}

type Expert {
  familyName: String
  givenName: String
}
Copied!

A plain list query sorted by family name:

Query sorted by familyName
{
  experts(sortBy: "familyName") {
    familyName
    givenName
  }
}
Copied!

The same type sorted by a different field:

Query sorted by givenName
{
  experts(sortBy: "givenName") {
    familyName
    givenName
  }
}
Copied!

A connection query sorts records within each page:

Paginated query with sorting
{
  expertsConnection(sortBy: "familyName", first: 10) {
    edges {
      node {
        familyName
        givenName
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
Copied!

Custom Resolvers 

TypoGraph's default resolver dispatches root Query fields by looking them up in typograph.tableMapping: a root field name is mapped to a database table, the resolver fetches rows from that table, applies configured filters and relations, and hands the result to the GraphQL layer. This works for everything that can be expressed as "list rows from table X".

Some root fields cannot be expressed that way. Examples include computed aggregates (e.g., pivoted totals across many joined tables), data assembled from multiple tables under a single logical query, or any result whose rows are not a straightforward projection of one table. For these cases TypoGraph exposes a small extension point, the CustomResolverInterface, that lets a consuming extension supply a dedicated resolver for a given root field.

How It Works 

Custom resolving depends on two components:

  1. A CustomResolverInterface implementation that handles one specific root field name. It receives the GraphQL arguments, ResolveInfo, and (optionally) the current PSR-7 request, and returns the resolved value in whatever shape the schema expects.
  2. A CustomResolverRegistry that collects every implementation registered in the DI container via a tagged iterator. Its get(string $fieldName) returns the handler for a given field, or null if none is registered.

TypoGraph's ResolverService consults the registry at the start of every Query-level resolve call. When a custom resolver matches the current field name, its return value is used directly and the default tableMapping dispatch is skipped for that field. When no custom resolver matches, resolution falls through to the regular tableMapping path and there is no behaviour change for existing fields.

The customResolverRegistry constructor argument of ResolverService is nullable; when absent (e.g., in projects that do not register any custom resolver), the feature has zero runtime cost.

When to Use a Custom Resolver 

Use a custom resolver when a root field:

  • Aggregates across multiple tables in a way tableMapping cannot express (joins, sums, pivots).
  • Delegates to an existing service whose result shape already matches the GraphQL type (no reason to duplicate the logic in the schema).
  • Needs arguments or behaviour that do not fit the standard equality filters TypoGraph derives from each root field's arguments (e.g., enum-valued partitioning parameters).

Prefer plain tableMapping when the field really is "list rows from one table, optionally filter and paginate". That path is declarative and gets cursor pagination, sortBy, and field transforms for free.

Implementing the Interface 

CustomResolverInterface has two methods:

EXT:typograph/Classes/CustomResolver/CustomResolverInterface.php
namespace Digicademy\TypoGraph\CustomResolver;

use GraphQL\Type\Definition\ResolveInfo;
use Psr\Http\Message\ServerRequestInterface;

interface CustomResolverInterface
{
    public function getFieldName(): string;

    public function resolve(
        array $args,
        ResolveInfo $info,
        ?ServerRequestInterface $request
    ): mixed;
}
Copied!

getFieldName() must return the exact name of the Query root field this resolver handles. It is used as the lookup key in the registry, so a field name collision between two resolvers is a configuration error (see the section on collisions below).

resolve() receives the GraphQL arguments (associative array keyed by the argument name from the schema), the ResolveInfo object (exposing the selection set and return type), and the current PSR-7 request if one is attached to the resolve call.

The return value must match the schema's declared return type. Once it is returned, TypoGraph's nested resolution logic walks the structure as usual, so associative arrays and plain objects with matching properties both work.

Registering a Resolver 

Every CustomResolverInterface implementation is auto-tagged with typograph.custom_resolver by the _instanceof rule in EXT:typograph/Configuration/Services.yaml. The CustomResolverRegistry receives a !tagged_iterator argument populated from that tag.

Important Symfony DI detail: _instanceof is file-local. A rule declared in one extension's Services.yaml does not apply to services declared in a different extension's Services.yaml. Consuming extensions must repeat the rule in their own Services.yaml so their resolver services pick up the tag:

Configuration/Services.yaml (sitepackage)
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  # Repeat TypoGraph's tagging rule so our CustomResolverInterface
  # implementations become part of the CustomResolverRegistry's
  # tagged iterator.
  _instanceof:
    Digicademy\TypoGraph\CustomResolver\CustomResolverInterface:
      tags: ['typograph.custom_resolver']

  Vendor\Sitepackage\:
    resource: '../Classes/*'
Copied!

No explicit service entry for the resolver class is required — the resource: glob picks it up, and the _instanceof rule tags it.

Registering the Field in the Schema 

A custom resolver only dispatches for fields declared in the GraphQL schema. Add the field to Query.graphql (or wherever your root type lives) like any other:

Resources/Private/Schemas/Query.graphql
type Query {
  # …regular tableMapping-backed fields…

  disciplineStats: [DisciplineStat!]!
}
Copied!

Types referenced by a custom-resolved field must be declared in a schema file loaded before the one that references them. TypoGraph concatenates the files listed in typograph.schemaFiles in order, so put the types' schema file earlier in the list than Query.graphql (the Stats schema in this example):

config/sites/<site-identifier>/config.yaml
typograph:
  schemaFiles:
    - 'EXT:typograph/Resources/Private/Schemas/Pagination.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Stats.graphql'
    - 'EXT:sitepackage/Resources/Private/Schemas/Query.graphql'
    # …other type files…
Copied!

The root field does not need a tableMapping entry. Fields handled by a custom resolver never reach the tableMapping dispatch path.

Complete Example 

A sitepackage wants to expose a disciplineStats root field that returns one row per discipline along with the number of experts attached to it. The count crosses two tables (discipline and expert) joined by a foreign key, so the default tableMapping dispatch cannot express it: each DisciplineStat row is a projection of an aggregate, not of a single database row. A custom resolver is the right fit.

Resources/Private/Schemas/Stats.graphql
type DisciplineStat {
  discipline: String!
  expertCount: Int!
}
Copied!
Resources/Private/Schemas/Query.graphql
type Query {
  # …regular tableMapping-backed fields (experts, disciplines, …)…

  disciplineStats: [DisciplineStat!]!
}
Copied!
Classes/GraphQL/Resolver/DisciplineStatsResolver.php
namespace Vendor\Sitepackage\GraphQL\Resolver;

use Digicademy\TypoGraph\CustomResolver\CustomResolverInterface;
use GraphQL\Type\Definition\ResolveInfo;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;

final class DisciplineStatsResolver implements CustomResolverInterface
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
    ) {}

    public function getFieldName(): string
    {
        return 'disciplineStats';
    }

    public function resolve(array $args, ResolveInfo $info, ?ServerRequestInterface $request): array
    {
        // One query joining discipline and expert, grouped per
        // discipline — the kind of shape tableMapping cannot express.
        $queryBuilder = $this->connectionPool
            ->getQueryBuilderForTable('tx_myextension_domain_model_discipline');
        $rows = $queryBuilder
            ->select('d.name AS discipline')
            ->addSelectLiteral('COUNT(e.uid) AS expertCount')
            ->from('tx_myextension_domain_model_discipline', 'd')
            ->leftJoin('d', 'tx_myextension_domain_model_expert', 'e', 'e.discipline = d.uid')
            ->groupBy('d.uid', 'd.name')
            ->orderBy('d.name', 'ASC')
            ->executeQuery()
            ->fetchAllAssociative();

        return array_map(
            static fn(array $row): array => [
                'discipline'  => (string)$row['discipline'],
                'expertCount' => (int)$row['expertCount'],
            ],
            $rows,
        );
    }
}
Copied!

With the _instanceof rule in the sitepackage's Services.yaml (shown in the previous section), nothing further is needed. The resolver is discovered at container build time and dispatched on every request to disciplineStats.

Dispatch Order and Precedence 

At the root level of a Query, ResolverService::resolve() runs in this order:

  1. Check the CustomResolverRegistry for a handler matching the current ResolveInfo::$fieldName. If one exists, call it and return its value.
  2. Otherwise fall through to tableMapping dispatch.

Inside the resolved value (nested fields on returned objects), standard resolution continues to apply: relation fields declared in typograph.relations still resolve through the default path, and field transforms declared in typograph.fieldTransforms still run against the records returned by your resolver. Your resolver does not have to re-implement those features; it only has to produce a value whose shape matches the schema.

Field Name Uniqueness and Collisions 

The registry stores at most one resolver per field name. If two services both implement CustomResolverInterface and return the same getFieldName() value, the last one registered wins. Symfony DI's iteration order is deterministic within a single container build but is not guaranteed across refactors, so colliding field names should be treated as a configuration error.

If you need to ship multiple alternative resolvers for the same field (e.g. behind a feature toggle), pick which one to register in your Services.yaml rather than relying on iteration order.

Error Behaviour 

  • If the current field has no matching custom resolver, dispatch falls through silently to tableMapping. Fields that are neither mapped to a table nor handled by a custom resolver return null — exactly the default behaviour from before the hook was introduced.
  • Exceptions thrown inside resolve() are not swallowed by the registry. They propagate to ResolverService::process() where GraphQL-level errors are logged and surfaced as a structured error response. Prefer returning empty arrays or null for domain-level "no data" conditions so they do not pollute the log.
  • Consuming projects that never register a custom resolver are not affected by anything in this document; the registry is injected as an optional dependency and the hook is a no-op when it is absent.

Error Handling 

TypoGraph logs helpful warnings and errors when relations are misconfigured:

  • Missing configuration: "Relation Taxonomy.disciplines is not configured in the site configuration"
  • Missing targetType: "Relation Taxonomy.disciplines is missing targetType configuration"
  • Unmapped target: "Target type Discipline is not mapped to a table in tableMapping"
  • Missing MM table: "Relation Expert.disciplines with storageType=mmTable is missing mmTable configuration"
  • Missing foreign key field: "Relation Taxonomy.disciplines with storageType=foreignKey is missing foreignKeyField configuration"

Check your TYPO3 logs if relations return null or empty arrays unexpectedly.

Example Setup 

For testing the TypoGraph extension in your system without hooking up any of your actual data, you can set up some example tables and seed them with a number of related entries:

Example setup command
vendor/bin/typo3 typograph:example
Copied!

The command accepts two parameters:

  • --site: If you install the extension in a multisite instance, you can specify for which site the GraphQL endpoint is configures. Defaults to main if omitted.
  • --no-seed: If you only want the example schemas and tables rolled out but fill them with data yourself, you can skip the entry seeding with this parameter.

The command creates the following tables and (unless you skip the seeding part) fills them with a handful of entries:

  • tx_typograph_example_taxonomies
  • tx_typograph_example_disciplines
  • tx_typograph_example_experts
  • tx_typograph_example_experts_disciplines_mm

It will also create the following schema files withing the TypoGraph package folder:

  • ResourcesPrivateSchemasExampleQuery.graphql
  • ResourcesPrivateSchemasExampleTypes.graphql

Finally, the command also adds the necessary configuration entries in config/sites/<site-name>/config.yaml.

Once the commmand has finished, you need to manually clear the TYPO3 and PHP caches via the TYPO3 Backend Maintenance Tool. This is recommended because due to your setup (e.g., if you are working with PHP-FPM), OPcache for worker threads will not be cleared unless done via the web interface (see, e.g., this discussion on TYPO3 cache flushing via the command line).

Now you can query the GraphQL endpoint available at the path /graphql for the example data. You can also run the Codeception API tests from testsApi against the endpoint, if needed.