API Platform GraphQL: Custom Resolver, Mutations und Subscriptions
AI generated
SF
{ }
Symfony · API Platform · GraphQL Resolver · PHP 8.4
API Platform GraphQL: eigene Resolver, Mutations und Subscriptions
wenn das automatische CRUD Schema nicht mehr reicht

API Platform generiert aus jeder Ressource automatisch Queries und Mutations, doch sobald eine Aktion mehrere Ressourcen kombiniert oder Echtzeit Updates gefragt sind, braucht es einen eigenen GraphQL Resolver. Dieser Artikel zeigt, wie Query Resolver, Mutation Resolver mit dedizierten Input Klassen und Mercure basierte Subscriptions in API Platform zusammenspielen.

18 Min. Lesezeit QueryItemResolverInterface · Mutation Input · Mercure Subscription API Platform 4 · Symfony 7 · PHP 8.4

1. Wann das automatische GraphQL Schema nicht ausreicht

Wird GraphQL Support in API Platform aktiviert, generiert die Bibliothek aus jeder mit #[ApiResource] annotierten Klasse automatisch Queries für Item und Collection sowie Mutations für Create, Update und Delete. Für einfache CRUD Ressourcen ist damit bereits ein vollständiges Schema vorhanden, ganz ohne manuell geschriebenen Resolver. Sobald aber eine Query Daten aus mehreren Quellen zusammenführen oder eine Mutation mehr als eine einfache Feldänderung auslösen soll, reicht dieses automatische Schema nicht mehr aus.

Ein eigener Resolver in API Platform ist konzeptionell dasselbe Prinzip wie ein State Provider, nur auf GraphQL zugeschnitten: Statt Doctrine direkt zu befragen, übernimmt eine PHP Klasse die Kontrolle über die Auflösung eines Feldes oder einer Operation. API Platform unterscheidet dabei zwischen Query Resolvern für Lesevorgänge und Mutation Resolvern für Schreibvorgänge, beide werden über Attribute an der Ressource registriert.

Der dritte Baustein, Subscriptions, geht über klassisches Request Response GraphQL hinaus: Statt dass ein Client wiederholt pollt, hält eine WebSocket ähnliche Verbindung über Mercure offen und pusht Updates, sobald sich eine Ressource ändert. Zusammen decken diese drei Mechanismen die Fälle ab, in denen reines CRUD GraphQL an seine Grenzen stößt.

2. Ein eigener Query Resolver für aggregierte Daten

Ein Query Resolver implementiert QueryItemResolverInterface für eine Item Query oder QueryCollectionResolverInterface für eine Collection Query. Registriert wird er über das resolver Argument innerhalb des GraphQlOperation Attributs. Ein typischer Anwendungsfall ist ein Dashboard Feld, das Kennzahlen aus mehreren Entities aggregiert, etwa Gesamtumsatz und Bestellanzahl eines Kunden in einem einzigen GraphQL Feld, statt den Client dazu zu zwingen, mehrere separate Queries zu kombinieren.

Der Resolver bekommt Zugriff auf das aufgelöste Basisobjekt und den GraphQL Kontext, inklusive angeforderter Felder. Das erlaubt gezielte Optimierung: Wird ein teures aggregiertes Feld in der aktuellen Query gar nicht abgefragt, kann der Resolver die zugehörige Berechnung komplett überspringen, statt sie vorsorglich immer auszuführen.


<?php

declare(strict_types=1);

namespace App\Resolver;

use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
use ApiPlatform\Metadata\Operation;
use App\Repository\OrderRepository;

/**
 * Custom GraphQL resolver that aggregates order statistics
 * for a customer into a single computed field.
 */
final readonly class CustomerStatsResolver implements QueryItemResolverInterface
{
    public function __construct(
        private OrderRepository $orders,
    ) {
    }

    public function __invoke(mixed $item, array $context): mixed
    {
        $stats = $this->orders->aggregateForCustomer($item->id);

        $item->totalRevenue = $stats['revenue'];
        $item->orderCount = $stats['count'];

        return $item;
    }
}

3. Mutation Resolver mit dedizierten Input und Output Klassen

Eine Mutation Resolver Klasse implementiert MutationResolverInterface und wird für Aktionen genutzt, die über eine simple Feldänderung hinausgehen, etwa Bestellung stornieren mit Rückerstattung oder Rabattcode einlösen mit Bestandsprüfung. Statt die gesamte Entity als Input zu akzeptieren, sollte eine dedizierte Input Klasse mit genau den Feldern definiert werden, die für diese eine Mutation tatsächlich benötigt werden.

Diese Trennung von Input, Entity und Output hält das GraphQL Schema selbstdokumentierend: Ein Client sieht in der Schema Introspektion exakt, welche Felder eine Mutation erwartet, ohne durch optionale, für diesen Anwendungsfall irrelevante Entity Felder abgelenkt zu werden. API Platform generiert aus der Input Klasse automatisch den passenden GraphQL Input Type.


<?php

declare(strict_types=1);

namespace App\Resolver;

use ApiPlatform\GraphQl\Resolver\MutationResolverInterface;
use App\Entity\Order;
use App\Repository\OrderRepository;
use App\Service\RefundService;
use Doctrine\ORM\EntityManagerInterface;

/**
 * Mutation resolver that cancels an order and triggers a refund,
 * going beyond a simple field update.
 */
final readonly class CancelOrderMutationResolver implements MutationResolverInterface
{
    public function __construct(
        private OrderRepository $orders,
        private RefundService $refunds,
        private EntityManagerInterface $entityManager,
    ) {
    }

    public function __invoke($item, array $context): Order
    {
        $reason = $context['args']['input']['reason'] ?? null;

        $item->status = 'cancelled';
        $item->cancellationReason = $reason;

        $this->refunds->issueRefund($item);
        $this->entityManager->flush();

        return $item;
    }
}

4. Validierung und Fehlerbehandlung in Mutations

Auch bei einem eigenen Mutation Resolver laufen die konfigurierten Symfony Validator Constraints auf der Input Klasse ganz normal vor dem Aufruf des Resolvers. Wichtig ist trotzdem, fachliche Regeln, die sich nicht als einfache Constraint ausdrücken lassen, explizit im Resolver zu prüfen, etwa ob eine Bestellung überhaupt noch stornierbar ist, weil sie sich in einem bestimmten Status befindet.

Für Fehlerfälle, die keine reine Validierungsverletzung sind, wirft der Resolver eine eigene Exception, die über einen GraphQL Error Formatter in eine strukturierte GraphQL Error Antwort mit passendem Error Code übersetzt wird. Ein Client kann dann anhand des Codes unterscheiden, ob eine Stornierung an einem falschen Status oder an einem technischen Fehler gescheitert ist, statt nur eine generische Fehlermeldung zu erhalten.

5. Echtzeit Subscriptions über Mercure aktivieren

API Platform bietet native GraphQL Subscriptions, die intern über den Mercure Hub realisiert werden, statt eine eigene WebSocket Infrastruktur zu benötigen. Aktiviert wird eine Subscription über das mercure Argument am Ressourcen Attribut, wodurch API Platform bei jeder Änderung automatisch ein Update Event über Mercure publiziert, das abonnierte GraphQL Clients in Echtzeit empfangen.

Der große Vorteil gegenüber klassischem Polling: Der Client muss die Ressource nicht mehr in festen Intervallen erneut abfragen, sondern bekommt Änderungen sofort gepusht, sobald der zugrunde liegende State Processor die Aktualisierung abschließt. Für Dashboards, Bestellstatus Tracking oder Chat ähnliche Funktionen reduziert das sowohl die Serverlast als auch die wahrgenommene Latenz erheblich.


<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Subscription;

/**
 * Enables a GraphQL subscription pushed through Mercure
 * whenever the shipment status changes.
 */
#[ApiResource(
    mercure: true,
    graphQlOperations: [
        new Subscription(),
        new Mutation(name: 'update'),
    ],
)]
final class ShipmentStatus
{
    public string $id;
    public string $status;
    public \DateTimeImmutable $updatedAt;
}

6. N Plus 1 Probleme in verschachtelten Queries vermeiden

GraphQL macht es Clients trivial, tief verschachtelte Queries zu formulieren, etwa eine Liste von Bestellungen inklusive jeweiligem Kunden und dessen Adresse. Ohne Vorsicht führt jede verschachtelte Relation zu einer eigenen Datenbankabfrage pro Element der äußeren Collection, dem klassischen N Plus 1 Problem, das bei GraphQL wegen der freien Feldauswahl noch unvorhersehbarer auftritt als bei fest definierten REST Endpunkten.

Die Lösung liegt in Eager Loading über Doctrine Extensions oder in einem DataLoader Pattern, das gleichartige Anfragen innerhalb eines einzigen GraphQL Requests bündelt und in einer einzigen Datenbankabfrage auflöst. API Platform selbst löst das nicht automatisch, ein eigener Query Resolver oder eine angepasste Doctrine Extension ist hier notwendig, um verschachtelte GraphQL Queries performant zu halten.

7. Feldbasierte Zugriffsprüfung im Resolver

Anders als bei REST, wo eine ganze Ressource typischerweise über einen einzigen Security Voter geschützt wird, erlaubt GraphQL feingranulare Zugriffsprüfung pro Feld. Ein Resolver kann etwa prüfen, ob der aktuell authentifizierte Nutzer berechtigt ist, das aggregierte Umsatzfeld eines fremden Kunden zu sehen, und bei fehlender Berechtigung selektiv nur dieses eine Feld als null zurückgeben, statt die gesamte Query abzulehnen.

Diese Granularität ist ein Vorteil gegenüber REST, bringt aber auch mehr Verantwortung mit sich: Jeder eigene Resolver muss die Zugriffsprüfung selbst implementieren, weil das #[ApiResource(security:)] Attribut nur auf Ebene der gesamten Operation greift, nicht auf Feldebene innerhalb eines eigenen Resolvers.

8. GraphQL Resolver und Mutations testen

Ein Query Resolver oder Mutation Resolver ist ein normaler Symfony Service und lässt sich damit ohne GraphQL Kontext als reiner Unit Test prüfen, indem der Resolver direkt mit Test Doubles für seine Abhängigkeiten instanziiert wird. Das deckt die eigentliche Logik schnell und isoliert ab.

Für den vollständigen Pfad inklusive Schema Validierung sendet ein funktionaler Test eine echte GraphQL Query oder Mutation über den Test Client an den /graphql Endpunkt und prüft die JSON Antwort inklusive möglicher Errors Array Einträge. Beide Ebenen zusammen stellen sicher, dass sowohl die Resolver Logik als auch ihre Einbindung in das generierte GraphQL Schema korrekt funktionieren.

9. Standard Schema und eigener Resolver im Vergleich

Die folgende Tabelle zeigt, wann das automatisch generierte GraphQL Schema ausreicht und wann ein eigener Resolver notwendig wird.

Anwendungsfall Standard GraphQL Schema Eigener Resolver Empfehlung
Einfaches CRUD auf einer Entity Vollständig ausreichend Unnötiger Mehraufwand Standard Schema nutzen
Aggregierte Kennzahlen Nicht abbildbar Query Resolver notwendig Eigener Resolver für Dashboards
Mehrstufige Aktion wie Stornierung Nur einfache Feldänderung Mutation Resolver mit Input Klasse Eigener Resolver für Geschäftslogik
Echtzeit Updates Nur über Polling Mercure Subscription Subscription für Dashboards und Tracking
Feldbasierte Zugriffsprüfung Nur operationsweit Im Resolver selbst umsetzbar Eigener Resolver für sensible Felder

Die Entscheidung zwischen automatisch generiertem GraphQL Schema und eigenem Resolver folgt demselben Muster wie bei REST Ressourcen: Der Standardpfad bleibt für den einfachen Fall, während eigene Resolver dort einsetzen, wo Aggregation, mehrstufige Geschäftslogik oder Echtzeit Anforderungen über simples CRUD hinausgehen.

