survos/wordpress-bundle

Symfony HttpClient client for the WordPress REST API (wp/v2) — posts, pages, media, taxonomies and custom post types, with multi-site configuration and Application Password auth.

Maintainers

Package info

github.com/survos/wordpress-bundle

Type:symfony-bundle

pkg:composer/survos/wordpress-bundle

Transparency log

Fund package maintenance!

kbond

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.24.3 2026-08-15 21:21 UTC

This package is auto-updated.

Last update: 2026-08-15 21:29:36 UTC


README

Symfony bundle client for the WordPress REST API (/wp-json/wp/v2) — posts, pages, media, taxonomies, users, and custom post types — built on Symfony's own HttpClient.

Multi-site by design: one app routinely talks to more than one WordPress install, so sites are named in configuration and each gets its own client.

Why this exists

Two Survos apps integrated with WordPress in two unrelated ways (survos-sites/ff#5):

  • ff used vnn/wordpress-rest-api-client, an unmaintained Guzzle/PSR-7 library whose composer.json still caps psr/http-message at ^1.0. Nothing else in the tree does — aws-sdk-php, guzzle/psr7, nyholm/psr7, google/auth and symfony/psr-http-message-bridge all accept ^1.1 || ^2.0 — so that single package was the reason survos/elastic-bundle could not be installed there at all (elasticsearch/elasticsearch requires psr/http-message ^2.0).
  • kpa hand-rolled the same integration with HttpClient::create(), raw curl strings and exec().

This bundle replaces both. It has no PSR-7 dependency of any kind, so nothing it brings in can pin psr/http-message.

Installation

composer require survos/wordpress-bundle

Configuration

# config/packages/survos_wordpress.yaml
survos_wordpress:
    default_site: ff
    sites:
        ff:
            base_url: '%env(WORDPRESS_BASE_URL)%'
            username: '%env(WORDPRESS_USERNAME)%'
            application_password: '%env(WORDPRESS_APPLICATION_PASSWORD)%'
        kpa:
            base_url: 'https://www.kidpanalley.org'   # public reads only, no credentials

Every key is optional. With no sites at all, one site named default is registered from WORDPRESS_BASE_URL / WORDPRESS_USERNAME / WORDPRESS_APPLICATION_PASSWORD.

key default
default_site first configured site which site WordpressClientInterface resolves to
rest_prefix /wp-json REST root under the site URL
user_agent Survos WordpressBundle/1.0 … managed hosts block generic agents — identify the app
timeout 30 seconds
retry_enabled / max_retries true / 3 see Fetch strategy
cache_enabled / cache_max_ttl false / 3600 see Fetch strategy

Credentials are Application Passwords

application_password is a WordPress Application Password (Users → Profile → Application Passwords, core since WP 5.6), not the account password. The generated value contains spaces; paste it as-is. It is sent as HTTP Basic auth, so the site must be HTTPS.

Credentials are only needed for writes. Reading public posts, pages and media works with no credentials at all, and in that case no Authorization header is sent.

Usage

use Survos\WordpressBundle\Client\WordpressClientInterface;

final readonly class ArticleImporter
{
    public function __construct(
        private WordpressClientInterface $wp,                      // the default site
        #[Target('kpa')] private WordpressClientInterface $kpa,    // a named one
    ) {}

    public function import(): void
    {
        foreach ($this->wp->posts()->iterateDto(['status' => 'publish']) as $post) {
            // $post is a WpPost; $post->raw still holds every ACF/plugin field
            $this->store($post->id, $post->title, $post->content);
        }
    }
}

Choosing a site at runtime (a --site option, a per-tenant lookup) goes through the registry:

$client = $registry->get($siteName);   // null → the default site
$registry->names();                    // ['ff', 'kpa']

Endpoints

posts(), pages(), media(), categories(), tags(), comments(), users(), types(), statuses(), taxonomies() — plus resource() for anything else:

$client->resource('songs');                  // a custom post type
$client->resource('options', 'acf/v3');      // a plugin's own namespace

Every endpoint offers:

get(int $id, array $query = []) one record, as WordPress returned it
list(array $query = []) one page of records
page(array $query = []) same, plus total / totalPages from the X-WP-* headers
iterate(array $query = []) the whole collection, lazily, one page at a time
create() / update() / save() / delete() writes; save() creates or updates on the presence of an id
getDto() / listDto() / iterateDto() the same reads, hydrated into DTOs

Raw arrays are the contract; DTOs (WpPost, WpTerm, WpMedia, WpUser) are a convenience and each keeps its source array on ->raw. WordPress records carry arbitrary plugin/ACF keys that no DTO can enumerate, so unknown fields are never dropped and never cause a hydration failure. Text fields arrive as {"rendered": "…"} in the default context and as plain strings in others; the DTOs flatten both.

Uploading media

WordPress creates an attachment from the raw file bytes plus a Content-Disposition filename — not multipart, not JSON — and metadata cannot ride along in that request. upload() handles both halves:

$attachment = $client->media()->upload('/path/to/barn.jpg', [
    'alt_text' => 'A barn in Rappahannock County',
    'post'     => $postId,
]);

// or, for bytes already in memory
$client->media()->uploadContents('barn.jpg', $bytes, 'image/jpeg');

Fetch strategy (retry / cache / rate limits)

Retry is on by default: transport errors and HTTP 500/502/503/504 are retried with exponential backoff (Symfony's RetryableHttpClient).

429 is deliberately excluded from that list. It surfaces as RateLimitException carrying the server's own Retry-After, so a caller — a Messenger consumer, a batch import — can reschedule itself with the real delay. Retrying it inside the HTTP client would swallow that signal. This is a live concern rather than a theoretical one: managed WordPress hosts (WP Engine among them) rate-limit /wp-json/ aggressively.

Caching (cache_enabled) wraps the client in Symfony's RFC 9111 CachingHttpClient via survos/fetch-bundle's shared factory. It is off by default, and that default is deliberate: WordPress core sends Cache-Control: no-cache on REST responses, so an RFC-9111 cache is a no-op against a stock install. Turn it on only for a site fronted by a CDN or a caching plugin that emits real freshness headers.

Errors

Everything thrown implements WordpressExceptionInterface. WordPress error bodies are structured — {"code":"rest_post_invalid_id","message":"…","data":{"status":404}} — and both the machine code and the human message are parsed onto the exception rather than collapsed into "Unexpected response":

AuthenticationException 401 / 403
NotFoundException 404 — the record, or the route
RateLimitException 429, with ->retryAfter
InvalidJsonException 2xx that wasn't JSON — a WAF challenge page, or a plugin echoing output
WordpressApiException everything else, and the base of all of the above

Console commands

bin/console wordpress:sites
bin/console wordpress:ping --site=ff
bin/console wordpress:list posts --site=ff --limit=10 --query="search=barn"
bin/console wordpress:get pages 1870

wordpress:ping fetches the REST index and, when credentials are configured, verifies them against wp/v2/users/me — the fastest way to tell a wrong Application Password from a host that strips the Authorization header before it reaches PHP (the two are indistinguishable from the response alone).

Migrating from vnn/wordpress-rest-api-client

The surface was kept close on purpose, including save()'s create-or-update-on-id behaviour:

vnn this bundle
new WpClient(new GuzzleAdapter(new Client()), $url) + setCredentials(new WpBasicAuth(…)) configure the site; inject WordpressClientInterface
$client->posts()->get(66) $client->posts()->get(66)
$client->posts()->get() $client->posts()->list() — or iterate(), which actually pages
$client->posts()->save(['id' => 66, …]) $client->posts()->save(['id' => 66, …])
$client->media()->upload($path, $data) $client->media()->upload($path, $data)
$client->categories()->get(null, ['per_page' => 100]) $client->categories()->list(['per_page' => 100])
RuntimeException('Unexpected response') a typed exception carrying the WordPress error code

The one behavioural difference worth knowing: vnn's get() with no id returned a single page and silently stopped at WordPress' default of 10 records. list() does the same thing explicitly; iterate() is what you want when you meant "all of them".

Not included

  • No OAuth / JWT / nonce-cookie auth. Application Passwords cover server-to-server access, which is the only case these apps have. Cookie auth requires a nonce from a logged-in browser session and does not apply.
  • No WordPress entity/Doctrine mapping. This is a client, not a sync layer. Apps own their own entities (kpa's Song::$wordpressPageId, ff's Article).
  • No block/Gutenberg parsing. content comes back as rendered HTML or as raw block markup depending on context; interpreting it belongs to the app.