This document explains how the pieces fit together. For how to actually write classes, see Writing the code API.
A dual API has two halves:
src/Api/. They are GraphQL-agnostic: they import nothing from any GraphQL library and work as a standalone, in-process PHP API. This is the authoritative, manually maintained source.src/Internal/Api/Autogenerated/ produced by a build script from the code API. It is committed to source control but never hand-edited. It powers the GraphQL endpoint your plugin registers (for example POST|GET /wp-json/wc/graphql/simple-events).The build script reads the code API and (re)generates the GraphQL layer. The relationship is one-directional: you change PHP classes, then regenerate.
src/Api/ ──(build-api.php)──▶ src/Internal/Api/Autogenerated/ ──▶ /wp-json/<your-route>
(you edit this) (generated, committed, never edited) (GraphQL endpoint)
Because the generated tree is committed to source code, regenerating it after a source change is mandatory; a staleness check can enforce this in your CI pipeline.
The code API is organized around the command pattern: each query or mutation is a class with a single execute() method (plus an optional authorize() method). Output types, input types, enums, interfaces, and scalars are likewise plain classes/enums.
#[Name( 'event' )]
#[Description( 'Fetch a single event by id.' )]
#[PublicAccess]
class GetEvent {
public function execute( int $id ): ?Event {
// ...
}
}
The build script infers as much as it can from code structure and uses PHP 8 attributes only where structure is not enough.
Two conventions drive most behavior:
Queries/ becomes a GraphQL query; one in Types/ becomes an output type; one in Enums/ becomes an enum; and so on. Arbitrary nested subdirectories are allowed for organization (e.g. Queries/Reports/GetStatistics.php) - nesting does not change the role. See Recognized directories.SCREAMING_SNAKE_CASE. Any of these can be overridden with #[Name( '...' )].Attributes fill the gaps that conventions cannot: descriptions, authorization, type shaping (arrays, connections, custom scalars), deprecation, and metadata. See the Attributes reference.
The GraphQL endpoint is currently powered by the webonyx/graphql-php package, which the WooCommerce Dual API plugin vendors and re-namespaces to Automattic\WooCommerce\Vendor\GraphQL\* to avoid version conflicts with other plugins.
This is deliberately hidden from code-API authors. The autogenerated code never references Vendor\GraphQL\* directly: it references only a thin schema surface under Api\Infrastructure\Schema\*. That surface is the single point of contact with the engine, so the engine could be replaced in the future without breaking already-committed generated code in plugins. As a code-API author you never see GraphQL types at all; as an engine maintainer, see Extending the infrastructure.
In the WooCommerce Dual API plugin (the engine):
| Path | Contents | Edit? |
|---|---|---|
src/Api/Attributes/ |
The built-in attributes (Name, Description, RequiredCapability, Metadata, …) |
Engine maintainers only |
src/Api/Infrastructure/ |
Public, engine-decoupled runtime surface and default convention classes (Main, Principal, ClassResolver, GraphQLControllerBase, the Schema\* wrappers, …) |
Engine maintainers only |
src/Api/Pagination/ |
The Relay pagination building blocks (Connection, Edge, PageInfo, PaginationParams) |
Engine maintainers only |
src/Api/*Exception.php |
The exception hierarchy | Engine maintainers only |
src/Internal/Api/ |
Internal runtime not referenced by external code (QueryCache, Settings, endpoint registrar, query rules, the plugin bootstrap) |
Engine maintainers only |
bin/api-builder/ |
The build tooling (ApiBuilder, build-api.php, staleness checker, templates). Shipped with the plugin so code-API plugins can build against an installed copy |
Engine maintainers only |
lib/packages/GraphQL/ |
The vendored GraphQL engine | Never; re-vendored with Mozart |
In a plugin that defines a dual API:
| Path | Contents | Edit? |
|---|---|---|
src/Api/ |
Your code API: queries, mutations, types, input types, enums, interfaces, scalars, custom attributes, convention classes, helpers | Yes, this is the source |
src/Internal/Api/Autogenerated/ |
Generated GraphQL resolvers and type definitions | No, regenerate instead |
bin/build-api.php |
Your build script, a thin wrapper around ApiBuilder::run_for_plugin() |
Rarely |
The generated tree mirrors the role directories: Autogenerated/GraphQLQueries/, GraphQLMutations/, and GraphQLTypes/{Output,Input,Enums,Interfaces,Scalars,Pagination}/, plus a RootQueryType, RootMutationType, TypeRegistry, and the GraphQLController that the endpoint is attached to.
When a GraphQL request hits an endpoint, the controller (a generated subclass of GraphQLControllerBase):
PrincipalResolver, then decides whether the request is processed at all: the “Allow anonymous requests” setting and the woocommerce_graphql_request_allowed filter can refuse it with a 401 before the query is parsed.OverlappingFieldsRule), and a response carries at most GraphQLControllerBase::MAX_ERRORS_PER_RESPONSE errors, with the number left out in extensions.omittedErrors.ClassResolver, check authorization, and call execute().HttpStatusResolver).Each of these steps is a documented extension point; see Authentication and authorization, Settings and caching, and Infrastructure classes.
Every plugin with a dual API registers its own, dedicated endpoint; there is no shared or federated schema. The engine is shared, though: its settings, filters and caches apply to every endpoint on the site (see Settings and caching). See Creating a dual API in a plugin for the bootstrap.