Mironsoft

Symfony und API Platform Architektur für anspruchsvolle APIs

GraphQL Resolver und Echtzeit Subscriptions für euer Projekt?

Wir entwerfen eigene GraphQL Resolver für Aggregationen und komplexe Mutations, integrieren Mercure Subscriptions für Echtzeit Updates und lösen N Plus 1 Probleme in verschachtelten Queries.

GraphQL Architektur

Query und Mutation Resolver für komplexe Anwendungsfälle

Echtzeit Features

Mercure Subscriptions für Dashboards und Status Tracking

Performance Tuning

N Plus 1 Analyse und DataLoader Pattern für GraphQL Queries

10. Zusammenfassung

Das automatisch generierte GraphQL Schema von API Platform deckt einfaches CRUD vollständig ab, doch für aggregierte Daten braucht es einen eigenen Query Resolver, für mehrstufige Geschäftslogik einen Mutation Resolver mit dedizierter Input Klasse. Echtzeit Anforderungen lassen sich über Mercure Subscriptions abbilden, ohne eigene WebSocket Infrastruktur aufbauen zu müssen.

Wer eigene GraphQL Resolver konsequent für die Fälle einsetzt, die über simples CRUD hinausgehen, und gleichzeitig N Plus 1 Probleme sowie feldbasierte Zugriffsprüfung im Blick behält, bekommt ein GraphQL Schema, das sowohl mächtig als auch performant bleibt. Das Standard Schema und eigene Resolver schließen sich dabei nie gegenseitig aus, sondern ergänzen sich in ein und demselben API Platform Projekt.

GraphQL Resolver in API Platform: Das Wichtigste auf einen Blick

Query Resolver

QueryItemResolverInterface für aggregierte oder berechnete Felder jenseits einfacher Doctrine Zugriffe.

Mutation Resolver

MutationResolverInterface mit dedizierter Input Klasse für mehrstufige Geschäftslogik.

Mercure Subscriptions

Echtzeit Updates ohne Polling, direkt über das mercure Argument am Ressourcen Attribut aktiviert.

N Plus 1 und Sicherheit

Eager Loading gegen verschachtelte Query Probleme, feldbasierte Zugriffsprüfung direkt im Resolver.

11. FAQ: GraphQL Resolver in API Platform

1Wann eigenen Resolver nutzen?
Bei aggregierten Daten oder Mutations mit mehr als einer einfachen Feldänderung, CRUD deckt das Standard Schema ab.
2Query vs Mutation Resolver?
Query Resolver für Lesevorgänge über QueryItemResolverInterface, Mutation Resolver für Schreibvorgänge über MutationResolverInterface.
3Warum dedizierte Input Klasse?
Hält das Schema selbstdokumentierend, ein Client sieht exakt die benötigten Felder statt der gesamten Entity.
4Validierung bei eigenem Resolver?
Ja, Constraints auf der Input Klasse laufen normal vor dem Resolver Aufruf, fachliche Regeln zusätzlich im Resolver prüfen.
5Wie funktionieren Subscriptions?
Über das mercure Argument, das bei jeder Änderung ein Event über den Mercure Hub an abonnierte Clients pusht.
6N Plus 1 automatisch gelöst?
Nein, aktiv über Eager Loading oder DataLoader Pattern lösen, GraphQL erzeugt unvorhersehbarere Zugriffsmuster als REST.
7Zugriffsprüfung pro Feld?
Ja, ein Resolver kann selektiv nur ein Feld als null zurückgeben, diese Prüfung muss im Resolver selbst stehen.
8Wie teste ich einen Resolver?
Unit Test mit Test Doubles und zusätzlich funktional über eine echte Query oder Mutation gegen den GraphQL Endpunkt.
9Ersetzt Resolver das Standard Schema?
Nein, beide existieren nebeneinander, CRUD bleibt automatisch, Resolver ergänzt komplexe Fälle gezielt.
10Fehler vs Validierung unterscheiden?
Validierungsfehler automatisch aus Constraints, fachliche Fehler als eigene Exception mit eigenem Error Code im Resolver.