Custom Scalars in GraphQL: DateTime, Email und JSON richtig definieren
AI generated
{ }
type
GraphQL · Type System · Schema Design · PHP
Custom Scalars in GraphQL richtig definieren
DateTime, Email und JSON jenseits von String und Int

Ein Datum als String zu übertragen sieht harmlos aus, bis zwei Clients unterschiedliche Formate erwarten und das Backend Validierung doppelt implementieren muss. Custom Scalars verlagern Format und Validierung in einen einzigen, wiederverwendbaren Typ im Schema, mit klaren Regeln für Serialisierung, Eingabe-Parsing und Fehlerfälle.

17 Min. Lesezeit Custom Scalars · serialize · parseValue · parseLiteral webonyx/graphql-php · PHP 8.4

1. Warum String und Int für DateTime, Email und JSON nicht ausreichen

GraphQL liefert von Haus aus fünf eingebaute Scalar-Typen: Int, Float, String, Boolean und ID. Für ein Datum, eine E-Mail-Adresse oder eine dynamische JSON-Struktur ist keiner davon semantisch passend, weshalb die meisten Schemas sie notgedrungen als String modellieren. Das Problem: String transportiert keine Formatgarantie. Ein Client kann "2026-08-06", "06.08.2026" oder "August 6, 2026" senden, und ohne Custom Scalars muss jeder Resolver, der das Feld liest, sein eigenes Parsing und seine eigene Validierung mitbringen.

Diese verteilte Validierungslogik ist der eigentliche Kostenpunkt: Ein Bug im Datumsparsing eines Resolvers bleibt isoliert, während derselbe Fehler in einem anderen Resolver unbemerkt bleibt, weil beide Stellen die Regeln unabhängig voneinander implementiert haben. Custom Scalars zentralisieren serialize, parseValue und parseLiteral an einer einzigen Stelle im Schema. Jeder Client, der eine Query gegen das Schema validiert, bekommt Typfehler für falsch formatierte DateTime- oder Email-Werte, bevor überhaupt ein Resolver aufgerufen wird.

2. Aufbau eines Custom Scalars: serialize, parseValue, parseLiteral

Jeder Custom Scalar in GraphQL definiert drei Funktionen, die zusammen den vollständigen Lebenszyklus eines Werts abdecken. serialize() wandelt den internen PHP-Wert, etwa ein DateTimeImmutable-Objekt, in eine Ausgabeform für die Response um. parseValue() übernimmt die Umkehrung für Variablen, die als JSON-Payload mit einer Query mitgeschickt werden. parseLiteral() übernimmt dieselbe Aufgabe, wenn der Wert direkt im Query-Text als Literal steht, etwa createdAt: "2026-08-06T10:00:00Z".

Diese Dreiteilung existiert, weil GraphQL zwei unterschiedliche Eingabewege kennt: Werte können als Teil der Query-Syntax (Literal, als AST-Knoten) oder als separates Variablen-Objekt (bereits als PHP-Wert aus JSON dekodiert) ankommen. Ein Custom Scalar, der nur parseValue() implementiert, funktioniert bei Variablen, wirft aber bei Literalen im Query-Text einen Fehler oder, schlimmer, akzeptiert unvalidierte Werte. Die korrekte Implementierung deckt beide Wege konsistent ab, mit identischer Validierungslogik in beiden Funktionen.

3. DateTime Custom Scalar implementieren

Ein DateTime Custom Scalar sollte intern immer mit DateTimeImmutable arbeiten und nach außen ein festes Format erzwingen, üblicherweise ISO 8601 im UTC-Zeitzonenformat. Damit ist ausgeschlossen, dass ein Client lokale Zeitzonen-Strings sendet, die serverseitig uneindeutig interpretiert werden müssten. Die folgende Implementierung zeigt alle drei Pflichtmethoden für webonyx/graphql-php.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlScalars\Type;

use DateTimeImmutable;
use DateTimeInterface;
use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;

/**
 * Custom Scalar for ISO 8601 UTC datetimes, e.g. 2026-08-06T10:00:00Z.
 */
final class DateTimeScalar extends ScalarType
{
    public string $name = 'DateTime';

    public ?string $description = 'ISO 8601 datetime string in UTC, e.g. 2026-08-06T10:00:00Z.';

    /**
     * Converts an internal DateTimeInterface value into the ISO 8601 output string.
     *
     * @param mixed $value Internal value, expected to implement DateTimeInterface
     * @return string ISO 8601 formatted datetime
     * @throws Error If the value is not a DateTimeInterface instance
     */
    public function serialize(mixed $value): string
    {
        if (!$value instanceof DateTimeInterface) {
            throw new Error('DateTime scalar can only serialize DateTimeInterface instances.');
        }

        return $value->format(DateTimeInterface::ATOM);
    }

    /**
     * Parses a datetime value coming from a GraphQL variable payload.
     *
     * @param mixed $value Raw variable value, expected to be a string
     * @return DateTimeImmutable Parsed datetime
     * @throws Error If the value is not a valid ISO 8601 string
     */
    public function parseValue(mixed $value): DateTimeImmutable
    {
        if (!is_string($value)) {
            throw new Error('DateTime scalar requires a string value.');
        }

        return $this->parseIso8601($value);
    }

    /**
     * Parses a datetime literal directly from the query AST.
     *
     * @param Node $valueNode AST node representing the literal
     * @param array<string, mixed>|null $variables Query variables, unused here
     * @return DateTimeImmutable Parsed datetime
     * @throws Error If the literal is not a string or not valid ISO 8601
     */
    public function parseLiteral(Node $valueNode, ?array $variables = null): DateTimeImmutable
    {
        if (!$valueNode instanceof StringValueNode) {
            throw new Error('DateTime scalar literal must be a string.', [$valueNode]);
        }

        return $this->parseIso8601($valueNode->value);
    }

    /**
     * Shared ISO 8601 parsing and validation logic used by parseValue and parseLiteral.
     *
     * @param string $raw Raw datetime string
     * @return DateTimeImmutable Parsed and validated datetime
     * @throws Error If the string cannot be parsed as ISO 8601
     */
    private function parseIso8601(string $raw): DateTimeImmutable
    {
        $parsed = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $raw);

        if ($parsed === false) {
            throw new Error(sprintf('"%s" is not a valid ISO 8601 datetime.', $raw));
        }

        return $parsed;
    }
}

Der entscheidende Punkt: parseValue() und parseLiteral() rufen beide dieselbe private parseIso8601()-Methode auf. Das verhindert, dass sich die Validierungslogik für Variablen und Literale auseinanderentwickelt, ein häufiger Fehler bei handgeschriebenen Custom Scalars, bei denen beide Pfade unabhängig implementiert werden und im Laufe der Zeit unterschiedliche Toleranzen entwickeln.

4. Email Custom Scalar mit Validierung

Ein Email Custom Scalar nutzt idealerweise PHPs eingebaute filter_var()-Funktion mit FILTER_VALIDATE_EMAIL, statt eine eigene Regex zu pflegen. E-Mail-Validierung per Regex ist notorisch fehleranfällig, weil der RFC 5322-Standard deutlich komplexer ist, als die meisten selbstgeschriebenen Muster abbilden. Die Serialisierung ist bei diesem Scalar besonders einfach, weil der interne und der externe Wert identisch sind, ein String bleibt ein String.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlScalars\Type;

use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;

/**
 * Custom Scalar for RFC 5322 compliant email addresses.
 */
final class EmailScalar extends ScalarType
{
    public string $name = 'Email';

    public ?string $description = 'A valid email address, validated against RFC 5322.';

    /**
     * @param mixed $value Internal value, expected to already be a valid email string
     * @return string Unmodified email string
     * @throws Error If the value is not a string
     */
    public function serialize(mixed $value): string
    {
        if (!is_string($value)) {
            throw new Error('Email scalar can only serialize string values.');
        }

        return $value;
    }

    /**
     * @param mixed $value Raw variable value
     * @return string Validated email address
     * @throws Error If the value is not a valid email address
     */
    public function parseValue(mixed $value): string
    {
        return $this->validate($value);
    }

    /**
     * @param Node $valueNode AST node representing the literal
     * @param array<string, mixed>|null $variables Query variables, unused here
     * @return string Validated email address
     * @throws Error If the literal is not a valid email address
     */
    public function parseLiteral(Node $valueNode, ?array $variables = null): string
    {
        if (!$valueNode instanceof StringValueNode) {
            throw new Error('Email scalar literal must be a string.', [$valueNode]);
        }

        return $this->validate($valueNode->value);
    }

    /**
     * @param mixed $value Value to validate as an email address
     * @return string Validated email address, unchanged
     * @throws Error If validation via FILTER_VALIDATE_EMAIL fails
     */
    private function validate(mixed $value): string
    {
        if (!is_string($value) || filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
            throw new Error(sprintf('"%s" is not a valid email address.', (string) $value));
        }

        return $value;
    }
}

5. JSON Custom Scalar für dynamische Strukturen

Manche Felder tragen bewusst keine feste Struktur, etwa ein metadata-Feld für beliebige Zusatzattribute oder eine Konfigurationsstruktur, die sich zwischen Objekttypen unterscheidet. Statt dafür ein starres GraphQL-Objekttyp-Geflecht zu bauen, ist ein JSON Custom Scalar die pragmatische Lösung. Er verzichtet bewusst auf Typsicherheit innerhalb der Struktur, dafür bleibt das Schema flexibel für Felder, deren Form sich häufig ändert oder die aus externen Systemen unverändert durchgereicht werden.

Wichtig ist, den JSON-Scalar sparsam einzusetzen: Jedes Feld, das als JSON typisiert wird, verliert die Introspection-Fähigkeiten von GraphQL, Clients können nicht mehr abfragen, welche Unterfelder existieren, und Tools wie GraphiQL können keine Autovervollständigung anbieten. Ein JSON Custom Scalar ist deshalb die richtige Wahl für tatsächlich dynamische Daten, nicht als Abkürzung, um ein sauberes Objekttyp-Design zu vermeiden.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlScalars\Type;

use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Utils\AST;
use GraphQL\Type\Definition\ScalarType;

/**
 * Custom Scalar for arbitrary JSON-serializable values.
 */
final class JsonScalar extends ScalarType
{
    public string $name = 'JSON';

    public ?string $description = 'Arbitrary JSON-serializable value, no fixed structure enforced.';

    /**
     * @param mixed $value Internal PHP value, must be JSON-serializable
     * @return mixed Unchanged value, passed through to the response encoder
     * @throws Error If the value cannot be JSON-encoded
     */
    public function serialize(mixed $value): mixed
    {
        json_encode($value);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new Error('JSON scalar value is not JSON-serializable.');
        }

        return $value;
    }

    /**
     * @param mixed $value Raw variable value, accepted as is
     * @return mixed Unchanged value
     */
    public function parseValue(mixed $value): mixed
    {
        return $value;
    }

    /**
     * @param Node $valueNode AST node representing the literal
     * @param array<string, mixed>|null $variables Query variables, forwarded to AST value conversion
     * @return mixed Value converted from the AST node
     */
    public function parseLiteral(Node $valueNode, ?array $variables = null): mixed
    {
        // AST::valueFromASTUntyped handles objects, lists, and scalars recursively
        return AST::valueFromASTUntyped($valueNode, $variables);
    }
}

6. Custom Scalars im SDL deklarieren und registrieren

Im Schema Definition Language (SDL) wird ein Custom Scalar mit dem Schlüsselwort scalar deklariert, ohne Feldliste, da Scalars keine Unterfelder besitzen. Die eigentliche Verhaltenslogik aus serialize, parseValue und parseLiteral wird separat als PHP-Klasse registriert und beim Schema-Aufbau mit dem SDL-Typnamen verknüpft.


scalar DateTime
scalar Email
scalar JSON

type Customer {
  id: ID!
  email: Email!
  createdAt: DateTime!
  preferences: JSON
}

input CustomerInput {
  email: Email!
  preferences: JSON
}

type Mutation {
  updateCustomer(id: ID!, input: CustomerInput!): Customer!
}

