GraphQL Directives: eigene Direktiven für Auth und Formatierung bauen
AI generated
{ }
type
GraphQL · Directives · Auth · Schema Design
GraphQL Directives: eigene Direktiven für Auth und Formatierung bauen
Wiederkehrendes Feldverhalten direkt im Schema deklarieren statt im Resolver

Wenn dieselbe Berechtigungsprüfung in zwanzig Resolvern kopiert wird, ist das ein Zeichen, dass Verhalten in die Schema-Deklaration gehört statt in wiederholten Code. GraphQL Directives machen genau das möglich: Ein @auth-Tag am Feld ersetzt den Boilerplate-Check, eine @formatDate-Direktive steuert Ausgabeformate ohne Resolver-Änderung.

18 Min. Lesezeit Custom Directives · @auth · @formatDate · Directive-Visitor webonyx/graphql-php · PHP 8.4

1. Was Directives in GraphQL wirklich sind

Eine Directive in GraphQL ist eine annotierbare Markierung im Schema oder in einer Query, erkennbar am vorangestellten @-Zeichen, die zusätzliches Verhalten an einer bestimmten Stelle auslöst. GraphQL bringt zwei eingebaute Directives mit, @skip und @include, die auf Query-Ebene Felder bedingt ein- oder ausblenden. Diese eingebauten Directives zeigen das Grundprinzip: Statt Logik in Resolvern zu verstecken, wird sie explizit und deklarativ direkt an der betroffenen Stelle im Schema oder in der Query sichtbar gemacht.

Eigene GraphQL Directives erweitern dieses Prinzip auf schemaseitige Anwendungsfälle: Zugriffskontrolle, Formatierung, Deprecation-Hinweise, Rate-Limiting-Regeln oder Caching-Hints lassen sich als Directive am Feld deklarieren, statt sie in jedem betroffenen Resolver erneut zu implementieren. Der entscheidende Unterschied zu einer Funktion im Resolver: Eine Directive ist Teil der Schema-Definition selbst und für Tools, die das Schema introspizieren, sowie für andere Entwickler direkt am Feld sichtbar, ohne den Resolver-Code lesen zu müssen.

2. Die @auth-Direktive: Feldzugriff nach Rolle einschränken

Eine @auth-Direktive ist der klassische Einstiegspunkt für eigene GraphQL Directives, weil das Muster "dieses Feld darf nur mit Rolle X gelesen werden" in fast jedem produktiven Schema vorkommt. Ohne Directive würde diese Prüfung als erste Zeile in jedem betroffenen Resolver stehen, mit Directive steht sie einmalig direkt an der Felddeklaration.


directive @auth(role: String!) on FIELD_DEFINITION

type Customer {
  id: ID!
  email: String!
  internalNotes: String @auth(role: "ADMIN")
  lifetimeValue: Float @auth(role: "SALES_MANAGER")
}

Die Deklaration directive @auth(role: String!) on FIELD_DEFINITION legt zwei Dinge fest: Erstens akzeptiert die Direktive ein Pflichtargument role vom Typ String, zweitens darf sie nur an Feld-Definitionen angewendet werden, nicht etwa an Query-Argumente oder Typdeklarationen. Diese Einschränkung über on FIELD_DEFINITION verhindert, dass die Direktive versehentlich an einer semantisch unpassenden Stelle im Schema landet.

3. Directive-Definition im SDL deklarieren

Jede Custom Directive muss vor ihrer Verwendung im SDL deklariert werden, mit Namen, Argumenten und den erlaubten Anwendungsorten. GraphQL kennt über zwanzig mögliche Orte, darunter FIELD_DEFINITION, OBJECT, ARGUMENT_DEFINITION und QUERY, jede Directive muss explizit angeben, an welchen davon sie erlaubt ist. Mehrere Orte lassen sich mit | kombinieren, falls eine Directive sowohl an Feldern als auch an ganzen Typen sinnvoll ist.


