GraphQL Directives: Building Custom Directives for Auth and Formatting
AI generated
{ }
type
GraphQL · Directives · Auth · Schema Design
GraphQL Directives: Building Custom Directives for Auth and Formatting
Declaring recurring field behavior right in the schema instead of the resolver

When the same authorization check gets copied into twenty resolvers, that's a sign the behavior belongs in the schema declaration instead of repeated code. GraphQL directives make exactly that possible: an @auth tag on a field replaces the boilerplate check, a @formatDate directive controls output formats without touching the resolver.

18 min read Custom Directives · @auth · @formatDate · Directive Visitor webonyx/graphql-php · PHP 8.4

1. What directives in GraphQL really are

A directive in GraphQL is an annotatable marker in the schema or a query, recognizable by its leading @ sign, that triggers additional behavior at a specific location. GraphQL ships with two built-in directives, @skip and @include, which conditionally hide or show fields at the query level. These built-in directives demonstrate the core principle: instead of hiding logic inside resolvers, it becomes explicit and declarative, visible directly at the affected spot in the schema or query.

Custom GraphQL directives extend this principle to schema-side use cases: access control, formatting, deprecation hints, rate-limiting rules, or caching hints can all be declared as a directive on a field instead of being reimplemented in every affected resolver. The decisive difference from a function inside a resolver: a directive is part of the schema definition itself and visible directly on the field, both to tools that introspect the schema and to other developers, without having to read resolver code.

2. The @auth directive: restricting field access by role

An @auth directive is the classic entry point for custom GraphQL directives, because the pattern "this field may only be read with role X" shows up in almost every production schema. Without a directive, this check would sit as the first line in every affected resolver, with a directive it sits once, directly on the field declaration.


directive @auth(role: String!) on FIELD_DEFINITION

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

The declaration directive @auth(role: String!) on FIELD_DEFINITION establishes two things: first, the directive accepts a required argument role of type String, second, it may only be applied to field definitions, not to query arguments or type declarations. This restriction via on FIELD_DEFINITION prevents the directive from accidentally ending up at a semantically unfitting spot in the schema.

3. Declaring a directive definition in the SDL

Every custom directive must be declared in the SDL before use, with a name, arguments, and the allowed application locations. GraphQL knows over twenty possible locations, including FIELD_DEFINITION, OBJECT, ARGUMENT_DEFINITION, and QUERY, and every directive must explicitly state which of these it's allowed on. Multiple locations can be combined with | if a directive makes sense both on fields and on entire types.


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
}

In this example, @auth(role: "ADMIN") applies at the type level, using OBJECT as an allowed location, and implicitly protects every field of the AdminReport type. This pattern noticeably reduces repetition compared to a field-by-field annotation whenever an entire type should only be accessible to a specific role.

4. Implementing a directive handler

The SDL declaration alone doesn't trigger any behavior yet, it merely describes that the directive is allowed to exist. The actual logic is implemented in webonyx/graphql-php via a schema visitor or an explicit field wrapper that enriches the original resolver at a directive-annotated location with additional behavior.


<?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);
    }
}

This approach wraps the original resolver in a new closure that first runs the role check and only calls the original resolver on success. The actual field resolver stays completely unchanged and knows nothing about the existence of the @auth directive, the authorization check is fully decoupled from the business logic.

5. The @formatDate directive: output formatting in the schema

Besides access control, custom directives are also useful for output formatting. A @formatDate directive lets you declare the desired date format right in the schema next to the field, instead of solving formatting logic via custom scalars or client-side libraries. That's especially handy for internal reporting schemas where different fields need different, fixed display formats.


<?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;
    }
}

The resolver still returns a DateTimeImmutable object, exactly as it would without the directive, formatting only happens in the wrapping closure, right before the value goes back to the GraphQL execution layer. If no pattern argument is present, the default value "Y-m-d" defined in the SDL kicks in.

6. Directives with arguments: configurable behavior

Both @auth(role: String!) and @formatDate(pattern: String = "Y-m-d") demonstrate an important pattern: GraphQL directives can accept arguments with or without a default value, exactly like fields and query arguments. A required argument without a default, like role on @auth, forces every use of the directive to explicitly specify a value. An optional argument with a default, like pattern on @formatDate, allows compact use of @formatDate without parentheses when the standard format is sufficient.

This flexibility makes custom directives a powerful tool for configurable, reusable behavior: a single @auth directive covers any number of roles, a single @formatDate directive covers any number of date formats, without needing a separate directive written for every combination. The directive handler reads the arguments from the AST at schema build time and configures the wrapped behavior accordingly.

