WooCommerce Dual API

Writing the code API

This guide covers how to write the PHP classes the GraphQL layer is generated from. It applies to any plugin that defines a dual API; see Creating a dual API in a plugin for the plugin-specific bootstrap. The examples come from the woocommerce-simple-events reference plugin, an event-registration API.

The workflow

  1. Add or edit classes under src/Api/.
  2. Regenerate the GraphQL layer: php bin/build-api.php.
  3. Run your tests and the staleness check.
  4. Commit the source change and the regenerated Autogenerated/ tree together.

You never edit the generated tree by hand. If a generated file looks wrong, fix the source class or the underlying templates and regenerate.

Queries and mutations

A query or mutation is a class with one public execute() method. Place it under Queries/ or Mutations/ (nested subdirectories are fine). The GraphQL field name defaults to the camelCase form of the class name; override with #[Name].

#[Name( 'event' )]
#[Description( 'Fetch a single event by id.' )]
#[PublicAccess]
class GetEvent {
    public function execute(
        #[Description( 'Identifier of the event to fetch.' )]
        int $id,
    ): ?Event {
        return Store::get_event( $id );
    }
}

Mutations are identical except for the directory. They typically take a single input-type argument and return an output type or a dedicated result type.

Output types

Classes under Types/ become GraphQL output types. Public properties become fields, named as-is (snake_case is preserved). Type mapping is inferred from the PHP property type.

#[Description( 'An event open for registration.' )]
class Event {
    use ScheduledItem; // contributes the `id` and `date` fields

    #[Description( 'Human-readable name.' )]
    public string $name;

    #[Description( 'Current lifecycle status.' )]
    public EventStatus $status; // enum

    #[Description( 'Where the event takes place.' )]
    #[Deprecated( 'Use venue instead.' )]
    public string $location;

    #[Description( 'Talks scheduled for this event.' )]
    #[ArrayOf( Session::class )]
    public array $sessions; // list type

    #[Ignore]
    public ?string $audit_token = null; // not exposed
}

Useful attributes on properties:

See the Attributes reference for exact signatures.

Input types

Classes under InputTypes/ become GraphQL input types. A field is optional when its type is nullable or it has a default value; a non-nullable field with no default is required. (The example below uses nullable-with-default for optional fields, which is the common shape.)

Use the TracksProvidedFields trait (from Automattic\WooCommerce\Api\InputTypes) to distinguish “field omitted” (leave unchanged) from “field explicitly set to null” (clear it) - essential for patch-style update mutations:

#[Description( 'Patchable fields on an event.' )]
class UpdateEventInput {
    use TracksProvidedFields;

    #[Description( 'New event name.' )]
    public ?string $name = null;

    #[Description( 'New capacity for the venue.' )]
    public ?int $capacity = null;
}

In the consuming execute(), call $input->was_provided( 'name' ) to check whether the client actually sent the field. This works on any input type that uses the trait, whether it’s an argument to a mutation (the common case, for patch-style updates) or to a query - the operation resolver populates the tracker when it builds the input object. The exception is an #[Unroll]ed input parameter: its fields are flattened into separate arguments and the object is rebuilt through a different path, so was_provided() isn’t populated there.

Enums, interfaces, scalars

Why interfaces are PHP traits

GraphQL interfaces are modeled as PHP traits rather than PHP interfaces for a concrete reason: in the code API a type’s fields are its public properties, and a PHP interface can only declare methods, not properties. A trait, by contrast, can declare the shared properties and inject them into every type that uses it; so a single trait both defines the interface’s field set and physically contributes those fields to each implementer. The builder treats a trait placed under Interfaces/ as a GraphQL interface and registers every output type that uses it as an implementer. (This is also why a query/mutation returning an interface can’t type-hint it directly - a trait isn’t a usable return type - and instead uses #[ReturnType]; see Queries and mutations.)

A trait that lives outside Interfaces/ is just an ordinary code-sharing mixin: the builder does not turn it into a GraphQL type. This matters for input types: an input type may use traits to share fields or behavior (for example TracksProvidedFields, or a shared base of common input fields), but doing so never produces an “input interface”. GraphQL defines interfaces only for output object types (there is no input-interface concept in the GraphQL specification) so there is nothing for the builder to generate. Interface modeling applies to output types only.

Pagination (connections)

List queries handle pagination with Relay-style cursor connections: return a Connection and declare the node type with #[ConnectionOf( <NodeType>::class )], taking a PaginationParams argument (which #[Unroll]s into first / last / after / before).

#[Name( 'eventsConnection' )]
#[Description( 'List events with cursor-based pagination.' )]
#[PublicAccess]
class ListEventsConnection {
    #[ConnectionOf( Event::class )]
    public function execute( PaginationParams $pagination ): Connection {
        // build Edge[] with cursors, a PageInfo, and a total_count
    }
}

This is a whole topic of its own: cursors, PageInfo semantics, the page-size cap, nested connections, and the two ways to build a Connection. See Relay-style pagination.

Infrastructure parameters

execute() and authorize() can declare specially named, underscore-prefixed parameters that the engine injects. They are optional, detected by name, and may appear in any order; declare only the ones you need:

See Recognized methods and parameters for the full contract, and Authentication and authorization for how authorization is wired.

After you change anything

Regenerate and commit the generated tree. The staleness check in your CI fails any pull request whose src/Api/ source doesn’t match its committed Autogenerated/ output.