directive @auth(role: String!) on FIELD_DEFINITION | OBJECT
directive @formatDate(pattern: String = "Y-m-d") on FIELD_DEFINITION
directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE

type AdminReport @auth(role: "ADMIN") {
  generatedAt: String @formatDate(pattern: "d.m.Y H:i")
  totalRevenue: Float
}

Im Beispiel wirkt @auth(role: "ADMIN") auf Typebene, angewendet mit OBJECT als erlaubtem Ort, und schützt implizit alle Felder des AdminReport-Typs. Dieses Muster reduziert Wiederholung deutlich gegenüber einer Feld-für-Feld-Annotation, wenn ein kompletter Typ nur für eine bestimmte Rolle zugänglich sein soll.

4. Directive-Handler implementieren

Die SDL-Deklaration allein löst noch kein Verhalten aus, sie beschreibt nur, dass die Direktive existieren darf. Die eigentliche Logik wird in webonyx/graphql-php über einen Schema-Visitor oder über einen expliziten Field-Wrapper implementiert, der den ursprünglichen Resolver einer Directive-annotierten Stelle um zusätzliches Verhalten anreichert.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlDirectives\Directive;

use GraphQL\Error\Error;
use GraphQL\Type\Definition\FieldDefinition;
use GraphQL\Type\Definition\ResolveInfo;

/**
 * Wraps a field's resolver with an authorization check based on the @auth directive.
 */
final class AuthDirectiveHandler
{
    /**
     * Wraps the original field resolver with a role check derived from the
     * @auth directive's "role" argument, if present on the field definition.
     *
     * @param FieldDefinition $field Field definition potentially carrying an @auth directive
     * @return void
     */
    public function apply(FieldDefinition $field): void
    {
        $authDirective = $field->astNode?->directives !== null
            ? $this->findAuthDirective($field)
            : null;

        if ($authDirective === null) {
            return;
        }

        $requiredRole = $authDirective['role'];
        $originalResolver = $field->resolveFn;

        $field->resolveFn = function (mixed $root, array $args, mixed $context, ResolveInfo $info) use ($originalResolver, $requiredRole) {
            if (!$this->currentUserHasRole($context, $requiredRole)) {
                throw new Error(sprintf(
                    'Access denied: field "%s" requires role "%s".',
                    $info->fieldName,
                    $requiredRole
                ));
            }

            return $originalResolver !== null
                ? $originalResolver($root, $args, $context, $info)
                : $root->{$info->fieldName} ?? null;
        };
    }

    /**
     * Extracts the role argument from the field's @auth directive, if present.
     *
     * @param FieldDefinition $field Field definition to inspect
     * @return array{role: string}|null Directive arguments, or null if no @auth directive is present
     */
    private function findAuthDirective(FieldDefinition $field): ?array
    {
        foreach ($field->astNode->directives as $directiveNode) {
            if ($directiveNode->name->value === 'auth') {
                foreach ($directiveNode->arguments as $argument) {
                    if ($argument->name->value === 'role') {
                        return ['role' => $argument->value->value];
                    }
                }
            }
        }

        return null;
    }

    /**
     * Checks whether the current authenticated user holds the required role.
     *
     * @param mixed $context Request context, expected to expose the current user
     * @param string $requiredRole Role required to access the field
     * @return bool True if the user holds the required role
     */
    private function currentUserHasRole(mixed $context, string $requiredRole): bool
    {
        return in_array($requiredRole, $context->currentUser->roles ?? [], true);
    }
}

Dieser Ansatz kapselt den ursprünglichen Resolver in eine neue Closure, die zuerst die Rollenprüfung ausführt und nur bei Erfolg den ursprünglichen Resolver aufruft. Der eigentliche Feld-Resolver bleibt dadurch komplett unverändert und weiß nichts von der Existenz der @auth-Direktive, die Berechtigungsprüfung ist vollständig von der Business-Logik entkoppelt.

5. Die @formatDate-Direktive: Ausgabeformatierung im Schema

Neben Zugriffskontrolle sind Custom Directives auch für Ausgabeformatierung nützlich. Eine @formatDate-Direktive erlaubt es, das gewünschte Datumsformat direkt im Schema neben dem Feld zu deklarieren, statt Formatierungslogik über Custom Scalars oder client-seitige Bibliotheken zu lösen. Das ist besonders praktisch für interne Reporting-Schemas, bei denen unterschiedliche Felder unterschiedliche, feste Anzeigeformate benötigen.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlDirectives\Directive;

use DateTimeInterface;
use GraphQL\Type\Definition\FieldDefinition;
use GraphQL\Type\Definition\ResolveInfo;

/**
 * Wraps a field's resolver to format DateTimeInterface values via the @formatDate directive.
 */
final class FormatDateDirectiveHandler
{
    /**
     * Wraps the original field resolver, applying the pattern from the
     * @formatDate directive's "pattern" argument to the resolved value.
     *
     * @param FieldDefinition $field Field definition potentially carrying a @formatDate directive
     * @return void
     */
    public function apply(FieldDefinition $field): void
    {
        $pattern = $this->findPattern($field);

        if ($pattern === null) {
            return;
        }

        $originalResolver = $field->resolveFn;

        $field->resolveFn = function (mixed $root, array $args, mixed $context, ResolveInfo $info) use ($originalResolver, $pattern) {
            $value = $originalResolver !== null
                ? $originalResolver($root, $args, $context, $info)
                : $root->{$info->fieldName} ?? null;

            return $value instanceof DateTimeInterface ? $value->format($pattern) : $value;
        };
    }

    /**
     * Extracts the pattern argument from the field's @formatDate directive, if present.
     *
     * @param FieldDefinition $field Field definition to inspect
     * @return string|null Format pattern, or null if no @formatDate directive is present
     */
    private function findPattern(FieldDefinition $field): ?string
    {
        foreach ($field->astNode->directives ?? [] as $directiveNode) {
            if ($directiveNode->name->value === 'formatDate') {
                foreach ($directiveNode->arguments as $argument) {
                    if ($argument->name->value === 'pattern') {
                        return $argument->value->value;
                    }
                }

                return 'Y-m-d';
            }
        }

        return null;
    }
}

Der Resolver liefert weiterhin ein DateTimeImmutable-Objekt zurück, genau wie ohne Directive, die Formatierung passiert erst in der umschließenden Closure, kurz bevor der Wert an die GraphQL-Ausführungsebene zurückgegeben wird. Fällt kein pattern-Argument an, greift der im SDL definierte Default-Wert "Y-m-d".

6. Directives mit Argumenten: konfigurierbares Verhalten

Sowohl @auth(role: String!) als auch @formatDate(pattern: String = "Y-m-d") zeigen ein wichtiges Muster: GraphQL Directives können Argumente mit oder ohne Default-Wert annehmen, genau wie Felder und Query-Argumente. Ein Pflichtargument ohne Default, wie role bei @auth, erzwingt, dass jede Verwendung der Direktive explizit einen Wert angibt. Ein optionales Argument mit Default, wie pattern bei @formatDate, erlaubt eine kompakte Nutzung @formatDate ohne Klammern, wenn das Standardformat ausreicht.

Diese Flexibilität macht Custom Directives zu einem mächtigen Werkzeug für konfigurierbares, wiederverwendbares Verhalten: Eine einzige @auth-Direktive deckt beliebig viele Rollen ab, eine einzige @formatDate-Direktive beliebig viele Datumsformate, ohne dass für jede Kombination eine eigene Direktive geschrieben werden müsste. Der Directive-Handler liest die Argumente zur Schema-Bauzeit aus dem AST und konfiguriert das gewrappte Verhalten entsprechend.

7. Directives auf Query-Ebene: @skip, @include und Client-Directives