7. Directives at the query level: @skip, @include, and client directives

Besides schema-side directives on field definitions, GraphQL also knows directives that clients use directly in the query text, first and foremost the built-in @skip(if: Boolean!) and @include(if: Boolean!). These control whether a field is included in the response based on a variable, useful for queries that need different field sets depending on UI state, without maintaining two separate query strings.


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

Custom client directives, declared by the schema but used by the client in the query, are also possible, though they show up less often in practice than schema-side field directives like @auth. One use case would be a @client directive for Apollo Client that resolves a field purely locally, without sending it to the server, a pattern well established in the Apollo ecosystem.

8. Testing and debugging custom directives

A custom directive is most reliably tested with two kinds of tests: unit tests for the directive handler in isolation, using a mock field and various role combinations, and integration tests that run a complete query against the schema and check whether the directive correctly kicks in, either in the response result or in the errors array.


# 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'

A common debugging case is a directive that's declared in the SDL but shows no effect. Usually it's because the directive handler was never wired into the schema build process, for example because the visitor wasn't registered or a TypeConfigDecorator was forgotten. A simple test for this: run a query with an obviously wrong role and check whether any error comes back at all, before digging deeper into the directive's own logic.

9. Custom directives compared to resolver logic

Not every piece of logic belongs in a directive. The table below shows when a custom directive makes more sense than direct resolver code.

Criterion Custom Directive Resolver Logic
Recurring across many fields Ideal A lot of duplication
Visible directly in the schema for tools/reviewers Yes No, only in code
Field-specific, one-off logic Overkill Ideal
Complex, multi-step business logic Unsuitable Ideal
Configurable via arguments Very good Requires custom parameters

The rule of thumb: custom directives for cross-cutting behavior that's identical across many fields and should be visible in the schema, resolver logic for field-specific, one-off, or complex multi-step business logic. A directive that ends up needing field-specific special cases anyway is usually a sign that the logic actually belongs in the resolver.

Mironsoft

GraphQL schema architecture, authorization, and directive design

Want to declare access control directly in your GraphQL schema?

We design and implement custom directives for authorization, formatting, and recurring field behavior, including testing and schema documentation.

Auth directive design

Role-based field access declared centrally in the schema

Directive handlers

Robust field wrapper implementation with argument support

Testing & debugging

Unit and integration tests for reliable directive behavior

10. Summary

GraphQL directives move recurring field behavior out of resolver code and directly into the schema declaration, visible to anyone reading the schema without needing to know the underlying PHP code. An @auth directive replaces copied role checks in resolvers with a declarative annotation on the field, a @formatDate directive controls output formats without changing resolver logic. Both follow the same technical pattern: the original field resolver gets wrapped in a closure that runs additional behavior before or after the actual call.

The important distinction: directives fit cross-cutting behavior that's identical across many fields, not field-specific or complex multi-step business logic. Arguments with and without default values make directives configurable and reusable, so a single custom directive covers any number of configurations in the schema. Respecting that boundary gets you more readable schemas and noticeably less duplicated resolver code.

GraphQL Directives — Key Takeaways

SDL declaration

directive @name(arg: Type) on FIELD_DEFINITION sets the name, arguments, and allowed locations.

Directive handler

Wraps the original resolver in a closure with additional behavior, without changing the business logic.

@auth and @formatDate

Two practical examples for authorization and output formatting directly in the schema.

Boundary with resolvers

Directives for cross-cutting behavior, resolver code for field-specific, complex business logic.

11. FAQ: GraphQL Directives

1What is a directive in GraphQL?
A marker starting with @ that triggers additional behavior, such as access control or formatting.
2How do you declare a custom directive?
With directive @name(arg: Type) on LOCATION, where LOCATION specifies the allowed schema spots.
3How do you implement the behavior?
Via a directive handler that wraps the original resolver in a closure with additional behavior.
4How does @auth work?
Checks the user's role before the resolver runs and throws a GraphQL error if authorization fails.
5Can directives have default arguments?
Yes, just like fields and query arguments, for compact use without explicit parentheses.
6What are @skip and @include?
Built-in query directives that conditionally include fields in the response based on a variable.
7Where should you avoid directives?
For field-specific or complex multi-step business logic that fits better inside the resolver.
8How do you test a custom directive?
With isolated unit tests for the handler and integration tests against the full schema.
9Why does the directive show no effect?
Usually the handler wasn't wired into the schema build process, the SDL declaration alone isn't enough.
10Can multiple directives be on one field?
Yes, multiple directives can be combined, the application order depends on the implementation.