Contents
Introduction
This article is a comprehensive, practical guide to extending WPGraphQL with custom types and resolvers in PHP. It covers the basic building blocks, step-by-step code examples, patterns for organizing code in a plugin or theme, security and performance considerations, testing and debugging tips, and advanced topics such as mutations and external data sources. All examples target WPGraphQL running on WordPress (assumes WPGraphQL plugin installed and active).
Prerequisites
- WordPress site with administrator access.
- WPGraphQL plugin installed and active. (Official project: https://github.com/wp-graphql/wp-graphql).
- PHP 7.4 recommended. Composer available if you plan to autoload classes or add external libraries like a DataLoader implementation.
- Basic familiarity with PHP, WordPress hooks, and GraphQL concepts (types, fields, queries, mutations).
Core concepts
When extending WPGraphQL youll generally work with:
- Object types — new GraphQL types (register_graphql_object_type).
- Fields — adding fields to existing types such as RootQuery, Post, User (register_graphql_field).
- Resolvers — PHP callables that return data for a field. Resolver signature: (source, args, context, info).
- Mutations — operations to create/update/delete persistent state (register_graphql_mutation).
- Schema hook — add your types/fields on the graphql_register_types action.
Basic anatomy: register types and fields
The most common hook is graphql_register_types. Use it to call register_graphql_object_type, register_graphql_field, register_graphql_enum, register_graphql_interface, and register_graphql_mutation.
Minimal example: register a new object type and root query field
This example registers a simple object type MyType and a root query myType that returns a single instance. Place this in a plugin file or inside your themes functions.php (prefer plugin for maintainability).
lt?php / Plugin Name: WPGraphQL Custom Types Example Description: Example of adding a custom type and resolver to WPGraphQL. Version: 0.1 Author: You / add_action( graphql_register_types, function() { // Register a new GraphQL object type MyType register_graphql_object_type( MyType, [ description => A simple custom type, fields => [ id => [ type => ID, description => Unique identifier, ], name => [ type => String, description => Name of the item, ], meta => [ type => String, description => Computed or meta information, ], ], ] ) // Add a field to the RootQuery to fetch MyType register_graphql_field( RootQuery, myType, [ type => MyType, args => [ id => [ type => ID, ], ], resolve => function( root, args, context, info ) { id = isset( args[id] ) ? args[id] : default-id // Example data replace with real data lookup return [ id => id, name => Item {id}, meta => Generated at . current_time( mysql ), ] }, ] ) } )
GraphQL query example
# Query you can run in GraphiQL or via POST to /graphql { myType(id: abc123) { id name meta } }
Expected JSON response
{ data: { myType: { id: abc123, name: Item abc123, meta: Generated at 2025-09-26 10:00:00 } } }
Resolving fields on existing types (example: add field to Post)
Often youll add fields to existing WPGraphQL types like Post, User, or Term. Use register_graphql_field with the type name, for example Post or the GraphQL single type name like Post (WPGraphQL registers Post type as Post by default).
add_action( graphql_register_types, function() { // Add an estimated_read_time field to Post type register_graphql_field( Post, estimatedReadTime, [ type => Int, description => Estimated read time in minutes computed from post content, resolve => function( post, args, context, info ) { // post is the WP_Post object or array depending on how WPGraphQL returns it. content = if ( is_object( post ) isset( post->post_content ) ) { content = post->post_content } elseif ( is_array( post ) isset( post[content][raw] ) ) { // WPGraphQL may supply content in nested array form for some use-cases content = post[content][raw] } words = str_word_count( wp_strip_all_tags( content ) ) wpm = 200 // words per minute return max( 1, (int) ceil( words / wpm ) ) } ] ) } )
Registering a custom post type integrated with WPGraphQL
If you register a custom post type in WordPress, expose it to the GraphQL schema via the register_post_type args show_in_graphql => true and the GraphQL name fields. Example below.
add_action( init, function() { register_post_type( book, [ label => Books, public => true, supports => [ title, editor, thumbnail ], show_in_graphql => true, graphql_single_name => Book, graphql_plural_name => Books, ] ) }, 0 )
WPGraphQL will automatically add Book to the schema with connections and queries (books, book). You can further extend Book with additional fields via register_graphql_field as shown in the Post example.
Enums and interfaces
Use register_graphql_enum_type and register_graphql_interface to model more precise schemas.
add_action( graphql_register_types, function() { register_graphql_enum_type( DifficultyEnum, [ description => Difficulty level, values => [ EASY => [ value => easy, ], MEDIUM => [ value => medium, ], HARD => [ value => hard, ], ], ] ) register_graphql_object_type( Recipe, [ fields => [ title => [ type => String ], difficulty => [ type => DifficultyEnum ], ], ] ) } )
Mutations: create, update, delete
Mutations are created using register_graphql_mutation. The pattern is inputFields, outputFields, and mutateAndGetPayload. Permissions should be enforced inside mutateAndGetPayload (check current user capabilities and nonce where appropriate).
add_action( graphql_register_types, function() { register_graphql_mutation( createBook, [ inputFields => [ title => [ type => String, ], content => [ type => String, ], ], outputFields => [ bookId => [ type => ID, resolve => function( payload, args, context, info ) { return isset( payload[bookId] ) ? payload[bookId] : null }, ], book => [ type => Book, resolve => function( payload ) { return isset( payload[book] ) ? get_post( payload[book] ) : null }, ], ], mutateAndGetPayload => function( input, context, info ) { // Permissions: ensure user can create posts if ( ! is_user_logged_in() ! current_user_can( publish_posts ) ) { throw new GraphQLErrorError( You do not have permission to create a book. ) } post_id = wp_insert_post( [ post_title => sanitize_text_field( input[title] ?? ), post_content => wp_kses_post( input[content] ?? ), post_type => book, post_status => publish, ] ) if ( is_wp_error( post_id ) ! post_id ) { throw new GraphQLErrorError( Failed to create book. ) } return [ bookId => post_id, book => get_post( post_id ), ] }, ] ) } )
External APIs: resolvers that fetch external data with caching
Resolvers commonly call external data sources. Use WordPress HTTP API (wp_remote_get / wp_remote_post) and cache results via transients or object cache to reduce latency and rate limits. Also handle errors and timeouts gracefully.
add_action( graphql_register_types, function() { register_graphql_object_type( ExternalUser, [ fields => [ id => [ type => ID ], name => [ type => String ], email => [ type => String ], profileUrl => [ type => String ], ] ] ) register_graphql_field( RootQuery, externalUser, [ type => ExternalUser, args => [ id => [ type => ID ], ], resolve => function( root, args ) { id = args[id] ?? if ( empty( id ) ) { return null } transient_key = external_user_{id} cached = get_transient( transient_key ) if ( cached ) { return cached } response = wp_remote_get( https://api.example.com/users/ . rawurlencode( id ), [ timeout => 5, ] ) if ( is_wp_error( response ) ) { error_log( External API error: . response->get_error_message() ) return null } code = wp_remote_retrieve_response_code( response ) body = wp_remote_retrieve_body( response ) if ( code !== 200 empty( body ) ) { return null } data = json_decode( body, true ) if ( ! is_array( data ) ) { return null } mapped = [ id => data[id] ?? id, name => data[full_name] ?? data[name] ?? null, email => data[email] ?? null, profileUrl => data[profile_url] ?? null, ] // Cache for 5 minutes set_transient( transient_key, mapped, 5 MINUTE_IN_SECONDS ) return mapped }, ] ) } )
Batching and dataloader patterns
GraphQL queries often request many items in the same request. To avoid N 1 problems, use batching. There are two approaches:
- Use an external DataLoader implementation (e.g., overblog/dataloader-php) with a per-request instance stored in the GraphQL context.
- Implement a lightweight batch cache keyed by IDs in a resolver and load missing items in batch before resolving each field.
Example below demonstrates the idea of a simple per-request cache for external data. For production-grade batching, prefer a DataLoader library.
/ Simple per-request cache helper. Store in WPGraphQL context via a static or global. In a plugin, you may store this in a singleton or in the context using add_filter( graphql_context, ... ) to add your own objects. / // Add a context object (optional) with a simple cache container: add_filter( graphql_context, function( context ) { context[my_batch_cache] = [] return context } ) add_action( graphql_register_types, function() { // Assume we have a Post type field externalData that needs batching. register_graphql_field( Post, externalData, [ type => String, resolve => function( post, args, context, info ) { id = is_object( post ) ? post->ID : ( post[databaseId] ?? null ) if ( ! id ) { return null } // Use context cache to store fetched external data per post id if ( isset( context[my_batch_cache][ id ] ) ) { return context[my_batch_cache][ id ] } // For demonstration: fetch remote data for a single item. // For batching, youd accumulate missing IDs and load them once // prior to resolving all posts in the requested page. response = wp_remote_get( https://api.example.com/posts-meta/ . rawurlencode( id ), [ timeout => 5 ] ) if ( is_wp_error( response ) ) { context[my_batch_cache][ id ] = null return null } body = wp_remote_retrieve_body( response ) data = json_decode( body, true ) value = data[meta_summary] ?? null context[my_batch_cache][ id ] = value return value } ] ) } )
Note on DataLoader libraries
For robust batching, instantiate a DataLoader per GraphQL request and store it on the GraphQL context using the graphql_context filter. Then use the dataloader to queue loads in field resolvers and return promises or resolved values according to the librarys API. See libraries like overblog/dataloader-php.
Organizing code: recommended plugin structure
As a project grows, keep your schema code organized. A recommended structure:
- src/Schema/RegisterTypes.php — central registration class hooking into graphql_register_types.
- src/Types/ — classes that define object types, enums, interfaces.
- src/Resolvers/ — resolver classes or functions that encapsulate business logic and external API calls.
- src/Mutations/ — mutation classes.
- composer.json and PSR-4 autoloading (optional but recommended).
Example of a small class-based registration pattern (autoloader assumed):
lt?php namespace MyPluginSchema class RegisterTypes { public function __construct() { add_action( graphql_register_types, [ this, register ] ) } public function register() { this->register_my_type() this->register_query_field() } protected function register_my_type() { register_graphql_object_type( MyType, [ fields => [ id => [ type => ID ], name => [ type => String ], ], ] ) } protected function register_query_field() { register_graphql_field( RootQuery, myV2, [ type => MyType, args => [ id => [ type => ID ] ], resolve => [ this, resolve_my_v2 ], ] ) } public function resolve_my_v2( root, args ) { // Use separate resolver class in larger projects return [ id => args[id] ?? none, name => From class-based resolver, ] } }
Security best practices
- Always validate and sanitize inputs in resolvers and mutations (sanitize_text_field, wp_kses_post, intval, etc.).
- Enforce capability checks for mutations or sensitive queries (current_user_can, is_user_logged_in).
- Prefer explicit error messages but avoid leaking sensitive internal details. Throw GraphQL errors using GraphQLErrorError.
- Rate-limit or cache expensive external API calls. Use transients/object cache for repeated queries.
- If exposing internal endpoints, use nonces or tokens where appropriate. For mutations, rely on WordPress user caps rather than nonces alone for server-side enforcement.
Performance considerations
- Batch database and API calls where possible to avoid N 1 queries.
- Use caching (transient API, object cache) for expensive or infrequently changing data.
- Limit default page sizes and enforce sensible maxima for arguments such as first/last on connections.
- Use GraphQL query complexity or depth limiting plugins/middleware if you expose a public API to limit abusive queries.
- Consider persisted queries or persisted fragments for extremely high-traffic public APIs.
Debugging and testing
- Use WPGraphiQL or GraphiQL IDE to explore your schema and try queries. The WPGraphQL plugin adds a GraphiQL IDE in the WP admin panel if enabled.
- Log resolver errors to error_log or a logger like monolog for debugging. Throw GraphQLErrorError to return GraphQL-compliant errors.
- Write integration tests (PHPUnit) for resolvers by bootstrapping WP in tests or using WPGraphQL test utilities. Test resolver results and permission enforcement.
- For client-side testing, use Apollo Client or similar with introspection turned on to verify type definitions and queries.
Advanced: custom connections and pagination
WPGraphQL already exposes connections for post types and terms. If you need custom pagination structures or a custom connection type, you can register object types that implement Relay connection patterns, or reuse WPGraphQLs connection helpers. For most cases, add filter hooks to modify queries executed by WPGraphQL post connections (filters: graphql_post_object_aux_query or use WP_Query args before GraphQL resolves).
Error handling patterns
When resolvers encounter errors, use exceptions to propagate GraphQL-compliant errors. Example:
use GraphQLErrorError add_action( graphql_register_types, function() { register_graphql_field( RootQuery, dangerous, [ type => String, resolve => function() { try { // risky operation if ( rand(0,1) ) { throw new Exception( Something went wrong ) } return all good } catch ( Exception e ) { // Log internal details for debugging error_log( Resolver error: . e->getMessage() ) // Throw GraphQL-friendly error for the client throw new Error( Unable to fetch the resource at this time. ) } } ] ) } )
Versioning your schema
GraphQL typically favors additive changes. For larger evolutions:
- Prefer adding fields or types rather than removing/modifying existing ones.
- Deprecate fields with a reason using the deprecationReason key in field config.
- If you must change behavior drastically, consider namespacing new fields/types with a version suffix (e.g., myFieldV2) or expose a version field from the API.
Example: full plugin combining many pieces
Below is a compact example plugin that ties together a custom type, a field on RootQuery, a mutation, and external API caching. Use this as a starting point and expand into classes as your project grows.
lt?php / Plugin Name: WPGraphQL Extended Example Description: Demonstrates custom types, fields, mutations, and external data in WPGraphQL. Version: 0.1 Author: You / add_action( graphql_register_types, function() { // Object type register_graphql_object_type( Widget, [ description => A custom widget from external API or WP content., fields => [ id => [ type => ID ], title => [ type => String ], summary => [ type => String ], ], ] ) // Root query to fetch a widget (external API) with caching register_graphql_field( RootQuery, widget, [ type => Widget, args => [ id => [ type => ID ], ], resolve => function( root, args ) { id = args[id] ?? null if ( ! id ) { return null } cache_key = widget_api_{id} cached = get_transient( cache_key ) if ( cached ) { return cached } response = wp_remote_get( https://api.example.com/widgets/{id}, [ timeout => 5 ] ) if ( is_wp_error( response ) ) { return null } body = wp_remote_retrieve_body( response ) data = json_decode( body, true ) if ( ! is_array( data ) ) { return null } mapped = [ id => data[id] ?? id, title => data[title] ?? null, summary => data[summary] ?? null, ] set_transient( cache_key, mapped, 10 MINUTE_IN_SECONDS ) return mapped } ] ) // Mutation to create a widget as a WP custom post register_graphql_mutation( createWidgetPost, [ inputFields => [ title => [ type => String ], content => [ type => String ], ], outputFields => [ postId => [ type => ID ], post => [ type => Post, resolve => function( payload ) { return isset( payload[postId] ) ? get_post( payload[postId] ) : null } ], ], mutateAndGetPayload => function( input ) { if ( ! is_user_logged_in() ! current_user_can( publish_posts ) ) { throw new GraphQLErrorError( Permission denied. ) } post_id = wp_insert_post( [ post_title => sanitize_text_field( input[title] ?? ), post_content => wp_kses_post( input[content] ?? ), post_status => publish, post_type => post, ] ) if ( is_wp_error( post_id ) ! post_id ) { throw new GraphQLErrorError( Failed to create post. ) } return [ postId => post_id ] } ] ) } )
Common pitfalls and troubleshooting
- Forgetting to run register_graphql_object_type or calling it too late always use graphql_register_types hook.
- Confusing WPGraphQL type names and WordPress internal names verify the GraphQL type names via introspection or GraphiQL schema explorer.
- Returning incorrect data shapes from resolvers. If your field type is an object, return an array or object with matching keys/attributes. For ID fields, ensure values are scalars.
- Not sanitizing inputs — always sanitize and validate mutation inputs server-side.
- Side effects in resolvers — avoid writing to the DB in pure query resolvers reserve writes for mutations.
References and useful links
- WPGraphQL (GitHub) — official plugin repo and docs.
- WPGraphQL Docs — official documentation and guides.
- overblog/dataloader-php — PHP DataLoader implementation for batching.
Wrap-up
Extending WPGraphQL in PHP is mostly about defining types and fields with register_graphql_object_type and register_graphql_field and implementing resolvers that return the expected shapes. Use register_graphql_mutation for changes. Organize code, enforce permissions, and address performance via caching and batching. Start small with a plugin scaffold and move resolvers into classes as complexity grows.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |