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.
Inhaltsverzeichnis
- 1. Warum String und Int für DateTime, Email und JSON nicht ausreichen
- 2. Aufbau eines Custom Scalars: serialize, parseValue, parseLiteral
- 3. DateTime Custom Scalar implementieren
- 4. Email Custom Scalar mit Validierung
- 5. JSON Custom Scalar für dynamische Strukturen
- 6. Custom Scalars im SDL deklarieren und registrieren
- 7. Fehlerbehandlung bei ungültigen Scalar-Werten
- 8. Custom Scalars in Queries und Mutations nutzen
- 9. Custom Scalars im Vergleich zu Input Types
- 10. Zusammenfassung
- 11. FAQ
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.