HTTP and Filesystem APIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), which bundles version 2.0.17 of the Requests library, with PHP 8.4 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.1 requires PHP 7.4 at minimum, and PHP 8.3 or newer is recommended; WordPress 7.2 (beta, due December 2026) is not expected to change either API. All output below comes from a local Compose stack (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, wordpress:cli-2.12.0-php8.4) with a fake distributor API in a php:8.4-cli container.

The HTTP API calls remote services while hiding the transport, proxies and certificate bundle; the Filesystem API writes files while hiding whether PHP writes them directly or through FTP or SSH. The examples extend lumen-catalog of the fictitious publisher Lumen Press (Plugin Fundamentals): the stock sync scheduled in WP-Cron and Scheduled Tasks gets a robust HTTP client, and the catalogue gets a CSV stock report in the uploads directory.

The HTTP API

WP_Http (available since 2.7) is a thin layer over the bundled Requests library, which does the work with cURL or, when cURL is missing, PHP sockets. WordPress 4.6 replaced its own transport classes with Requests, and 6.2 moved to Requests 2, whose classes live in the WpOrg\Requests namespace; the old Requests_* class names still load but are deprecated. Plugin code never needs either: it calls the functions below, which add the WordPress defaults, filters, proxy settings (WP_PROXY_HOST and related constants) and the CA bundle in wp-includes/certificates/ca-bundle.crt.

wp_remote_get(), wp_remote_post() and wp_remote_head() (2.7) send one method; wp_remote_request() sends any (PUT, PATCH, DELETE) set with the method argument. wp_safe_remote_get(), wp_safe_remote_post(), wp_safe_remote_head() and wp_safe_remote_request() (3.6) do the same with reject_unsafe_urls set, so the URL and every redirect target must pass wp_http_validate_url(). download_url() streams a file to a temporary file through the safe variant and returns its path, which the caller deletes. Every function returns either a WP_Error or an array with the keys headers, body, response (code, message), cookies, filename and http_response (a WP_HTTP_Requests_Response), read through the helpers: wp_remote_retrieve_response_code() and wp_remote_retrieve_response_message() (status code and reason phrase), wp_remote_retrieve_body(), wp_remote_retrieve_header() (one header, an array if sent several times, '' if absent), wp_remote_retrieve_headers() (all headers as a case-insensitive WpOrg\Requests\Utility\CaseInsensitiveDictionary since 4.6) and the cookie helpers added in 4.4. Each returns '' when given a WP_Error.

Request arguments

The second parameter is an array merged over these defaults in WP_Http::request():

Argument Default Notes

timeout

5

Seconds for the whole request (filter http_request_timeout). Too short for slow APIs, far too long for a request made while a visitor waits

redirection

5 (0 for HEAD)

Redirects followed (filter http_request_redirection_count)

user-agent

WordPress/7.1.3; <home URL>

Filter http_headers_useragent. It reveals the site and version

reject_unsafe_urls

false

true in the wp_safe_remote_* functions

blocking

true

false sends the request and returns at once, with no status or body (WP-Cron spawning uses it)

headers, cookies, body

empty

body can be a string or an array, which is form-encoded; send JSON with wp_json_encode() and a Content-Type: application/json header

sslverify, sslcertificates

true, the CA bundle

Never disable; pass a custom CA bundle path instead

stream, filename

false, null

Write the body to a file instead of memory

limit_response_size

null

Maximum bytes read; protects against huge or endless responses

Errors: transport failures versus HTTP errors

is_wp_error() is true only when no HTTP response was obtained: the URL is invalid or unsafe, DNS fails, the connection is refused, TLS verification fails, the timeout expires or the request was blocked. A 401, 404 or 503 is a successful request that returned an error status, so the code must check the status code too. Transport errors use the code http_request_failed (the message carries the cURL error), blocked requests http_request_not_executed. A wp eval-file loop calling wp_remote_get() on the endpoints of the fake distributor (shown below), printing either the WP_Error or the status code, message and Retry-After header, gave:

stock            200 OK, Retry-After: -
maintenance      503 Service Unavailable, Retry-After: 120
no token         401 Unauthorized, Retry-After: -
slow, timeout 2  WP_Error http_request_failed: cURL error 28: Operation timed out after 2003 milliseconds with 0 bytes received
unknown host     WP_Error http_request_failed: cURL error 6: Could not resolve host: no-such-host.invalid (Domain name not found)

Timeouts and where requests run

A request inside a page load blocks a PHP worker for up to timeout seconds (Performance and Caching). Never call a remote API on every front-end request: fetch in WP-Cron, Action Scheduler or WP-CLI, store the result and render from storage; if a page must fetch, cache the response and use a 2–3 second timeout. Set timeout explicitly on every call. Use 'blocking' => false only for fire-and-forget notifications. Retry on the next scheduled run rather than in a loop, and honour Retry-After on 429 and 503 responses.

Safe requests and SSRF

When any part of the URL comes from a user, a setting or a remote document, use the wp_safe_remote_* variants: they refuse non-HTTP schemes, credentials in the URL, hosts that resolve to loopback, private, link-local (cloud metadata), reserved or multicast addresses, and ports other than 80, 443 and 8080, unless the host is the site’s own (Security for Developers demonstrates it). The client below uses them even for the admin-set distributor URL, since a stolen admin session could repoint it.

Filters for controlling requests

Hook Use

http_request_args (filter)

Change the arguments of every request: a header, a timeout or a user agent for one host

pre_http_request (filter)

Return a response array or WP_Error instead of performing the request: mocks in tests, offline mode. It runs before URL validation and before WP_HTTP_BLOCK_EXTERNAL

http_api_debug (action)

Fires with every response or error: logging, metrics

http_request_host_is_external (filter)

Treat an internal host as external for the safe functions

http_allowed_safe_ports (filter)

Ports allowed for the safe functions (default 80, 443, 8080)

block_local_requests (filter)

With WP_HTTP_BLOCK_EXTERNAL, also block requests to the site itself

Blocking outbound requests: WP_HTTP_BLOCK_EXTERNAL

define( 'WP_HTTP_BLOCK_EXTERNAL', true ); in wp-config.php makes the HTTP API refuse every host except the site itself and localhost; WP_ACCESSIBLE_HOSTS lists exceptions, comma-separated, with *. wildcards (include api.wordpress.org and downloads.wordpress.org for updates). It governs only the HTTP API, not file_get_contents() or direct cURL. With the constant set on the test site, the client below returned http_request_not_executed: User has blocked requests through HTTP to the URL: http://distributor:8080/v1/stock.; adding distributor to WP_ACCESSIBLE_HOSTS let it through (wp-config.php Reference).

Example: the distributor stock sync

The six-hourly event lp_catalog_sync_stock copies stock levels from the distributor into the lp_stock meta of each lp_book. This includes/distributor.php replaces the first version on the WP-Cron page. The base URL comes from an LUMEN_DISTRIBUTOR_URL constant or environment variable on local and staging copies, else from the lp_catalog_settings option (whose sanitizer accepts only https:// URLs); the token comes from LUMEN_DISTRIBUTOR_TOKEN, never from the database. The client sets a timeout, a redirect limit and a response size limit, separates transport from HTTP errors, validates the JSON, normalises ISBNs (978-1-23456-789-7 matches 9781234567897) and caches the parsed result in the transient lumen_distributor_stock for 15 minutes, so screens and the stock badge can call lumen_catalog_fetch_stock() without a request each time.

<?php
/**
 * Distributor API client and the stock synchronisation run by WP-Cron.
 *
 * File: lumen-catalog/includes/distributor.php
 */

defined( 'ABSPATH' ) || exit;

const LP_CATALOG_STOCK_TRANSIENT = 'lumen_distributor_stock';

/** Base URL: LUMEN_DISTRIBUTOR_URL (constant or environment) on local/staging copies, else the settings. */
function lumen_catalog_distributor_url(): string {
	$override = defined( 'LUMEN_DISTRIBUTOR_URL' ) ? LUMEN_DISTRIBUTOR_URL : getenv( 'LUMEN_DISTRIBUTOR_URL' );
	if ( $override ) {
		return untrailingslashit( $override );
	}
	$settings = get_option( 'lp_catalog_settings', array() );
	return untrailingslashit( $settings['distributor_url'] ?? 'https://distributor.example' );
}

/** "978-1-23456-789-7" and "9781234567897" both become "9781234567897". */
function lumen_catalog_normalize_isbn( string $isbn ): string {
	return (string) preg_replace( '/[^0-9X]/', '', strtoupper( $isbn ) );
}

/** @return array<string,int>|WP_Error Stock levels as ISBN => quantity; $force skips the cache. */
function lumen_catalog_fetch_stock( bool $force = false ) {
	$cached = $force ? false : get_transient( LP_CATALOG_STOCK_TRANSIENT );
	if ( is_array( $cached ) ) {
		return $cached;
	}

	$token    = defined( 'LUMEN_DISTRIBUTOR_TOKEN' ) ? LUMEN_DISTRIBUTOR_TOKEN : (string) getenv( 'LUMEN_DISTRIBUTOR_TOKEN' );
	$response = wp_safe_remote_get(
		lumen_catalog_distributor_url() . '/v1/stock',
		array(
			'timeout'             => 10,
			'redirection'         => 2,
			'limit_response_size' => MB_IN_BYTES,
			'headers'             => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $token ),
		)
	);

	// Transport failures: DNS, refused connection, timeout, TLS, unsafe URL.
	if ( is_wp_error( $response ) ) {
		return $response;
	}

	// HTTP errors are normal responses: check the status code yourself.
	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( 200 !== $code ) {
		$message = sprintf( 'Distributor answered HTTP %d %s.', $code, wp_remote_retrieve_response_message( $response ) );
		$data    = array( 'status' => $code, 'retry_after' => wp_remote_retrieve_header( $response, 'retry-after' ) );
		return new WP_Error( 'lumen_distributor_http', $message, $data );
	}

	$items = json_decode( wp_remote_retrieve_body( $response ), true );
	if ( ! is_array( $items ) ) {
		return new WP_Error( 'lumen_distributor_json', 'Distributor sent a body that is not a JSON array.' );
	}

	$stock = array();
	foreach ( $items as $item ) {
		$isbn = lumen_catalog_normalize_isbn( (string) ( $item['isbn'] ?? '' ) );
		if ( '' !== $isbn ) {
			$stock[ $isbn ] = absint( $item['stock'] ?? 0 );
		}
	}

	set_transient( LP_CATALOG_STOCK_TRANSIENT, $stock, 15 * MINUTE_IN_SECONDS );
	return $stock;
}

add_action( 'lp_catalog_sync_stock', 'lp_catalog_sync_stock' );

/** WP-Cron callback: copies the distributor's stock into the lp_stock meta of each book. */
function lp_catalog_sync_stock(): void {
	$stock = lumen_catalog_fetch_stock( true );
	if ( is_wp_error( $stock ) ) {
		error_log( 'Lumen Catalog: stock sync failed: ' . $stock->get_error_message() );
		return; // Keep the old values; the next run retries.
	}

	$updated = 0;
	$books   = get_posts( array( 'post_type' => 'lp_book', 'post_status' => 'any', 'numberposts' => -1, 'fields' => 'ids' ) );
	foreach ( $books as $book_id ) {
		$isbn = lumen_catalog_normalize_isbn( (string) get_post_meta( $book_id, 'lp_isbn', true ) );
		if ( isset( $stock[ $isbn ] ) ) {
			update_post_meta( $book_id, 'lp_stock', $stock[ $isbn ] );
			++$updated;
		}
	}
	update_option( 'lp_catalog_last_sync', array( 'time' => time(), 'updated' => $updated ), false );
}

