Custom Scalars in GraphQL: Defining DateTime, Email and JSON Correctly
AI generated
{ }
type
GraphQL · Type System · Schema Design · PHP
Defining Custom Scalars in GraphQL Correctly
DateTime, Email and JSON beyond String and Int

Sending a date as a plain string looks harmless until two clients expect different formats and the backend ends up duplicating validation. Custom scalars move format and validation into a single, reusable type in the schema, with clear rules for serialization, input parsing, and error cases.

17 min read Custom Scalars · serialize · parseValue · parseLiteral webonyx/graphql-php · PHP 8.4

1. Why String and Int aren't enough for DateTime, Email and JSON

GraphQL ships with five built-in scalar types out of the box: Int, Float, String, Boolean, and ID. For a date, an email address, or a dynamic JSON structure, none of these are semantically fitting, which is why most schemas end up modeling them as String out of necessity. The problem: String carries no format guarantee. A client can send "2026-08-06", "06.08.2026", or "August 6, 2026", and without custom scalars, every resolver reading the field has to bring its own parsing and its own validation.

This scattered validation logic is the real cost: a bug in one resolver's date parsing stays isolated, while the same bug in another resolver goes unnoticed because both places implemented the rules independently. Custom scalars centralize serialize, parseValue, and parseLiteral in a single place in the schema. Every client validating a query against the schema gets type errors for malformed DateTime or Email values before any resolver is even called.

2. Anatomy of a custom scalar: serialize, parseValue, parseLiteral

Every custom scalar in GraphQL defines three functions that together cover the full lifecycle of a value. serialize() converts the internal PHP value, for example a DateTimeImmutable object, into the output form for the response. parseValue() handles the reverse for variables sent as a JSON payload alongside a query. parseLiteral() handles the same job when the value appears directly in the query text as a literal, such as createdAt: "2026-08-06T10:00:00Z".

This three-way split exists because GraphQL knows two different input paths: values can arrive as part of the query syntax (a literal, as an AST node) or as a separate variables object (already decoded from JSON into a PHP value). A custom scalar that only implements parseValue() works for variables but either throws an error for literals in the query text or, worse, accepts unvalidated values. A correct implementation covers both paths consistently, with identical validation logic in both functions.

3. Implementing a DateTime custom scalar

A DateTime custom scalar should always work internally with DateTimeImmutable and enforce a fixed format externally, usually ISO 8601 in UTC. This rules out clients sending local timezone strings that would need to be interpreted ambiguously on the server. The following implementation shows all three required methods for 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;
    }
}

The key point: parseValue() and parseLiteral() both call the same private parseIso8601() method. This prevents the validation logic for variables and literals from drifting apart, a common mistake in hand-written custom scalars where both paths are implemented independently and develop different tolerances over time.

4. An Email custom scalar with validation

An Email custom scalar ideally uses PHP's built-in filter_var() function with FILTER_VALIDATE_EMAIL, instead of maintaining a custom regex. Email validation via regex is notoriously error-prone because the RFC 5322 standard is considerably more complex than most hand-written patterns account for. Serialization for this scalar is especially simple, since the internal and external values are identical, a string stays a 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. A JSON custom scalar for dynamic structures

Some fields deliberately carry no fixed structure, for example a metadata field for arbitrary extra attributes, or a configuration structure that differs between object types. Instead of building a rigid GraphQL object type hierarchy for that, a JSON custom scalar is the pragmatic solution. It deliberately gives up type safety inside the structure, but keeps the schema flexible for fields whose shape changes frequently or gets passed through unchanged from external systems.

It's important to use the JSON scalar sparingly: every field typed as JSON loses GraphQL's introspection capabilities, clients can no longer query which sub-fields exist, and tools like GraphiQL cannot offer autocomplete. A JSON custom scalar is therefore the right choice for genuinely dynamic data, not as a shortcut to avoid clean object type design.


<?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. Declaring and registering custom scalars in the SDL

In the Schema Definition Language (SDL), a custom scalar is declared with the scalar keyword, without a field list, since scalars have no sub-fields. The actual behavior from serialize, parseValue, and parseLiteral is registered separately as a PHP class and linked to the SDL type name during schema construction.


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!
}

During registration in PHP, the mapping between SDL type names and scalar classes is typically built through a TypeConfigDecorator, which webonyx/graphql-php calls while parsing the SDL document. Every type name from a scalar declaration is mapped to an instance of the matching custom scalar class, so schema and behavior stay separate but are consistently joined together.

7. Error handling for invalid scalar values

When parseValue() or parseLiteral() throw a GraphQL\Error\Error exception, the execution layer of webonyx/graphql-php catches it and automatically translates it into a structured entry in the response's errors array, including a path to the affected argument. The resolver for the affected mutation is never called in that case, the custom scalar validation runs before the actual business logic.

This behavior is one of the biggest advantages of custom scalars over validation in resolver code: an invalid date or a malformed email address produces a clear, specific error before database access, business logic, or side effects are triggered. When validation only happens in the resolver, there's a risk that parts of the mutation have already executed by the time the error is detected.

8. Using custom scalars in queries and mutations

Custom scalars work symmetrically in both directions: as an output type in queries, where serialize() applies, and as an input type in mutation arguments or input types, where parseValue() or parseLiteral() apply. This symmetry is an important design advantage over separate input and output types, because a single scalar type name can be used consistently throughout the entire schema.


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

In this example, the Email custom scalar already validates the email argument during query parsing, the JSON custom scalar passes preferences through unchanged as a nested object, and createdAt gets serialized into ISO 8601 format via the DateTime custom scalar when the response is delivered. From the client's perspective the entire process is transparent, only a different scalar type name is visible in the schema.

9. Custom scalars compared to input types

For structured input with multiple named fields, a GraphQL input type is often the better choice over a custom scalar. The table below shows when each approach makes more sense.

Criterion Custom Scalar Input Type
Structure known and fixed Unsuitable Ideal
Introspection of sub-fields Not possible Full
A single primitive value with a format rule Ideal Overkill
Dynamic, unknown structure (JSON) Fitting Cannot be modeled
Reuse across many types Very good Possible, but heavier

The rule of thumb: custom scalars for single values with a clear format rule like DateTime or Email, input types for structured objects with multiple named fields. JSON scalars are the deliberate exception for data whose structure is not known at schema design time or changes too often to type meaningfully.

Mironsoft

GraphQL schema design, type safety, and Magento integration

Want a GraphQL schema with clean, reusable types?

We design custom scalars for date values, email addresses, and dynamic structures that centralize validation and free resolver code from repeated checking logic.

Schema review

Analysis of existing string fields for custom scalar potential

Scalar implementation

serialize, parseValue, and parseLiteral, consistent and tested

Error handling

Structured GraphQL errors instead of scattered resolver validation

10. Summary

Custom scalars solve a recurring problem in GraphQL schemas: values with a clear format rule that neither a generic String nor an Int can model cleanly. The three methods serialize, parseValue, and parseLiteral together cover the full lifecycle of a value, from the database through query execution to the response. DateTime, Email, and JSON are the three most common practical use cases, each with its own validation logic but an identical underlying structure.

The biggest win lies in centralization: validation rules that would otherwise be scattered across dozens of resolvers live in a single place in the schema and apply automatically before resolvers are even called. For structured objects with multiple fields, the input type remains the right choice, custom scalars complement it for single, format-specific values and for deliberately dynamic structures like JSON metadata.

Custom Scalars in GraphQL — Key Takeaways

Three required methods

serialize for output, parseValue for variables, parseLiteral for literals in the query text.

Centralized validation

Invalid values are caught as a GraphQL error before the resolver is called, not first in business logic.

DateTime, Email, JSON

The three most common custom scalars, each with its own but consistent validation logic.

Distinction from input types

Custom scalars for single values with a format rule, input types for structured objects with multiple fields.

11. FAQ: Custom Scalars in GraphQL

1What is a custom scalar in GraphQL?
A self-defined scalar type extending the built-in types with custom format rules and validation, for example DateTime, Email, or JSON.
2What is serialize() used for?
Converts the internal PHP value into the output form for the response, usually a string in the agreed format.
3Why both parseValue AND parseLiteral?
GraphQL knows two input paths, variables and literals in the query text. Both must use the same validation.
4How do you validate DateTime correctly?
With createFromFormat against a fixed format like ISO 8601, throwing a GraphQL Error exception on failure.
5Why FILTER_VALIDATE_EMAIL over regex?
RFC 5322 is more complex than most hand-written patterns account for, the built-in function is more reliable.
6When to use a JSON custom scalar?
Only for genuinely dynamic structures without a fixed shape. For known structures, a regular object type is better.
7How is a scalar declared in the SDL?
With the scalar keyword and the type name, without a field list. The logic lives separately in a PHP class.
8What happens on an invalid value?
The Error exception becomes a structured errors entry, the resolver is never called.
9Scalar or input type for structured data?
Input type for multiple named fields with introspection, custom scalar for single values with a format rule.
10Do custom scalars lose introspection?
The scalar type name stays introspectable, only inside a JSON scalar is there no sub-structure introspection.