Bei der Registrierung in PHP wird das Mapping zwischen SDL-Typnamen und Scalar-Klassen typischerweise über einen TypeConfigDecorator aufgebaut, den webonyx/graphql-php beim Parsen des SDL-Dokuments aufruft. Jeder Typname aus der scalar-Deklaration wird dabei auf eine Instanz der passenden Custom Scalar-Klasse abgebildet, sodass Schema und Verhalten getrennt, aber konsistent zusammengeführt werden.

7. Fehlerbehandlung bei ungültigen Scalar-Werten

Wenn parseValue() oder parseLiteral() eine GraphQL\Error\Error-Exception werfen, fängt die Ausführungsebene von webonyx/graphql-php diese ab und übersetzt sie automatisch in einen strukturierten Eintrag im errors-Array der Response, inklusive Pfadangabe zum betroffenen Argument. Der Resolver für die betroffene Mutation wird in diesem Fall gar nicht erst aufgerufen, die Validierung des Custom Scalar greift vor der eigentlichen Geschäftslogik.

Dieses Verhalten ist einer der größten Vorteile von Custom Scalars gegenüber Validierung im Resolver-Code: Ein ungültiges Datum oder eine fehlerhafte E-Mail-Adresse führt zu einem klaren, spezifischen Fehler, bevor Datenbankzugriffe, Business-Logik oder Seiteneffekte ausgelöst werden. Bei Validierung erst im Resolver besteht die Gefahr, dass Teile der Mutation bereits ausgeführt wurden, bevor der Fehler erkannt wird.

8. Custom Scalars in Queries und Mutations nutzen

Custom Scalars funktionieren symmetrisch in beide Richtungen: als Ausgabetyp in Queries, wo serialize() greift, und als Eingabetyp in Mutation-Argumenten oder Input-Types, wo parseValue() beziehungsweise parseLiteral() greifen. Diese Symmetrie ist ein wichtiger Designvorteil gegenüber getrennten Input- und Output-Typen, weil ein einziger Scalar-Typname im gesamten Schema konsistent verwendet werden kann.


mutation UpdateCustomerProfile {
  updateCustomer(
    id: "42"
    input: {
      email: "kunde@example.com"
      preferences: { newsletter: true, theme: "dark" }
    }
  ) {
    id
    email
    createdAt
    preferences
  }
}

Im Beispiel validiert der Email Custom Scalar das Argument email bereits während des Query-Parsings, der JSON Custom Scalar lässt preferences unverändert als verschachteltes Objekt durch, und createdAt wird beim Ausliefern der Response über den DateTime Custom Scalar ins ISO-8601-Format serialisiert. Aus Client-Sicht ist der gesamte Vorgang transparent, es wird lediglich ein anderer Scalar-Typname im Schema sichtbar.

9. Custom Scalars im Vergleich zu Input Types

Für strukturierte Eingaben mit mehreren benannten Feldern ist ein GraphQL input-Typ oft die bessere Wahl als ein Custom Scalar. Die folgende Tabelle zeigt, wann welcher Ansatz sinnvoller ist.

Kriterium Custom Scalar Input Type
Struktur bekannt und fest Ungeeignet Ideal
Introspection der Unterfelder Nicht möglich Vollständig
Ein einzelner primitiver Wert mit Formatregel Ideal Overkill
Dynamische, unbekannte Struktur (JSON) Passend Nicht abbildbar
Wiederverwendung über viele Typen hinweg Sehr gut Möglich, aber schwerer

Die Faustregel: Custom Scalars für einzelne Werte mit klarer Formatregel wie DateTime oder Email, Input Types für strukturierte Objekte mit mehreren benannten Feldern. JSON-Scalars sind der bewusste Ausnahmefall für Daten, deren Struktur zur Schema-Designzeit nicht feststeht oder sich zu häufig ändert, um sie sinnvoll zu typisieren.

Mironsoft

GraphQL-Schema-Design, Typsicherheit und Magento-Integration

Ein GraphQL-Schema mit sauberen, wiederverwendbaren Typen?