Only parsed, validated, successful answers are cached; caching an error would hide the recovery for 15 minutes. The cron callback forces a fresh fetch, which refreshes the cache for everyone. Transients can vanish at any time, so the code copes with a miss. This file is the only place that defines LP_CATALOG_STOCK_TRANSIENT and fetches the list: Options, Transients and Object Cache wraps lumen_catalog_fetch_stock() in lp_catalog_get_distributor_stock(), which adds a last-good snapshot and a deliberate five-minute negative cache during outages, and the production job calls that wrapper. The callback above calls the client directly to show the error handling.

A fake distributor in Docker

Development and staging must never call the real distributor. A 30-line PHP script run by PHP’s built-in web server in a container is enough to imitate it, including the failure modes:

<?php
// Fake distributor API. File: distributor/router.php. Run: php -S 0.0.0.0:8080 router.php
header( 'Content-Type: application/json' );
header( 'X-Seen-User-Agent: ' . ( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );

if ( 'Bearer ' . getenv( 'DISTRIBUTOR_TOKEN' ) !== ( $_SERVER['HTTP_AUTHORIZATION'] ?? '' ) ) {
	http_response_code( 401 );
	echo '{"error":"invalid_token"}';
	return true;
}

switch ( parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) ) {
	case '/v1/stock':
		echo '[{"isbn":"9781234567897","stock":42},{"isbn":"978-1-23456-790-3","stock":0},'
			. '{"isbn":"9780000000000","stock":7}]';
		break;
	case '/v1/maintenance':
		http_response_code( 503 );
		header( 'Retry-After: 120' );
		echo '{"error":"maintenance"}';
		break;
	case '/v1/slow':
		sleep( 15 );
		echo '[]';
}
return true;

The override file adds it to the standard Lumen Press stack of Local Development Environments and passes the URL and token to the WordPress and WP-CLI containers. .env gains a LUMEN_DISTRIBUTOR_TOKEN=<choose-a-local-token> line.

# compose.distributor.yaml: a fake distributor API for the Lumen Press stack.
services:
  distributor:
    image: php:8.4-cli
    command: ["php", "-S", "0.0.0.0:8080", "/srv/router.php"]
    environment:
      DISTRIBUTOR_TOKEN: ${LUMEN_DISTRIBUTOR_TOKEN:?Set LUMEN_DISTRIBUTOR_TOKEN in .env}
    volumes:
      - ./distributor/router.php:/srv/router.php:ro

  wordpress:
    environment:
      LUMEN_DISTRIBUTOR_URL: http://distributor:8080
      LUMEN_DISTRIBUTOR_TOKEN: ${LUMEN_DISTRIBUTOR_TOKEN}

  cli:
    environment:
      LUMEN_DISTRIBUTOR_URL: http://distributor:8080
      LUMEN_DISTRIBUTOR_TOKEN: ${LUMEN_DISTRIBUTOR_TOKEN}

The container’s name distributor resolves to a private Docker address, which the safe functions refuse. A must-use plugin that exists only in the local stack allows that one host and sets a plugin user agent for it (an http_api_debug action is the place to log every call):

<?php
/**
 * Plugin Name: Lumen local distributor
 * Description: Lets the safe HTTP functions reach the fake distributor container (local sites only).
 */
// File: wp-content/mu-plugins/lumen-local-distributor.php

if ( 'local' !== wp_get_environment_type() ) {
	return;
}

add_filter(
	'http_request_host_is_external',
	static function ( $is_external, $host ) {
		return $is_external || 'distributor' === $host;
	},
	10,
	2
);

add_filter(
	'http_request_args',
	static function ( $args, $url ) {
		if ( 'distributor' === wp_parse_url( $url, PHP_URL_HOST ) ) {
			$args['user-agent'] = 'LumenCatalog/0.1.0; ' . home_url( '/' );
		}
		return $args;
	},
	10,
	2
);

Without it, lumen_catalog_fetch_stock( true ) returned http_request_failed: A valid URL was not provided. With it:

docker compose -f compose.yaml -f compose.distributor.yaml up -d
docker compose -f compose.yaml -f compose.distributor.yaml run --rm cli sh -c '
  wp cron event run lp_catalog_sync_stock
  wp option get lp_catalog_last_sync --format=json
  wp transient get lumen_distributor_stock --format=json'
Executed the cron event 'lp_catalog_sync_stock' in 0.023s.
Success: Executed a total of 1 cron event.
{"time":1793201277,"updated":2}
{"9781234567897":42,"9781234567903":0,"9780000000000":7}

"The Lighthouse Ledger" got lp_stock 42 and "The Glass Orchard" (978-1-23456-790-3) got 0; the third ISBN is not in the catalogue and was ignored. The distributor saw the user agent LumenCatalog/0.1.0; https://lumenpress.test/. Running the same event with a wrong token left the stored stock untouched and wrote Lumen Catalog: stock sync failed: Distributor answered HTTP 401 Unauthorized. to debug.log.

Testing without the network: pre_http_request

Automated tests must not depend on a container, let alone a real API. pre_http_request short-circuits the request with a prepared answer, and because it runs before URL validation and blocking, the code under test runs unchanged. The script below is a self-contained check run with wp eval-file; in a PHPUnit suite built on the WordPress test library the same filter goes in set_up() and is removed automatically after each test (Testing).

<?php
// Checks lumen_catalog_fetch_stock() without network access. Run: wp eval-file <this file>
$calls = 0;
$next  = null; // The fake answer for the next request.

add_filter(
	'pre_http_request',
	static function ( $pre, $args, $url ) use ( &$calls, &$next ) {
		if ( false === strpos( $url, '/v1/stock' ) ) {
			return $pre; // Any other request goes through.
		}
		++$calls;
		return $next;
	},
	10,
	3
);

function lumen_fake_response( int $code, string $body, array $headers = array() ): array {
	$status = array( 'code' => $code, 'message' => get_status_header_desc( $code ) );
	return array( 'headers' => $headers, 'body' => $body, 'response' => $status, 'cookies' => array(), 'filename' => null );
}

delete_transient( LP_CATALOG_STOCK_TRANSIENT );
$next    = lumen_fake_response( 200, '[{"isbn":"978-1-23456-789-7","stock":"5"}]' );
$results = array( 'maps ISBN to stock' => array( '9781234567897' => 5 ) === lumen_catalog_fetch_stock() );

lumen_catalog_fetch_stock();
$results['second call is served from the transient'] = 1 === $calls;

$next  = lumen_fake_response( 503, '', array( 'retry-after' => '120' ) );
$error = lumen_catalog_fetch_stock( true );
$results['503 becomes a WP_Error with Retry-After'] = is_wp_error( $error ) && '120' === $error->get_error_data()['retry_after'];

$next = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
$results['transport errors are passed through'] = 'http_request_failed' === lumen_catalog_fetch_stock( true )->get_error_code();

$next = lumen_fake_response( 200, '<html>Bad gateway</html>' );
$results['non-JSON body is rejected'] = is_wp_error( lumen_catalog_fetch_stock( true ) );

foreach ( $results as $label => $ok ) {
	printf( "%s %s\n", $ok ? 'PASS' : 'FAIL', $label );
}
delete_transient( LP_CATALOG_STOCK_TRANSIENT );

All five checks printed PASS on the test site. A fake response is a plain array, so wp_remote_retrieve_header() matches its header keys case-sensitively: write them in lower case.

The Filesystem API

file_put_contents() is right only when PHP can write the target and should own the result. Where WordPress files belong to an FTP or deploy account, updates and plugin writes must go through FTP or SSH. The Filesystem API (2.6) chooses the method and exposes one object, the global $wp_filesystem, with the same methods for every transport: direct (WP_Filesystem_Direct, plain PHP functions), ssh2 (SFTP through the ssh2 PECL extension), ftpext (PHP’s FTP extension) and ftpsockets.