Neben schemaseitigen Directives an Feld-Definitionen kennt GraphQL auch Directives, die Clients direkt im Query-Text verwenden, allen voran die eingebauten @skip(if: Boolean!) und @include(if: Boolean!). Diese steuern, ob ein Feld basierend auf einer Variable in die Response aufgenommen wird, nützlich für Queries, die je nach UI-Zustand unterschiedliche Feldmengen benötigen, ohne zwei separate Query-Strings pflegen zu müssen.


query CustomerDetails($id: ID!, $includeInternalNotes: Boolean!) {
  customer(id: $id) {
    id
    email
    internalNotes @include(if: $includeInternalNotes)
  }
}

Eigene Client-Directives, die vom Schema deklariert werden, aber vom Client in der Query verwendet werden, sind ebenfalls möglich, kommen in der Praxis aber seltener vor als schemaseitige Field-Directives wie @auth. Ein Anwendungsfall wäre eine @client-Directive für Apollo Client, die ein Feld ausschließlich lokal auflöst, ohne es an den Server zu senden, ein Muster, das im Apollo-Ökosystem etabliert ist.

8. Testing und Debugging eigener Directives

Eine Custom Directive testet man am zuverlässigsten mit zwei Arten von Tests: Unit-Tests für den Directive-Handler isoliert, mit einem Mock-Feld und verschiedenen Rollen-Konstellationen, und Integrationstests, die eine vollständige Query gegen das Schema ausführen und prüfen, ob die Directive im Response-Ergebnis oder im errors-Array korrekt greift.


# Integration test: verify @auth directive blocks access for insufficient roles
curl -s -X POST https://api.example.test/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $SALES_TOKEN" \
  -d '{"query":"{ customer(id: \"1\") { internalNotes } }"}' \
  | jq '.errors[0].message'
# Expected: "Access denied: field \"internalNotes\" requires role \"ADMIN\"."

# Verify access succeeds with the correct role
curl -s -X POST https://api.example.test/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"query":"{ customer(id: \"1\") { internalNotes } }"}' \
  | jq '.data.customer.internalNotes'

Ein häufiger Debugging-Fall ist eine Directive, die im SDL deklariert ist, aber keine Wirkung zeigt. Meist liegt es daran, dass der Directive-Handler nicht mit dem Schema-Build-Prozess verknüpft wurde, etwa weil der Visitor nicht registriert oder ein TypeConfigDecorator vergessen wurde. Ein einfacher Test dafür: eine Query mit offensichtlich falscher Rolle ausführen und prüfen, ob überhaupt ein Fehler zurückkommt, bevor man tiefer in die Logik der Directive selbst einsteigt.

9. Custom Directives im Vergleich zu Resolver-Logik

Nicht jede Logik gehört in eine Directive. Die folgende Tabelle zeigt, wann eine Custom Directive gegenüber direktem Resolver-Code sinnvoll ist.

Kriterium Custom Directive Resolver-Logik
Wiederkehrend über viele Felder Ideal Viel Duplizierung
Sichtbar direkt im Schema für Tools/Reviewer Ja Nein, nur im Code
Feldspezifische, einmalige Logik Overkill Ideal
Komplexe, mehrstufige Business-Logik Ungeeignet Ideal
Konfigurierbar per Argument Sehr gut Erfordert eigene Parameter

Die Faustregel: Custom Directives für Querschnittsverhalten, das über viele Felder hinweg identisch ist und im Schema sichtbar sein soll, Resolver-Logik für feldspezifische, einmalige oder komplexe mehrstufige Business-Logik. Eine Directive, die am Ende doch feldspezifische Sonderfälle abfangen muss, ist meist ein Zeichen, dass die Logik eigentlich im Resolver besser aufgehoben wäre.

Mironsoft

GraphQL-Schema-Architektur, Autorisierung und Directive-Design

Zugriffskontrolle direkt im GraphQL-Schema deklarieren?

Wir entwerfen und implementieren Custom Directives für Autorisierung, Formatierung und wiederkehrendes Feldverhalten, inklusive Testing und Schema-Dokumentation.

