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.
Package info
github.com/survos/wordpress-bundle
Type:symfony-bundle
pkg:composer/survos/wordpress-bundle
Fund package maintenance!
Requires
- php: ^8.5
- survos/fetch-bundle: ^2.5
- survos/kit-bundle: ^2.5
- symfony/config: ^8.1
- symfony/console: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/http-client: ^8.1
- symfony/http-kernel: ^8.1
- symfony/mime: ^8.1
- symfony/string: ^8.1
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^13.0
- symfony/framework-bundle: ^8.1
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):
ffusedvnn/wordpress-rest-api-client, an unmaintained Guzzle/PSR-7 library whosecomposer.jsonstill capspsr/http-messageat^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 reasonsurvos/elastic-bundlecould not be installed there at all (elasticsearch/elasticsearchrequirespsr/http-message ^2.0).kpahand-rolled the same integration withHttpClient::create(), rawcurlstrings andexec().
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'sSong::$wordpressPageId,ff'sArticle). - No block/Gutenberg parsing.
contentcomes back as rendered HTML or as raw block markup depending on context; interpreting it belongs to the app.