How get_filesystem_method() decides, the ownership models, the credentials form (request_filesystem_credentials()), FS_METHOD, FS_CHMOD_FILE/FS_CHMOD_DIR and an admin-screen export that asks for credentials are covered in File Permissions and Filesystem. The sequence is WP_Filesystem( $credentials, $context ) (defined in wp-admin/includes/file.php, which code outside the admin must load first), which connects and sets $wp_filesystem, then calls on that object.

The methods of $wp_filesystem (WP_Filesystem_Base) read and write (get_contents(), get_contents_array(), put_contents( $file, $contents, $mode ) with FS_CHMOD_FILE), test (exists(), is_file(), is_dir(), is_readable(), is_writable()), create and remove (mkdir(), rmdir( $path, $recursive ), delete()), copy and rename one file (copy(), move()), manage permissions (chmod(), chown(), chgrp(), owner(), getchmod()) and read metadata (size(), mtime(), touch(), dirlist()). abspath(), wp_content_dir(), wp_plugins_dir(), wp_themes_dir() and find_folder() give the transport’s view of the WordPress directories, since under FTP the remote root differs from ABSPATH.

Helpers in wp-admin/includes/file.php build on it and require an initialised $wp_filesystem: unzip_file( $file, $to ) (used for plugin and theme packages; uses ZipArchive when available, else PclZip), copy_dir(), move_dir() (6.2, renames where possible and falls back to copying) and wp_tempnam(). wp_mkdir_p() and wp_upload_bits() in wp-includes are not part of the API: they use plain PHP functions, like media uploads.

Where plugin files belong: wp_upload_dir()

Generated files go in the uploads directory, never in the plugin’s folder (an update replaces it). wp_upload_dir() returns path, url and subdir for the current month folder (which it creates unless the second argument is false), basedir and baseurl for the root, and error (false on success); on Multisite it points at uploads/sites/<id>/. Everything there is downloadable by URL, so exports with personal data belong outside the web root or behind a download handler that checks a capability.

Example: a CSV stock report

lumen_catalog_export_stock_csv() writes uploads/lumen-catalog/stock-<date>.csv. It runs from WP-Cron or WP-CLI, where nobody can type FTP credentials, so it requires the direct method instead of showing the credentials form. Spreadsheets execute a cell starting with =, +, - or @ as a formula (CSV injection), so such cells of user-supplied text get a leading apostrophe.

<?php
// CSV stock report through the Filesystem API. File: lumen-catalog/includes/export-csv.php

defined( 'ABSPATH' ) || exit;

/** Rows to CSV text; a cell a spreadsheet would run as a formula gets a leading apostrophe. */
function lumen_catalog_to_csv( array $rows ): string {
	$csv = '';
	foreach ( $rows as $row ) {
		$cells = array();
		foreach ( $row as $cell ) {
			$cell = (string) $cell;
			if ( '' !== $cell && false !== strpos( "=+-@\t\r", $cell[0] ) ) {
				$cell = "'" . $cell;
			}
			$cells[] = '"' . str_replace( '"', '""', $cell ) . '"';
		}
		$csv .= implode( ',', $cells ) . "\r\n";
	}
	return $csv;
}