Wir designen Custom Scalars für Datumswerte, E-Mail-Adressen und dynamische Strukturen, die Validierung zentralisieren und Resolver-Code von wiederholter Prüflogik befreien.

Schema-Review

Analyse bestehender String-Felder auf Custom-Scalar-Potenzial

Scalar-Implementierung

serialize, parseValue und parseLiteral konsistent und getestet

Fehlerbehandlung

Strukturierte GraphQL-Errors statt verstreuter Resolver-Validierung

10. Zusammenfassung

Custom Scalars lösen ein wiederkehrendes Problem in GraphQL-Schemas: Werte mit klarer Formatregel, die weder als generischer String noch als Int sauber modelliert werden können. Die drei Methoden serialize, parseValue und parseLiteral bilden zusammen den vollständigen Lebenszyklus eines Werts ab, von der Datenbank über die Query-Ausführung bis zur Response. DateTime, Email und JSON sind die drei häufigsten praktischen Anwendungsfälle, jeder mit eigener Validierungslogik, aber identischer Grundstruktur.

Der größte Gewinn liegt in der Zentralisierung: Validierungsregeln, die sonst über Dutzende Resolver verstreut wären, leben an einer einzigen Stelle im Schema und greifen automatisch, bevor Resolver überhaupt aufgerufen werden. Für strukturierte Objekte mit mehreren Feldern bleibt der Input Type die richtige Wahl, Custom Scalars ergänzen ihn für einzelne, formatspezifische Werte und für bewusst dynamische Strukturen wie JSON-Metadaten.

Custom Scalars in GraphQL — Das Wichtigste auf einen Blick

Drei Pflichtmethoden

serialize für die Ausgabe, parseValue für Variablen, parseLiteral für Literale im Query-Text.

Zentrale Validierung

Ungültige Werte werden vor dem Resolver-Aufruf als GraphQL-Error abgefangen, nicht erst in der Business-Logik.

DateTime, Email, JSON

Die drei häufigsten Custom Scalars, jeweils mit eigener, aber konsistenter Validierungslogik.

Abgrenzung zu Input Types

Custom Scalars für einzelne Werte mit Formatregel, Input Types für strukturierte Objekte mit mehreren Feldern.

11. FAQ: Custom Scalars in GraphQL

1Was ist ein Custom Scalar in GraphQL?
Ein selbst definierter Skalartyp, der die eingebauten Typen um eigene Formatregeln und Validierung ergänzt, etwa für DateTime, Email oder JSON.
2Wofür wird serialize() gebraucht?
Wandelt den internen PHP-Wert in die Ausgabeform für die Response um, meist einen String im vereinbarten Format.
3Warum parseValue UND parseLiteral?
GraphQL kennt zwei Eingabewege, Variablen und Literale im Query-Text. Beide müssen dieselbe Validierung nutzen.
4Wie validiert man DateTime korrekt?
Mit createFromFormat gegen ein festes Format wie ISO 8601 prüfen, bei Fehlschlag eine GraphQL-Error-Exception werfen.
5Warum FILTER_VALIDATE_EMAIL statt Regex?
RFC 5322 ist komplexer als die meisten selbstgeschriebenen Muster abbilden, die eingebaute Funktion ist zuverlässiger.
6Wann JSON Custom Scalar einsetzen?
Nur für tatsächlich dynamische Strukturen ohne feste Form. Bei bekannter Struktur ist ein normaler Objekttyp besser.
7Wie wird ein Scalar im SDL deklariert?
Mit dem Schlüsselwort scalar und dem Typnamen, ohne Feldliste. Die Logik lebt separat in einer PHP-Klasse.
8Was passiert bei ungültigem Wert?
Die Error-Exception wird in einen strukturierten errors-Eintrag übersetzt, der Resolver wird nicht aufgerufen.
9Scalar oder Input Type für strukturierte Daten?
Input Type für mehrere benannte Felder mit Introspection, Custom Scalar für einzelne Werte mit Formatregel.
10Verlieren Custom Scalars die Introspection?
Der Scalar-Typname bleibt introspizierbar, nur innerhalb eines JSON-Scalars gibt es keine Unterstruktur-Introspection.