WooCommerce Dual API

Relay-style pagination

List queries in the dual API paginate with cursor-based connections following the Relay Cursor Connections specification. You write a command that returns a Connection; the builder generates the matching GraphQL Connection, Edge, and shared PageInfo types. The building blocks live in Automattic\WooCommerce\Api\Pagination and are shared by every dual API.

The connection shape

For a node type Event, a #[ConnectionOf( Event::class )] query produces this GraphQL shape:

type EventConnection {
  edges: [EventEdge!]!    # each item paired with its cursor
  nodes: [Event!]!        # the items alone, a convenience shortcut
  page_info: PageInfo!
  total_count: Int!       # total matches before the page window
}

type EventEdge {
  cursor: String!
  node: Event!
}

type PageInfo {
  has_next_page: Boolean!
  has_previous_page: Boolean!
  start_cursor: String
  end_cursor: String
}

edges and nodes carry the same items; edges adds the per-item cursor, while nodes is there for clients that just want the data. PageInfo is a single shared type across every connection.

Writing a paginated query

Place the query under Queries/, return a Connection, and annotate execute() with #[ConnectionOf( <NodeType>::class )]. Take an argument of type PaginationParams - this type carries #[Unroll], so its properties expand into individual GraphQL arguments rather than a nested input object:

#[Name( 'eventsConnection' )]
#[Description( 'List events with cursor-based pagination.' )]
#[PublicAccess]
class ListEventsConnection {
    #[ConnectionOf( Event::class )]
    public function execute( PaginationParams $pagination, ?EventStatus $status = null ): Connection {
        // 1. query your data store, fetching one extra row to detect a next page
        // 2. build an Edge per item (cursor + node)
        // 3. populate a PageInfo and total_count
        // 4. return the Connection
    }
}

The resulting field accepts the four standard arguments plus any others you declare (like status above):

eventsConnection(first: Int, last: Int, after: String, before: String, status: EventStatus) { ... }

The pagination arguments

PaginationParams defines the forward/backward window:

Argument Meaning
first Return the first N items (forward pagination).
after Return items after this cursor.
last Return the last N items (backward pagination).
before Return items before this cursor.

Bounds are enforced: first/last must be between 0 and PaginationParams::MAX_PAGE_SIZE; a negative or over-cap value throws INVALID_ARGUMENT (HTTP 400). When neither first nor last is given, PaginationParams::get_default_page_size() applies. The same bounds are enforced on nested connection fields via PaginationParams::validate_args(), so a deeply nested first: 1000 can’t slip past the cap.

These maximum and default page sizes are currently hardcoded to 100, but may become configurable in future versions of the engine.

Cursors

Cursors are opaque strings to the client, never construct or parse them on the client side. Beyond that opacity, the engine mandates nothing about their format: any stable, encodable key works. The reference plugin encodes the node’s numeric id as base64 (base64_encode( (string) $event->id )); IdCursorFilter::decode_id_cursor() is the matching helper that validates such a cursor and throws INVALID_ARGUMENT (400) on a malformed one rather than silently returning unfiltered results. That scheme is a choice, not a requirement; your own connections are free to use a different encoding - just keep cursors opaque and validate them on decode.

IdCursorFilter (in the Api\Pagination namespace) is a helper for connections over WP_Query-backed data. It windows a post query on the ID column via a lazy posts_where filter and two query vars:

Set whichever you need on your WP_Query args and call IdCursorFilter::ensure_registered() once before running the query. None of this is mandated by the engine: a plugin paginating its own post-backed data may find it useful to reuse IdCursorFilter (or follow the same ID-cursor pattern), but it’s specific to WP_Query sources, and a connection over any other data store won’t touch it.

PageInfo semantics

Building the Connection: two paths

Connection supports both a performant pre-paginated path and a slice-it-for-me path, and it guards against being sliced twice (so it’s safe whether or not the generated resolver also calls slice()):

Nested connections

A Connection-typed property on an output type, annotated with #[ConnectionOf], becomes a paginated field on that type; for example an Event.attendees field:

#[Description( 'People registered for this event.' )]
#[ConnectionOf( Attendee::class )]
public Connection $attendees;

The generated resolver slices the property per the field’s own pagination arguments, enforcing the same MAX_PAGE_SIZE cap as top-level queries.

Complexity

Connection fields contribute to a query’s computed complexity: a connection’s cost multiplies its children’s cost by the requested page size. This is what the Maximum query complexity limit guards against, see Settings and caching.

Reusing the building blocks

Connection, Edge, PageInfo, and PaginationParams are part of the public Api\Pagination surface, so a plugin returns them directly without redefining its own. The woocommerce-simple-events plugin’s eventsConnection query is a minimal, in-memory working example (it builds edges over the full set and calls slice()).