/** @return string|WP_Error Path of a new CSV with ISBN, title and stock of every published book. */
function lumen_catalog_export_stock_csv() {
	require_once ABSPATH . 'wp-admin/includes/file.php';

	$uploads = wp_upload_dir();
	if ( false !== $uploads['error'] ) {
		return new WP_Error( 'lumen_uploads', $uploads['error'] );
	}

	// Only 'direct' works without a person at the keyboard; never prompt here.
	if ( 'direct' !== get_filesystem_method( array(), $uploads['basedir'] ) || ! WP_Filesystem( false, $uploads['basedir'] ) ) {
		return new WP_Error( 'lumen_fs', __( 'The uploads directory cannot be written without credentials.', 'lumen-catalog' ) );
	}

	global $wp_filesystem;
	$dir = trailingslashit( $uploads['basedir'] ) . 'lumen-catalog';

	if ( ! $wp_filesystem->is_dir( $dir ) && ! $wp_filesystem->mkdir( $dir, FS_CHMOD_DIR ) ) {
		return new WP_Error( 'lumen_mkdir', __( 'Could not create the export directory.', 'lumen-catalog' ) );
	}

	$rows = array( array( 'ISBN', 'Title', 'Stock' ) );
	$ids  = get_posts( array( 'post_type' => 'lp_book', 'numberposts' => -1, 'fields' => 'ids', 'orderby' => 'title', 'order' => 'ASC' ) );
	foreach ( $ids as $id ) {
		$rows[] = array( get_post_meta( $id, 'lp_isbn', true ), get_post_field( 'post_title', $id, 'raw' ), (int) get_post_meta( $id, 'lp_stock', true ) );
	}

	$name = wp_unique_filename( $dir, 'stock-' . gmdate( 'Y-m-d' ) . '.csv' );
	if ( ! $wp_filesystem->put_contents( $dir . '/' . $name, lumen_catalog_to_csv( $rows ), FS_CHMOD_FILE ) ) {
		return new WP_Error( 'lumen_write', __( 'Could not write the CSV file.', 'lumen-catalog' ) );
	}
	return $dir . '/' . $name;
}

The title is read raw with get_post_field(): get_the_title() applies display filters and turns quotes into HTML entities such as &#8220;. With a third book titled =HYPERLINK("https://attacker.example","Click"):

wp eval 'var_dump( lumen_catalog_export_stock_csv() );'
cat wp-content/uploads/lumen-catalog/stock-2026-10-28.csv
string(67) "/var/www/html/wp-content/uploads/lumen-catalog/stock-2026-10-28.csv"
"ISBN","Title","Stock"
"978-1-23456-791-0","'=HYPERLINK(""https://attacker.example"",""Click"")","0"
"978-1-23456-790-3","The Glass Orchard","0"
"978-1-23456-789-7","The Lighthouse Ledger","42"

A second run produced stock-2026-10-28-1.csv (wp_unique_filename()), mode 644 and owned by www-data. WP-CLI forces direct through a filesystem_method filter, so the guard never fires there; called from plain PHP with FS_METHOD set to ftpext, as on an FTP-only host’s WP-Cron, the function returned its "cannot be written without credentials" error and wrote nothing.

Pitfalls

Symptom Cause and fix

cURL error 60: SSL certificate problem

An outdated CA bundle or an intercepting proxy. Update WordPress, add the CA with sslcertificates; do not disable sslverify

A valid URL was not provided.

The safe functions refused the host or port, or the URL has a typo

An HTML error page parsed as data

Only is_wp_error() was checked: check the status code and the JSON

A test hits the real API

Mock with pre_http_request; set WP_HTTP_BLOCK_EXTERNAL in the test configuration

Call to a member function put_contents() on null

WP_Filesystem() was not called or failed; check its return value and load wp-admin/includes/file.php first

Where older books are outdated

None of the three older books documents the HTTP or the Filesystem API: WordPress All-in-One For Dummies has no development content, and the classic-theme and REST chapters of Pro WordPress and the Ultimate WordPress Handbook do not reach them. Older tutorials and snippets add these mistakes:

  • Calling file_get_contents() on a URL or curl_init() directly, skipping proxies, the CA bundle, the HTTP filters, WP_HTTP_BLOCK_EXTERNAL and SSRF checks (WPCS flags both through WordPress.WP.AlternativeFunctions).

  • Referring to Snoopy, WP_Http_Fsockopen or Requests_* classes. Snoopy has been deprecated since 3.0, WP_Http_Fsockopen no longer exists, WordPress’s own transports gave way to Requests in 4.6, and the Requests_* names are deprecated since 6.2 in favour of WpOrg\Requests.

  • Treating wp_remote_retrieve_headers() as a plain array, setting sslverify to false to "fix" certificate errors, and writing exports into the plugin directory, which the next update deletes.