Auth-Directive-Design

Rollenbasierte Feldzugriffe zentral im Schema deklarieren

Directive-Handler

Robuste Field-Wrapper-Implementierung mit Argumenten-Unterstützung

Testing & Debugging

Unit- und Integrationstests für zuverlässiges Directive-Verhalten

10. Zusammenfassung

GraphQL Directives verlagern wiederkehrendes Feldverhalten aus dem Resolver-Code direkt in die Schema-Deklaration, sichtbar für jeden, der das Schema liest, ohne dass er den zugrunde liegenden PHP-Code kennen muss. Eine @auth-Direktive ersetzt kopierte Rollenprüfungen in Resolvern durch eine deklarative Annotation am Feld, eine @formatDate-Direktive steuert Ausgabeformate ohne Änderung der Resolver-Logik. Beide folgen demselben technischen Muster: Der ursprüngliche Feld-Resolver wird in eine Closure gewrappt, die vor oder nach dem eigentlichen Aufruf zusätzliches Verhalten ausführt.

Wichtig ist die Abgrenzung: Directives eignen sich für Querschnittsverhalten, das über viele Felder identisch ist, nicht für feldspezifische oder komplexe mehrstufige Business-Logik. Argumente mit und ohne Default-Wert machen Directives konfigurierbar und wiederverwendbar, sodass eine einzige Custom Directive beliebig viele Konfigurationen im Schema abdeckt. Wer diese Grenze respektiert, gewinnt lesbarere Schemas und deutlich weniger duplizierten Resolver-Code.

GraphQL Directives — Das Wichtigste auf einen Blick

SDL-Deklaration

directive @name(arg: Type) on FIELD_DEFINITION legt Name, Argumente und erlaubte Orte fest.

Directive-Handler

Wrappt den ursprünglichen Resolver in eine Closure mit zusätzlichem Verhalten, ohne die Business-Logik zu verändern.

@auth und @formatDate

Zwei praxisnahe Beispiele für Autorisierung und Ausgabeformatierung direkt im Schema.

Abgrenzung zu Resolvern

Directives für Querschnittsverhalten, Resolver-Code für feldspezifische, komplexe Business-Logik.

11. FAQ: GraphQL Directives

1Was ist eine Directive in GraphQL?
Eine mit @ eingeleitete Markierung, die zusätzliches Verhalten auslöst, etwa Zugriffskontrolle oder Formatierung.
2Wie deklariert man eine eigene Directive?
Mit directive @name(arg: Type) on ORT, wobei ORT die erlaubten Schema-Stellen festlegt.
3Wie implementiert man das Verhalten?
Über einen Directive-Handler, der den ursprünglichen Resolver in eine Closure mit Zusatzverhalten wrappt.
4Wie funktioniert @auth?
Prüft vor dem Resolver-Aufruf die Nutzerrolle und wirft bei fehlender Berechtigung einen GraphQL-Error.
5Können Directives Default-Argumente haben?
Ja, wie bei Feldern und Query-Argumenten, für kompakte Nutzung ohne explizite Klammern.
6Was sind @skip und @include?
Eingebaute Query-Directives, die Felder basierend auf einer Variable bedingt in die Response aufnehmen.
7Wo sollte man Directives nicht einsetzen?
Für feldspezifische oder komplexe mehrstufige Business-Logik, die besser im Resolver aufgehoben ist.
8Wie testet man eine Custom Directive?
Mit isolierten Unit-Tests für den Handler und Integrationstests gegen das vollständige Schema.
9Warum zeigt die Directive keine Wirkung?
Meist fehlt die Verknüpfung des Handlers mit dem Schema-Build-Prozess, die SDL-Deklaration allein reicht nicht.
10Mehrere Directives an einem Feld möglich?
Ja, mehrere Directives lassen sich kombinieren, die Anwendungsreihenfolge hängt von der Implementierung ab.