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 |
|---|---|---|
|
|
Seconds for the whole request (filter |
|
|
Redirects followed (filter |
|
|
Filter |
|
|
|
|
|
|
|
empty |
|
|
|
Never disable; pass a custom CA bundle path instead |
|
|
Write the body to a file instead of memory |
|
|
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 |
|---|---|
|
Change the arguments of every request: a header, a timeout or a user agent for one host |
|
Return a response array or |
|
Fires with every response or error: logging, metrics |
|
Treat an internal host as external for the safe functions |
|
Ports allowed for the safe functions (default 80, 443, 8080) |
|
With |
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;
Source: PHP Manual: Built-in web server
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}
Source: Docker Docs: Merge Compose files
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 );
Source: Code Reference: pre_http_request
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 “. 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
Source: WP-CLI Commands: wp eval
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 |
|---|---|
|
An outdated CA bundle or an intercepting proxy. Update WordPress, add the CA
with |
|
The safe functions refused the host or port, or the URL has a typo |
An HTML error page parsed as data |
Only |
A test hits the real API |
Mock with |
|
|
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 orcurl_init()directly, skipping proxies, the CA bundle, the HTTP filters,WP_HTTP_BLOCK_EXTERNALand SSRF checks (WPCS flags both throughWordPress.WP.AlternativeFunctions). -
Referring to
Snoopy,WP_Http_FsockopenorRequests_*classes. Snoopy has been deprecated since 3.0,WP_Http_Fsockopenno longer exists, WordPress’s own transports gave way to Requests in 4.6, and theRequests_*names are deprecated since 6.2 in favour ofWpOrg\Requests. -
Treating
wp_remote_retrieve_headers()as a plain array, settingsslverifytofalseto "fix" certificate errors, and writing exports into the plugin directory, which the next update deletes.