Skip to content

feat: Support custom Type-Directives#806

Open
michael-georgiadis wants to merge 12 commits into
thecodingmachine:masterfrom
michael-georgiadis:feat/support-custom-directives
Open

feat: Support custom Type-Directives#806
michael-georgiadis wants to merge 12 commits into
thecodingmachine:masterfrom
michael-georgiadis:feat/support-custom-directives

Conversation

@michael-georgiadis
Copy link
Copy Markdown

Description

This adds a way for you to configure your own directives in GraphQLite using PHP attributes and registering them on the schema.

They split in two. Pure metadata ones or with some run behavior. I am expecting a debate on this cause they do look a lot like middleware. Happy to reach somewhere with it tho.

It also includes the @oneOf which is built in.

Why

GraphQLite has always been a pain to deal with when directives come into play. I have used it in one company that we wanted to have federated support and we had to hack a lot of internals to add them and then now a teammate also had to use the ClassFinder class of the library to wire the @oneOf directive and keep the GraphQLite feel in our implementation

In my opinion (and it is purely my opinion ofc), if webonyx supports directives, GraphQLite should do too.

How it works

A directive is a PHP attribute class that implements one of the family interfaces,
which is what ties it to a GraphQL location:

Interface GraphQL location
FieldDirective FIELD_DEFINITION
InputFieldDirective INPUT_FIELD_DEFINITION
ObjectTypeDirective OBJECT
InputObjectTypeDirective INPUT_OBJECT

Implementing the interface alone gives you a metadata-only directive: it's registered on the schema and shows up in introspection, but does nothing at runtime. To actually do something, implement the matching Behavioral* sub-interface, which adds an apply hook dispatched through a middleware pipe.

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)]
final class Uppercase implements BehavioralFieldDirective
{
    public static function definition(): DirectiveDefinition
    {
        return new DirectiveDefinition(name: 'uppercase', locations: [DirectiveLocation::FIELD_DEFINITION]);
    }

    public function applyToField(QueryFieldDescriptor $descriptor, FieldHandlerInterface $next): FieldDefinition|null
    {
        $resolver = $descriptor->getResolver();
        return $next->handle($descriptor->withResolver(
            static fn (...$args) => strtoupper((string) $resolver(...$args)),
        ));
    }
}

The pieces, each with one job:

  • Discovery (DirectiveClassFinder) — finds directive classes in the configured
    namespaces, reusing the existing class-finder cache.
  • DirectiveValidator — validates a class: the #[Attribute] PHP target has to
    cover the declared locations, and the implemented interface has to match the location.
  • DirectiveResolver — turns a valid class into the data we cache: its constructor
    arguments (as GraphQL args), its repeatability, and the webonyx Directive to register.
  • DirectiveRegistry — orchestrates validate → resolve, enforces name uniqueness
    (and the built-in override rule), stores the result, and answers lookups.

What ends up in the schema

  • Definitions — custom directives are registered on the schema, so they appear in
    SDL (directive @uppercase on FIELD_DEFINITION) and in introspection alongside the
    webonyx built-ins.
  • Behavior — behavioral directives wrap their resolver / mutate the type at build time.
  • Applications — when you put @audit on a field, the application is recorded on
    the element's astNode->directives, but it is not printed in the SDL.

That last point is on purpose: webonyx's SchemaPrinter doesn't print directive applications (only @deprecated and @oneOf, which it special-cases), and we follow that behavior rather than shipping a parallel printer GraphQLite would have to maintain. The applications are still attached to the AST, so anyone who needs them in SDL (Apollo Federation's @key, schema tooling) can plug in their own printer.

Notable decisions

  • @oneOf as a built-in. #[OneOf] on an #[Input] class flips webonyx's isOneOf
    flag. webonyx already declares @oneOf, so we don't register a second definition for it
    (DirectiveDefinition::$builtIn = true). Users can override a built-in by shipping their
    own class with the same name.

Scope / not included

  • Apply hooks exist for the four locations above. Other locations (SCALAR, ENUM,
    INTERFACE, SCHEMA, …) are present in the DirectiveLocation enum but not wired yet.
  • Argument types are limited to scalars (string, int, float, bool) for now.
  • Executable (query-side) directives like a client-applied @uppercase are out of scope;
    these are type-system directives declared by the API author.

@michael-georgiadis michael-georgiadis changed the title Feat/support custom directives feat: Support custom Type-Directives Jun 1, 2026
@oojacoboo
Copy link
Copy Markdown
Collaborator

oojacoboo commented Jun 2, 2026

So, I don't really have an issue with this. I'd need to review in further detail. But, I think we should probably separate this out into 2 PRs - one that adds support for the built-in GraphQL directives, first. And then the custom directive overlay.

I thought we already had the #[Deprecated] attribute (or was working with @deprecated annotation, previously), but that doesn't seem to be the case. Nonetheless, we'd need these built-in directives to flow through the directive middleware. Then, the custom directives could simply plug into the directive layer.

I think having this broken apart will help to reason about it more clearly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants