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.