WordPress Filesystem API
WP abstracts filesystem access because on shared hosts the web user often can't write plugin/theme/core paths directly — FTP/SSH credentials are required. WP_Filesystem picks the right transport (direct when PHP can write as the file owner, otherwise ssh2 / ftpext / ftpsockets) and exposes a uniform method set. Plugins that call file_put_contents() directly outside writable uploads fail on those hosts; plugins that use WP_Filesystem work across more hosting setups.
When to use this skill
Trigger when ANY of the following is true:
- A plugin needs to write files outside
wp-content/uploads/— generated CSS, bundled asset extraction, log files, exported reports, cache, mu-plugins install. - Code references
WP_Filesystem,request_filesystem_credentials,get_filesystem_method,FS_METHOD,FS_CHMOD_FILE,FS_CHMOD_DIR,$wp_filesystem,WP_Filesystem_Direct,WP_Filesystem_SSH2,WP_Filesystem_FTPext,WP_Filesystem_ftpsockets. - Code uses
file_put_contents/fwrite/fopen/unlink/mkdir/rmdirfor plugin-owned files outside uploads. - The user reports: "works on my localhost, fails on the shared host", "the file doesn't write", "users see an FTP prompt out of nowhere".
The bootstrap — five lines, in this exact order
// 1. Load the API. NOT autoloaded.
require_once ABSPATH . 'wp-admin/includes/file.php';
// 2. Request creds (returns true for direct, an array for FTP/SSH, false if form was shown).
$creds = request_filesystem_credentials( $form_url, '', false, $context );
if ( false === $creds ) {
return; // Form was rendered; wait for the next request.
}
// 3. Initialize (returns true|false|null).
if ( ! WP_Filesystem( $creds, $context ) ) {
// Bad creds — re-render the form with an error.
request_filesystem_credentials( $form_url, '', true, $context );
return;
}
// 4. Use the global instance.
global $wp_filesystem;
$wp_filesystem->put_contents( $context . '/generated.css', $css, FS_CHMOD_FILE );
The $context arg is "a directory you're about to write to" — wp-admin/includes/file.php:2364. It controls writability detection (get_filesystem_method writes a temp file there to confirm permissions) and determines whether direct is safe.
What request_filesystem_credentials() returns
Verified at wp-admin/includes/file.php:2364-2393:
| Return | Meaning | What you do |
|---|---|---|
true |
No credentials needed — direct available on this host |
Proceed to WP_Filesystem() |
array |
User entered FTP/SSH credentials | Pass to WP_Filesystem( $creds ) |
false |
Form was rendered AND no submission yet | return — wait for the form post |
The function ALSO ECHOES the form when no creds are present and $_POST is empty. Don't render your own output before calling it on an admin POST handler — the form must reach the screen.
The four transports
get_filesystem_method() (file.php:2260) picks in this order:
| Priority | Method | Condition |
|---|---|---|
| 1 | direct |
PHP can write as the same owner as WP files (or $allow_relaxed_file_ownership set + dir already writable) |
| 2 | ssh2 |
PHP ssh2 extension loaded AND user picked SSH in the form |
| 3 | ftpext |
PHP ftp extension loaded |
| 4 | ftpsockets |
PHP sockets extension OR fsockopen() available |
Force a specific method in wp-config.php:
define( 'FS_METHOD', 'direct' ); // skip the credentials prompt
Defaults set on first successful WP_Filesystem() call (file.php:2208-2228):
FS_CONNECT_TIMEOUT = 30FS_TIMEOUT = 30FS_CHMOD_DIR = (fileperms(ABSPATH) & 0777) | 0755FS_CHMOD_FILE = (fileperms(ABSPATH . 'index.php') & 0777) | 0644
Always pass FS_CHMOD_FILE / FS_CHMOD_DIR to write/mkdir operations so permissions match WP's own.
The methods you'll actually use
From WP_Filesystem_Base (wp-admin/includes/class-wp-filesystem-base.php):
global $wp_filesystem;
// Read.
$wp_filesystem->exists( $path ); // bool
$wp_filesystem->is_file( $path ); // bool
$wp_filesystem->is_dir( $path ); // bool
$wp_filesystem->is_readable( $path ); // bool
$wp_filesystem->is_writable( $path ); // bool
$wp_filesystem->size( $path ); // int bytes
$wp_filesystem->mtime( $path ); // int unix ts
$wp_filesystem->get_contents( $path ); // string|false
$wp_filesystem->get_contents_array( $path ); // array|false (one line per element)
$wp_filesystem->dirlist( $path, $hidden = true, $recursive = false );
// Write.
$wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE ); // bool
$wp_filesystem->touch( $path ); // bool
$wp_filesystem->mkdir( $path, FS_CHMOD_DIR ); // bool
$wp_filesystem->chmod( $path, $mode, $recursive = false ); // bool
$wp_filesystem->copy( $source, $dest, $overwrite = false, FS_CHMOD_FILE );
$wp_filesystem->move( $source, $dest, $overwrite = false );
// Delete.
$wp_filesystem->delete( $path, $recursive = false, $type = false ); // $type: 'f' | 'd' | false
$wp_filesystem->rmdir( $path, $recursive = false );
put_contents returns true on success — always check.
When NOT to use WP_Filesystem
| Need | Right tool |
|---|---|
Writing a user-uploaded file to wp-content/uploads/ |
wp_handle_upload() or media_handle_upload() (wp-admin/includes/file.php:1097) |
| Reading any existing file (where you don't need write capability) | Plain PHP file_get_contents() is fine — read access doesn't need FS abstraction |
Touching a file in wp-content/uploads/ that you JUST got back from wp_handle_upload |
Plain PHP — uploads dir is by definition writable as web user (otherwise wp_handle_upload would have failed) |
Anything in PHP tempnam() / sys_get_temp_dir() |
Plain PHP |
WP_Filesystem is overkill for wp-content/uploads. It's the right tool for wp-content/, wp-content/plugins/<self>/cache/, wp-content/mu-plugins/, or anywhere ELSE that needs the credentials dance on restricted hosts.
Pattern: write generated CSS into uploads on settings save
add_action( 'update_option_myplugin_options', static function ( $old, $new ): void {
if ( $old === $new ) {
return;
}
$upload = wp_upload_dir();
$dir = $upload['basedir'] . '/myplugin';
$file = $dir . '/style.css';
$css = ':root { --brand: ' . sanitize_hex_color( $new['brand_color'] ?? '#000' ) . '; }';
if ( ! empty( $upload['error'] ) ) {
return;
}
if ( ! wp_mkdir_p( $dir ) ) {
return;
}
if ( false === file_put_contents( $file, $css, LOCK_EX ) ) {
return;
}
}, 10, 2 );
This is intentionally plain PHP: wp-content/uploads is web-user-writable or wp_upload_dir() / wp_mkdir_p() fails. Use WP_Filesystem when writing outside uploads, where an admin page can show the credentials form.
Pattern: read a JSON config the plugin ships
For READING bundled assets, just use PHP — no credentials needed:
$config = json_decode( file_get_contents( MYPLUGIN_DIR . '/config/defaults.json' ), true );
WP_Filesystem for reads is unnecessary indirection. The abstraction exists for WRITES on hosts where the web user lacks privileges.
Critical rules
require_once ABSPATH . 'wp-admin/includes/file.php'before any of the API. Not autoloaded. Frontend, REST, and cron contexts don't include it.- Always pass
FS_CHMOD_FILE/FS_CHMOD_DIRtoput_contents/mkdir/copy. Skipping them means permissions are left to whatever the transport's defaults are — often too-open on direct, too-locked on FTP. - Pass the same
$contexttorequest_filesystem_credentials()andWP_Filesystem(). CallingWP_Filesystem()with no context defaults detection toWP_CONTENT_DIR, which can be wrong when you're targeting a deeper plugin/cache directory. request_filesystem_credentials()ECHOES a form when no creds are stored. Don't call it from a page that's already streamed HTML, and don't call it from REST / AJAX — it's not designed for those contexts.WP_Filesystemis admin-only by design. The credentials prompt makes no sense in cron or REST. If a cron task needs FS writes, gate writes ondirectbeing available ('direct' === get_filesystem_method()).- Don't reach for
WP_Filesystemto write insidewp-content/uploads—wp_handle_uploador plain PHP suffices. Reserve it for paths that might NOT be web-user-writable. $wp_filesystem->delete( $path )does NOT recurse by default. Pass$recursive = truewhen deleting non-empty directories, and pass$type('f'or'd') when the type is known — saves a stat call.FS_METHOD = 'direct'inwp-config.phpis the standard "this host is fine, skip the form" override. Recommend it in your docs for users who report seeing FTP prompts. Don't define it from your plugin — that's the host operator's call.- Don't store FTP credentials. WP intentionally does NOT persist the password — the user re-enters it each session. Don't add your own "save FTP password" UI.
Common AI mistakes
// WRONG — fails on hosts where web user != file owner
file_put_contents( WP_CONTENT_DIR . '/myplugin-cache.json', $data );
// RIGHT — on an admin page, request creds, then initialize WP_Filesystem
require_once ABSPATH . 'wp-admin/includes/file.php';
$form_url = wp_nonce_url( admin_url( 'admin.php?page=myplugin' ), 'myplugin_write_cache' );
$context = WP_CONTENT_DIR;
$creds = request_filesystem_credentials( $form_url, '', false, $context );
if ( false === $creds ) {
return;
}
if ( ! WP_Filesystem( $creds, $context ) ) {
request_filesystem_credentials( $form_url, '', true, $context );
return;
}
global $wp_filesystem;
$wp_filesystem->put_contents( WP_CONTENT_DIR . '/myplugin-cache.json', $data, FS_CHMOD_FILE );
// WRONG — credentials form leaks into REST / AJAX response
add_action( 'rest_api_init', static function (): void {
register_rest_route( 'myplugin/v1', '/clear-cache', array(
'callback' => static function () {
request_filesystem_credentials( '' ); // echoes <form>; corrupts JSON response
// ...
}
) );
} );
// RIGHT — only request creds in admin page contexts; gate by transport availability elsewhere
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( 'direct' !== get_filesystem_method() ) {
return new WP_Error( 'fs_unavailable', 'Server requires admin filesystem access.' );
}
if ( ! WP_Filesystem() ) {
return new WP_Error( 'fs_unavailable', 'Filesystem initialization failed.' );
}
// WRONG — overkill for an upload coming out of wp_handle_upload (it's already in writable uploads/)
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
$wp_filesystem->put_contents( $uploaded['file'], $modified );
// RIGHT — uploads/ is web-user-writable by definition
file_put_contents( $uploaded['file'], $modified );
// WRONG — no chmod; permission set by transport defaults
$wp_filesystem->put_contents( $path, $data );
// RIGHT — match WP's own permission convention
$wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE );
Cross-references
- See
wp-plugin-options-storagefor storing config — most "I need to write a file" needs are better served by an option / transient. - See
wp-security-deepfor path traversal checks (realpath(),wp_normalize_path()) —WP_Filesystemdoesn't validate paths for you. - See
wp-plugin-cronwhen an FS-touching task moves to background — note the'direct'requirement.
What this skill does NOT cover
- WP's update / upgrader APIs (
WP_Upgrader,Plugin_Upgrader,Theme_Upgrader) which useWP_Filesysteminternally. Different abstraction layer for the install/update flow. - WP-CLI's
\WP_CLI\Utils\http_requestfor downloading files. WP-CLI commands typically use plain PHP for FS — see thewp-cli-extendingskill. - The
wp-content/uploads/upload pipeline (wp_handle_upload,media_handle_upload,wp_handle_sideload). Adjacent topic — different bootstrap.
References
wp-admin/includes/file.php:2169—WP_Filesystem()initializer (sets$wp_filesystemglobal, defines FS_CHMOD_* constants).wp-admin/includes/file.php:2260—get_filesystem_method()with the transport priority and writability detection.wp-admin/includes/file.php:2364—request_filesystem_credentials()(returnstrue|false|array, echoes form when needed).wp-admin/includes/class-wp-filesystem-base.php— the base class method surface (lines 487-861 cover the public methods listed above).wp-admin/includes/file.php:1097—wp_handle_upload()for the uploads/ pipeline.