The WordPress Filesystem API: When, Why, and How to Use WP_Filesystem Correctly
Every year, hundreds of plugin authors submit their work to the WordPress.org plugin repository and get the same feedback from the review team: “Please use the WordPress Filesystem API instead of direct PHP file functions.” It is one of the most common reasons for rejection, and yet many developers still do not fully understand why it matters or how to implement it properly. This article walks through the entire Filesystem API from first principles, including how WordPress selects a transport, how to request credentials from users, how to write files safely, and how to handle edge cases like AJAX requests and background processing.
If you have ever called file_put_contents() in a WordPress plugin and wondered why the review team flagged it, this article is for you. If you have used WP_Filesystem before but want to understand the internals better, keep reading. We will cover everything from transport selection to atomic writes to unit testing strategies.
Why Direct File Operations Fail Plugin Review
PHP offers a simple set of file functions: fopen(), fwrite(), file_put_contents(), file_get_contents(), unlink(), and so on. They work fine on your local development machine. They work fine on many shared hosting accounts. So why does the WordPress plugin review team reject code that uses them?
The answer comes down to file ownership and server configurations. On many shared hosting environments, the web server process (Apache or Nginx) runs as a different user than the account that owns the WordPress files. When PHP writes a file using the direct file functions, that file is owned by the web server user, not the account owner. This creates a real problem: the account owner cannot edit or delete those files through FTP, and other PHP processes running under the account owner may not be able to read them.
WordPress solved this problem years ago by introducing the Filesystem API. Instead of writing files directly through PHP, the API can use FTP or SSH to write files as the correct user. This means file ownership stays consistent, permissions remain correct, and hosting environments that restrict direct writes still function properly.
There is also a security angle. Direct file writes bypass any hosting-level restrictions that might be in place. Some hosts intentionally prevent the web server from writing to certain directories. The Filesystem API respects these boundaries by using the same access method the site owner would use.
The WordPress.org plugin guidelines are explicit about this. Section 7 of the Detailed Plugin Guidelines states that plugins must use the WordPress Filesystem API for writing files. If you call file_put_contents(), fwrite(), or similar functions, your plugin will be flagged during review. The reviewer will point you to WP_Filesystem and ask you to refactor.
There are a few narrow exceptions. Writing to the system temp directory using wp_fopen() with a manually constructed filename is generally acceptable because those files are transient. Reading files with file_get_contents() on local paths is sometimes tolerated, though using $wp_filesystem->get_contents() is preferred. But for any operation that creates, modifies, or deletes files within the WordPress installation directory, the Filesystem API is required.
Understanding get_filesystem_method()
Before WordPress can write a file, it needs to figure out which transport method to use. The function responsible for this decision is get_filesystem_method(). It accepts two optional parameters: an array of connection arguments and a directory path to test against.
$method = get_filesystem_method( array(), WP_CONTENT_DIR );
When called without arguments, this function runs a series of checks to determine whether direct PHP file operations will maintain correct file ownership. The logic works like this:
First, WordPress checks if the FS_METHOD constant is defined. If it is, the function returns that value immediately without running any other tests. This is the override mechanism that administrators use to force a specific transport.
Second, if no constant is set, WordPress creates a temporary file in the target directory using fopen() with a manually constructed filename. It then compares the owner of that temporary file with the owner of the WordPress core file itself (file.php). If they match, it means PHP is running as the same user who owns the files, and direct writes will maintain correct ownership. In that case, the function returns 'direct'.
Third, if the owners do not match, WordPress checks whether the SSH2 PHP extension is loaded and the connection_type is explicitly set to ssh AND the connection_type argument is set to ssh. If both conditions are met, the function returns 'ssh2'.
Fourth, if SSH2 is not available, WordPress checks for the FTP extension. If the ftp_connect() function exists, it returns 'ftpext'.
Fifth, as a last resort, WordPress falls back to 'ftpsockets', which uses a pure PHP FTP implementation that does not require any extensions.
You can hook into this decision with the filesystem_method filter:
add_filter( 'filesystem_method', function( $method, $args, $context ) {
// Force direct method for a specific directory.
if ( $context === '/path/to/known/safe/directory' ) {
return 'direct';
}
return $method;
}, 10, 3 );
This filter receives the determined method, the arguments array, and the context directory. It fires after all the built-in checks have run, giving you the final say on which transport gets used.
Understanding this function matters because it determines the user experience in your plugin. If the method is 'direct', your plugin can write files silently without any user interaction. If the method is anything else, you will need to present a credentials form so the user can provide FTP or SSH login details.
The FS_METHOD Constant
The FS_METHOD constant is defined in wp-config.php and overrides the automatic transport detection. It accepts four values: 'direct', 'ssh2', 'ftpext', and 'ftpsockets'.
define( 'FS_METHOD', 'direct' );
Setting FS_METHOD to 'direct' is extremely common. Many hosting providers add this to wp-config.php by default because their server configuration ensures PHP runs as the file owner. Docker-based development environments, including DDEV and Local by Flywheel, almost always need this constant because the container’s web server user matches the file owner.
You should be careful about recommending FS_METHOD changes to your plugin users. If a site genuinely needs FTP-based writes to maintain correct ownership, forcing 'direct' will create files with the wrong owner. This can lead to permission errors during updates, inability to edit files through FTP clients, and even security vulnerabilities if the web server user has broader permissions than expected.
There are related constants that control FTP and SSH connection details:
define( 'FTP_HOST', 'ftp.example.com' );
define( 'FTP_USER', 'username' );
define( 'FTP_PASS', 'password' );
define( 'FTP_SSL', true );
define( 'FTP_PUBKEY', '/path/to/public/key' );
define( 'FTP_PRIKEY', '/path/to/private/key' );
When these constants are defined, WordPress uses them automatically and skips the credentials form. This is useful for automated deployments and CI/CD pipelines where interactive credential entry is not possible.
One scenario where FS_METHOD is particularly important is WP-CLI. When running commands through the CLI, there is no browser to display a credentials form. If the filesystem method requires FTP credentials and they are not defined as constants, file operations will fail silently. Setting FS_METHOD to 'direct' in CLI environments (or defining the FTP constants) avoids this problem.
Initializing the Filesystem: WP_Filesystem()
Before you can use any filesystem methods, you need to initialize the global $wp_filesystem object. This is done by calling WP_Filesystem(), which is defined in wp-admin/includes/file.php. This file is not loaded on the frontend by default, so you need to include it explicitly.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$creds = request_filesystem_credentials( '', '', false, false, null );
if ( false === $creds ) {
// The credentials form was just displayed. Stop execution.
return;
}
if ( ! WP_Filesystem( $creds ) ) {
// Credentials were invalid. Show the form again with an error.
request_filesystem_credentials( '', '', true, false, null );
return;
}
global $wp_filesystem;
// Now $wp_filesystem is ready to use.
The WP_Filesystem() function accepts an optional array of credentials and returns true on success or false on failure. After it succeeds, the global $wp_filesystem variable holds an instance of one of the transport classes: WP_Filesystem_Direct, WP_Filesystem_SSH2, WP_Filesystem_FTPext, or WP_Filesystem_ftpsockets.
All four transport classes implement the same interface, so your code does not need to care which one is active. You call the same methods regardless of the underlying transport. This abstraction is the entire point of the API.
Here are the most commonly used methods:
// Reading files.
$content = $wp_filesystem->get_contents( $file_path );
// Writing files.
$wp_filesystem->put_contents( $file_path, $data, FS_CHMOD_FILE );
// Checking existence.
$exists = $wp_filesystem->exists( $file_path );
// Deleting files.
$wp_filesystem->delete( $file_path );
// Creating directories.
$wp_filesystem->mkdir( $dir_path, FS_CHMOD_DIR );
// Listing directory contents.
$files = $wp_filesystem->dirlist( $dir_path );
// Checking if path is a directory.
$is_dir = $wp_filesystem->is_dir( $dir_path );
// Checking if path is a file.
$is_file = $wp_filesystem->is_file( $file_path );
// Getting file modification time.
$mtime = $wp_filesystem->mtime( $file_path );
// Changing permissions.
$wp_filesystem->chmod( $file_path, FS_CHMOD_FILE );
The constants FS_CHMOD_FILE and FS_CHMOD_DIR are defined by WordPress and default to 0644 and 0755 respectively. They can be overridden in wp-config.php if a hosting environment requires different permissions.
request_filesystem_credentials(): Handling the Credentials Form
When WordPress determines that FTP or SSH credentials are needed, it displays a form asking the user to enter them. The function that manages this is request_filesystem_credentials(). Its full signature is:
request_filesystem_credentials(
string $form_post,
string $type = '',
bool $error = false,
string $context = '',
array $extra_fields = null,
bool $allow_relaxed_file_ownership = false
)
The $form_post parameter is the URL that the credentials form should submit to. When the user fills in their FTP details and clicks “Proceed,” the form posts back to this URL. Your plugin catches that POST request, extracts the credentials, and passes them to WP_Filesystem().
The $type parameter forces a specific connection type. Leave it empty to let WordPress auto-detect.
The $error parameter controls whether an error message is displayed. Set it to true when re-showing the form after a failed connection attempt.
The $context parameter is a directory path. WordPress uses this to test whether credentials are actually needed for that specific location. If the directory is writable with the direct method, the credentials form is skipped entirely.
The $extra_fields parameter is an array of field names from your form that should be preserved as hidden inputs in the credentials form. This is critical for maintaining state. If your plugin form had a “file path” input and the user gets redirected to the credentials form, you need to pass that value through so it is available when the form posts back.
Here is a complete example of integrating the credentials form into a plugin settings page:
function my_plugin_write_config() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized access.' );
}
check_admin_referer( 'my_plugin_write_config' );
$config_data = sanitize_textarea_field( $_POST['config_data'] ?? '' );
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$url = wp_nonce_url(
admin_url( 'options-general.php?page=my-plugin' ),
'my_plugin_write_config'
);
$creds = request_filesystem_credentials(
$url,
'',
false,
WP_CONTENT_DIR,
array( 'config_data' )
);
if ( false === $creds ) {
return; // Form was displayed, stop here.
}
if ( ! WP_Filesystem( $creds ) ) {
request_filesystem_credentials( $url, '', true, WP_CONTENT_DIR, array( 'config_data' ) );
return;
}
global $wp_filesystem;
$file = WP_CONTENT_DIR . '/my-plugin-config.php';
$wp_filesystem->put_contents( $file, $config_data, FS_CHMOD_FILE );
wp_redirect( admin_url( 'options-general.php?page=my-plugin&updated=1' ) );
exit;
}
Notice how 'config_data' is passed in the $extra_fields array. When the credentials form renders, it creates a hidden input that preserves the value of $_POST['config_data']. When the form submits back to the same URL, the config data is still available for processing.
Transport Comparison: Direct vs SSH2 vs FTPext vs FTPsockets
Each transport has different characteristics, performance profiles, and requirements. Understanding the differences helps you anticipate what your users might encounter.
WP_Filesystem_Direct
This transport calls PHP file functions directly: file_get_contents(), file_put_contents(), mkdir(), rmdir(), and so on. It is the fastest transport by a significant margin because it involves no network overhead and no protocol negotiation.
The Direct transport is used when PHP runs as the same user who owns the WordPress files. This is the case on most modern managed hosting platforms (Kinsta, WP Engine, Cloudways, etc.) and in containerized environments. If get_filesystem_method() returns 'direct', your plugin can write files without ever showing a credentials form.
One subtle point: even with the Direct transport, $wp_filesystem->put_contents() adds a layer of permission handling that raw file_put_contents() does not. It calls $this->chmod() after writing, ensuring the file has the correct permissions. This is another reason to use the API even when you know the transport will be direct.
WP_Filesystem_SSH2
This transport uses PHP’s ssh2 extension to connect to the server over SSH and execute file operations. It supports both password and key-based authentication. The SSH2 transport is the most secure non-direct option because the connection is encrypted.
The ssh2 extension is not bundled with PHP by default and must be installed separately. On shared hosting, it is rarely available. On VPS and dedicated servers, it can be installed through PECL. Because of its limited availability, this transport is the least commonly used.
Performance is moderate. Each file operation requires a round-trip through the SSH connection, but the connection is persistent for the duration of the request.
WP_Filesystem_FTPext
This transport uses PHP’s built-in FTP extension (ext/ftp). It supports both plain FTP and FTPS (FTP over SSL). The FTP extension is available on most PHP installations because it has been bundled with PHP for many years.
The FTPext transport connects to the FTP server specified in the credentials, navigates to the correct directory, and performs file operations using FTP commands. It supports the FTP_SSL constant to enable encrypted connections.
Performance depends on network conditions. If the FTP server is localhost (which is common, since you are connecting to the same machine), latency is minimal. If the FTP server is remote, each operation incurs network round-trip time.
One important detail: the FTPext transport needs to translate between absolute filesystem paths and FTP paths. WordPress handles this by looking at the FTP home directory and mapping it to the WordPress installation path. This mapping can break on unusual server configurations where the FTP root does not correspond to the filesystem root in an obvious way.
WP_Filesystem_ftpsockets
This is the fallback transport. It implements the FTP protocol entirely in PHP using socket functions, without requiring any PHP extensions. The class is defined in wp-admin/includes/class-wp-filesystem-ftpsockets.php and uses the ftp_base class from the PemFTP library bundled with WordPress.
Because it is a pure PHP implementation, it is the slowest transport. Every FTP command is constructed and parsed manually. Socket operations are blocking. Large file transfers can be painfully slow.
The ftpsockets transport exists as a safety net. If a server has neither the SSH2 extension nor the FTP extension (which would be unusual), WordPress can still write files using this fallback. In practice, you will rarely encounter it on modern hosting.
Here is a comparison table:
| Transport | PHP Extension Required | Speed | Security | Availability |
|---|---|---|---|---|
| Direct | None | Fastest | N/A (local) | Most modern hosts |
| SSH2 | ssh2 | Moderate | Encrypted | Rare on shared hosting |
| FTPext | ftp (bundled) | Moderate | Optional SSL | Very common |
| ftpsockets | None | Slowest | Optional SSL | Always available |
Writing Files Atomically to Prevent Corruption
One of the less discussed aspects of file writing is atomicity. When you call $wp_filesystem->put_contents(), the write is not atomic. If the server crashes or the PHP process is killed midway through writing, you can end up with a partially written file. For most use cases, this is an acceptable risk. For critical files like configuration files or cache manifests, it is not.
The standard technique for atomic writes is the “write to temp, then rename” pattern. You write your data to a temporary file in the same directory as the target, then rename the temporary file to the final name. On POSIX filesystems, rename() is an atomic operation when source and destination are on the same filesystem. This means the target file either has the old content or the new content, never a partial state.
Here is how to implement this with the Filesystem API:
function atomic_put_contents( $file_path, $data ) {
global $wp_filesystem;
$dir = dirname( $file_path );
$temp_file = trailingslashit( $dir ) . '.tmp-' . wp_generate_password( 12, false );
// Write to temp file.
$written = $wp_filesystem->put_contents( $temp_file, $data, FS_CHMOD_FILE );
if ( ! $written ) {
return false;
}
// Verify the temp file was written correctly.
$verify = $wp_filesystem->get_contents( $temp_file );
if ( $verify !== $data ) {
$wp_filesystem->delete( $temp_file );
return false;
}
// Move temp file to final destination.
$moved = $wp_filesystem->move( $temp_file, $file_path, true );
if ( ! $moved ) {
$wp_filesystem->delete( $temp_file );
return false;
}
return true;
}
There is a catch with non-direct transports. When using FTP, the move() operation is implemented as an FTP RENAME command. Whether this is truly atomic depends on the FTP server implementation and the underlying filesystem. On most Linux servers with ext4 or XFS, FTP rename is atomic. On NFS mounts or Windows servers, it might not be.
For the Direct transport, WP_Filesystem_Direct::move() calls PHP’s rename() function, which maps directly to the POSIX rename() system call. This is as atomic as it gets.
Another consideration is file locking. If multiple processes might write to the same file simultaneously, you need locking. The Filesystem API does not provide built-in locking, so you need to implement it yourself. With the Direct transport, you can use flock(). With FTP transports, you need an application-level lock, such as a WordPress transient or a lock file.
function locked_put_contents( $file_path, $data ) {
$lock_key = 'file_lock_' . md5( $file_path );
// Try to acquire lock with 10 second timeout.
$attempts = 0;
while ( get_transient( $lock_key ) && $attempts < 20 ) {
usleep( 500000 ); // 0.5 seconds.
$attempts++;
}
if ( $attempts >= 20 ) {
return new WP_Error( 'lock_timeout', 'Could not acquire file lock.' );
}
set_transient( $lock_key, true, 30 ); // 30 second expiry as safety net.
$result = atomic_put_contents( $file_path, $data );
delete_transient( $lock_key );
return $result;
}
This is a basic implementation. For production use, you would want to use wp_cache_add() with an object cache backend that supports atomic add operations, or use $wpdb with a row-level lock.
Handling WP_Filesystem in AJAX Handlers and Background Processes
Using the Filesystem API in AJAX handlers presents a unique challenge. The standard flow of displaying a credentials form, waiting for user input, and processing the result does not work in an AJAX context. There is no page to render a form on. The request is asynchronous, and the response is expected to be JSON.
There are several strategies for handling this.
Strategy 1: Pre-check on the Settings Page
The most user-friendly approach is to verify filesystem access on your plugin’s settings page before allowing operations that will run via AJAX. If credentials are needed, prompt for them on the settings page and store them in an encrypted option.
// On the settings page:
$method = get_filesystem_method( array(), WP_CONTENT_DIR );
if ( 'direct' !== $method ) {
echo '<div class="notice notice-warning">';
echo '<p>This plugin needs FTP credentials to write files.</p>';
// Show credentials form here.
echo '</div>';
}
// In the AJAX handler:
add_action( 'wp_ajax_my_plugin_write_file', function() {
check_ajax_referer( 'my_plugin_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized.' );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
$method = get_filesystem_method( array(), WP_CONTENT_DIR );
if ( 'direct' === $method ) {
WP_Filesystem();
} else {
// Retrieve stored credentials.
$creds = get_option( 'my_plugin_fs_creds', array() );
if ( empty( $creds ) || ! WP_Filesystem( $creds ) ) {
wp_send_json_error( 'Filesystem credentials required. Please visit the settings page.' );
}
}
global $wp_filesystem;
$file = WP_CONTENT_DIR . '/my-plugin/output.txt';
$data = sanitize_textarea_field( $_POST['data'] ?? '' );
if ( $wp_filesystem->put_contents( $file, $data, FS_CHMOD_FILE ) ) {
wp_send_json_success( 'File written.' );
} else {
wp_send_json_error( 'Failed to write file.' );
}
});
Storing FTP credentials raises security concerns. You should encrypt them using wp_salt() or a similar site-specific key. Never store them in plain text.
Strategy 2: Assume Direct or Fail Gracefully
Many plugins take the pragmatic approach of only supporting the Direct transport for AJAX operations. If the filesystem method is not 'direct', they inform the user that the feature is not available on their hosting configuration.
add_action( 'wp_ajax_my_plugin_generate_file', function() {
check_ajax_referer( 'my_plugin_nonce' );
require_once ABSPATH . 'wp-admin/includes/file.php';
if ( 'direct' !== get_filesystem_method( array(), WP_CONTENT_DIR ) ) {
wp_send_json_error(
'Your server does not support direct file access. '
. 'Please add define("FS_METHOD", "direct") to wp-config.php '
. 'or contact your host.'
);
}
WP_Filesystem();
global $wp_filesystem;
// Proceed with file operations.
wp_send_json_success( 'Done.' );
});
This approach is honest and avoids the complexity of credential storage. Since the vast majority of modern hosting uses the Direct transport, it works for most users.
Strategy 3: Use wp_fopen() with a manually constructed filename for Temporary Files
If your AJAX handler generates a file for download rather than permanent storage, consider writing to the system temp directory. The wp_fopen() with a manually constructed filename function creates a temporary file using PHP’s fopen() with a manually constructed filename and is generally safe to use even without the Filesystem API.
$temp_file = wp_tempnam( 'my-plugin-export' );
file_put_contents( $temp_file, $csv_data );
// Return the file path or serve it directly.
wp_send_json_success( array( 'file' => $temp_file ) );
This sidesteps the Filesystem API entirely. The plugin review team generally accepts this pattern for temporary files, though you should clean up temp files promptly.
Background Processes and WP-Cron
Background processes triggered by wp_schedule_event() or wp_remote_post() face the same problem as AJAX handlers: no user is present to enter credentials. The same strategies apply. Either ensure the Direct transport is available, pre-store credentials, or use WP-CLI constants.
For WP-Cron tasks specifically, there is an additional wrinkle. The wp-admin/includes/file.php file may not be loaded in the cron context. Always check with function_exists() before calling WP_Filesystem():
add_action( 'my_plugin_daily_cleanup', function() {
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if ( 'direct' !== get_filesystem_method() ) {
error_log( 'my-plugin: Cannot perform cleanup, filesystem not directly accessible.' );
return;
}
WP_Filesystem();
global $wp_filesystem;
$log_dir = WP_CONTENT_DIR . '/my-plugin/logs/';
if ( ! $wp_filesystem->is_dir( $log_dir ) ) {
return;
}
$files = $wp_filesystem->dirlist( $log_dir );
$cutoff = time() - ( 30 * DAY_IN_SECONDS );
foreach ( $files as $name => $info ) {
$file_path = trailingslashit( $log_dir ) . $name;
if ( $wp_filesystem->mtime( $file_path ) < $cutoff ) {
$wp_filesystem->delete( $file_path );
}
}
});
Testing File Operations: Mocking WP_Filesystem in Unit Tests
Testing code that interacts with the filesystem is inherently tricky. You do not want your unit tests to actually create files on disk, especially in CI environments where the filesystem state should not carry over between tests. The Filesystem API’s abstraction layer makes mocking straightforward.
Approach 1: Mock the Global
The simplest approach is to replace the global $wp_filesystem with a mock object. If you are using PHPUnit with the WordPress test suite, you can do this in your test’s setUp() method:
class My_Plugin_File_Writer_Test extends WP_UnitTestCase {
private $original_filesystem;
private $mock_filesystem;
public function setUp(): void {
parent::setUp();
global $wp_filesystem;
$this->original_filesystem = $wp_filesystem;
$this->mock_filesystem = $this->createMock( WP_Filesystem_Direct::class );
$wp_filesystem = $this->mock_filesystem;
}
public function tearDown(): void {
global $wp_filesystem;
$wp_filesystem = $this->original_filesystem;
parent::tearDown();
}
public function test_write_config_creates_file() {
$this->mock_filesystem
->expects( $this->once() )
->method( 'put_contents' )
->with(
WP_CONTENT_DIR . '/my-plugin/config.json',
$this->stringContains( '"version"' ),
FS_CHMOD_FILE
)
->willReturn( true );
$this->mock_filesystem
->method( 'is_dir' )
->willReturn( true );
$writer = new My_Plugin_Config_Writer();
$result = $writer->save( array( 'version' => '1.0' ) );
$this->assertTrue( $result );
}
public function test_write_config_handles_failure() {
$this->mock_filesystem
->method( 'put_contents' )
->willReturn( false );
$this->mock_filesystem
->method( 'is_dir' )
->willReturn( true );
$writer = new My_Plugin_Config_Writer();
$result = $writer->save( array( 'version' => '1.0' ) );
$this->assertFalse( $result );
}
}
This approach works well but has a limitation: it couples your tests to the WP_Filesystem_Direct class interface. If WordPress changes the method signatures (unlikely but possible), your mocks could break.
Approach 2: Dependency Injection
A cleaner approach is to pass the filesystem object as a dependency rather than relying on the global:
class My_Plugin_Config_Writer {
private $filesystem;
public function __construct( $filesystem = null ) {
if ( null === $filesystem ) {
global $wp_filesystem;
$this->filesystem = $wp_filesystem;
} else {
$this->filesystem = $filesystem;
}
}
public function save( array $config ): bool {
$dir = WP_CONTENT_DIR . '/my-plugin/';
if ( ! $this->filesystem->is_dir( $dir ) ) {
$this->filesystem->mkdir( $dir, FS_CHMOD_DIR );
}
$json = wp_json_encode( $config, JSON_PRETTY_PRINT );
return (bool) $this->filesystem->put_contents(
$dir . 'config.json',
$json,
FS_CHMOD_FILE
);
}
}
Now your tests can inject the mock directly:
public function test_save_creates_directory_if_missing() {
$mock = $this->createMock( WP_Filesystem_Direct::class );
$mock->method( 'is_dir' )->willReturn( false );
$mock->expects( $this->once() )
->method( 'mkdir' )
->with( WP_CONTENT_DIR . '/my-plugin/', FS_CHMOD_DIR )
->willReturn( true );
$mock->method( 'put_contents' )->willReturn( true );
$writer = new My_Plugin_Config_Writer( $mock );
$writer->save( array( 'key' => 'value' ) );
}
Approach 3: Virtual Filesystem
For integration tests where you want to verify actual file content without touching the real filesystem, consider using a virtual filesystem library like vfsStream. While this does not directly integrate with WP_Filesystem, you can configure the Direct transport to work with vfsStream paths by adjusting the base directory:
use orgbovigovfsvfsStream;
class My_Plugin_Integration_Test extends WP_UnitTestCase {
private $root;
public function setUp(): void {
parent::setUp();
$this->root = vfsStream::setup( 'wp-content' );
}
public function test_real_write_to_virtual_fs() {
// This only works with the Direct transport.
global $wp_filesystem;
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
$path = vfsStream::url( 'wp-content/my-plugin/config.json' );
$wp_filesystem->put_contents( $path, '{"test": true}', FS_CHMOD_FILE );
$this->assertTrue( $this->root->hasChild( 'my-plugin/config.json' ) );
$this->assertStringContainsString(
'"test"',
$this->root->getChild( 'my-plugin/config.json' )->getContent()
);
}
}
This approach has limitations. The chmod() calls within put_contents() may not work as expected on a virtual filesystem. But for verifying that your code writes the correct content to the correct path, it is quite effective.
Real-World Examples
Let us look at three practical scenarios where the Filesystem API is the right tool.
Example 1: Writing a Debug Log File
Many plugins maintain their own log files for debugging. Here is a pattern that handles log rotation and uses the Filesystem API:
class My_Plugin_Logger {
private $log_dir;
private $max_size;
public function __construct() {
$this->log_dir = WP_CONTENT_DIR . '/my-plugin/logs/';
$this->max_size = 5 * MB_IN_BYTES; // 5 MB.
}
public function log( string $message, string $level = 'info' ): bool {
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if ( 'direct' !== get_filesystem_method( array(), $this->log_dir ) ) {
// Fall back to error_log() if Filesystem API is not direct.
error_log( "[my-plugin] [{$level}] {$message}" );
return false;
}
WP_Filesystem();
global $wp_filesystem;
// Ensure log directory exists.
if ( ! $wp_filesystem->is_dir( $this->log_dir ) ) {
$wp_filesystem->mkdir( $this->log_dir, FS_CHMOD_DIR );
// Add index.php to prevent directory listing.
$wp_filesystem->put_contents(
$this->log_dir . 'index.php',
'<?php // Silence is golden.',
FS_CHMOD_FILE
);
// Add .htaccess to block direct access.
$wp_filesystem->put_contents(
$this->log_dir . '.htaccess',
'Deny from all',
FS_CHMOD_FILE
);
}
$log_file = $this->log_dir . 'debug.log';
// Rotate if necessary.
if ( $wp_filesystem->exists( $log_file ) && $wp_filesystem->size( $log_file ) > $this->max_size ) {
$rotated = $this->log_dir . 'debug-' . gmdate( 'Y-m-d-His' ) . '.log';
$wp_filesystem->move( $log_file, $rotated );
}
$timestamp = gmdate( 'Y-m-d H:i:s' );
$entry = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
// Append to existing content.
$existing = '';
if ( $wp_filesystem->exists( $log_file ) ) {
$existing = $wp_filesystem->get_contents( $log_file );
}
return (bool) $wp_filesystem->put_contents(
$log_file,
$existing . $entry,
FS_CHMOD_FILE
);
}
}
Notice the security measures: an index.php file prevents directory listing, and an .htaccess file blocks direct HTTP access to log files. These are important because log files can contain sensitive information.
Also notice the append pattern. The Filesystem API does not have an append method. You must read the existing content and prepend it to the new data before writing. This is inefficient for high-volume logging. For write-heavy scenarios, consider using the database or the system error log instead.
Example 2: Generating a Configuration File
Some plugins generate PHP configuration files that are loaded on subsequent requests. This is common for caching plugins, security plugins, and optimization tools. Here is a pattern for generating a PHP config file safely:
class My_Plugin_Config_Generator {
public function generate( array $settings ): bool {
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$creds = request_filesystem_credentials( '', '', false, WP_CONTENT_DIR );
if ( false === $creds ) {
return false;
}
if ( ! WP_Filesystem( $creds ) ) {
return false;
}
global $wp_filesystem;
$config = $this->build_config_content( $settings );
$file = WP_CONTENT_DIR . '/my-plugin-config.php';
// Use atomic write for config files.
$temp = WP_CONTENT_DIR . '/my-plugin-config.tmp.php';
$written = $wp_filesystem->put_contents( $temp, $config, FS_CHMOD_FILE );
if ( ! $written ) {
return false;
}
// Validate the generated PHP.
$syntax_check = @shell_exec( sprintf( 'php -l %s 2>&1', escapeshellarg( $temp ) ) );
if ( false !== strpos( $syntax_check, 'Parse error' ) ) {
$wp_filesystem->delete( $temp );
return false;
}
$wp_filesystem->move( $temp, $file, true );
// Bust opcode cache.
if ( function_exists( 'opcache_invalidate' ) ) {
opcache_invalidate( $file, true );
}
return true;
}
private function build_config_content( array $settings ): string {
$lines = array( '<?php', '// Generated by My Plugin on ' . gmdate( 'Y-m-d H:i:s' ), '' );
foreach ( $settings as $key => $value ) {
$safe_key = preg_replace( '/[^A-Z0-9_]/', '', strtoupper( $key ) );
$safe_value = var_export( $value, true );
$lines[] = sprintf( "define( 'MY_PLUGIN_%s', %s );", $safe_key, $safe_value );
}
$lines[] = '';
return implode( PHP_EOL, $lines );
}
}
The syntax check before renaming is a safety net. If your config generation logic has a bug and produces invalid PHP, loading that file would cause a fatal error and could white-screen the entire site. By validating the temp file first, you catch the problem before it can do damage.
The OPcache invalidation step is also important. PHP’s OPcache caches the compiled bytecode of PHP files. If you overwrite a PHP file without invalidating the cache, the old version may continue to be served from the cache.
Example 3: Creating a Backup Archive
Here is a more involved example that creates a backup of plugin data as a ZIP file:
function my_plugin_create_backup(): string|WP_Error {
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if ( 'direct' !== get_filesystem_method( array(), WP_CONTENT_DIR ) ) {
return new WP_Error( 'fs_method', 'Direct filesystem access required for backups.' );
}
WP_Filesystem();
global $wp_filesystem;
$backup_dir = WP_CONTENT_DIR . '/my-plugin-backups/';
if ( ! $wp_filesystem->is_dir( $backup_dir ) ) {
$wp_filesystem->mkdir( $backup_dir, FS_CHMOD_DIR );
$wp_filesystem->put_contents(
$backup_dir . '.htaccess',
'Deny from all',
FS_CHMOD_FILE
);
$wp_filesystem->put_contents(
$backup_dir . 'index.php',
'<?php // Silence is golden.',
FS_CHMOD_FILE
);
}
// Gather data to back up.
$data_dir = WP_CONTENT_DIR . '/my-plugin/';
if ( ! $wp_filesystem->is_dir( $data_dir ) ) {
return new WP_Error( 'no_data', 'No plugin data directory found.' );
}
// Create ZIP using ZipArchive (not part of Filesystem API).
if ( ! class_exists( 'ZipArchive' ) ) {
return new WP_Error( 'no_zip', 'ZipArchive extension not available.' );
}
$timestamp = gmdate( 'Y-m-d-His' );
$zip_path = $backup_dir . "backup-{$timestamp}.zip";
$zip = new ZipArchive();
if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
return new WP_Error( 'zip_create', 'Failed to create ZIP file.' );
}
// Add files from the data directory.
$files = $wp_filesystem->dirlist( $data_dir, false, true );
my_plugin_add_files_to_zip( $zip, $wp_filesystem, $data_dir, '', $files );
// Add database export.
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_data';
$rows = $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A );
$json = wp_json_encode( $rows, JSON_PRETTY_PRINT );
$zip->addFromString( 'database-export.json', $json );
$zip->close();
// Clean up old backups (keep last 5).
$all_backups = $wp_filesystem->dirlist( $backup_dir );
$backup_files = array();
foreach ( $all_backups as $name => $info ) {
if ( '.zip' === substr( $name, -4 ) ) {
$backup_files[ $name ] = $info['lastmodunix'];
}
}
arsort( $backup_files );
$to_delete = array_slice( array_keys( $backup_files ), 5 );
foreach ( $to_delete as $old_file ) {
$wp_filesystem->delete( $backup_dir . $old_file );
}
return $zip_path;
}
function my_plugin_add_files_to_zip( ZipArchive $zip, $filesystem, string $base, string $prefix, array $files ): void {
foreach ( $files as $name => $info ) {
$full_path = trailingslashit( $base ) . $name;
$zip_path = $prefix ? trailingslashit( $prefix ) . $name : $name;
if ( 'd' === $info['type'] ) {
$zip->addEmptyDir( $zip_path );
if ( ! empty( $info['files'] ) ) {
my_plugin_add_files_to_zip( $zip, $filesystem, $full_path, $zip_path, $info['files'] );
}
} else {
$content = $filesystem->get_contents( $full_path );
if ( false !== $content ) {
$zip->addFromString( $zip_path, $content );
}
}
}
}
This example shows a common pattern: using the Filesystem API for directory listing and file reading while relying on ZipArchive for the actual compression. The Filesystem API does not have a built-in compression feature, so you combine it with other PHP tools as needed.
Note the backup rotation logic at the end. Without cleanup, backup directories can grow indefinitely. Keeping the last five backups provides a reasonable safety net without consuming excessive disk space.
Common Mistakes and Security Considerations
After working with the Filesystem API extensively and reviewing hundreds of plugins that use it, certain mistakes appear repeatedly. Here are the most important ones to avoid.
Mistake 1: Forgetting to Load the File
The most basic error is calling WP_Filesystem() without first loading the required file. On the frontend and in AJAX handlers, wp-admin/includes/file.php is not loaded automatically.
// Wrong: This will cause a fatal error on the frontend.
WP_Filesystem();
global $wp_filesystem;
// Right: Always check first.
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
Mistake 2: Not Checking Return Values
$wp_filesystem->put_contents() returns true on success and false on failure. Many developers ignore the return value and assume the write succeeded. This leads to silent data loss.
// Wrong: No error handling.
$wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE );
// Right: Check the result.
if ( ! $wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE ) ) {
return new WP_Error( 'write_failed', 'Could not write to ' . $path );
}
Mistake 3: Path Traversal Vulnerabilities
If your file path includes any user input, you must validate it rigorously. A path traversal attack can write files to arbitrary locations on the server.
// DANGEROUS: User controls the filename.
$filename = $_POST['filename'];
$path = WP_CONTENT_DIR . '/uploads/' . $filename;
$wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE );
// Safe: Validate and sanitize.
$filename = sanitize_file_name( $_POST['filename'] ?? '' );
if ( empty( $filename ) ) {
wp_die( 'Invalid filename.' );
}
// Ensure no directory traversal.
if ( $filename !== basename( $filename ) ) {
wp_die( 'Invalid filename.' );
}
// Restrict to expected extensions.
$allowed = array( 'json', 'txt', 'csv' );
$ext = pathinfo( $filename, PATHINFO_EXTENSION );
if ( ! in_array( $ext, $allowed, true ) ) {
wp_die( 'File type not allowed.' );
}
$path = trailingslashit( WP_CONTENT_DIR . '/my-plugin/exports/' ) . $filename;
// Final safety check: resolved path must be within expected directory.
$real_dir = realpath( WP_CONTENT_DIR . '/my-plugin/exports/' );
$real_path = realpath( dirname( $path ) ) . '/' . basename( $path );
if ( 0 !== strpos( $real_path, $real_dir ) ) {
wp_die( 'Path traversal detected.' );
}
$wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE );
The sanitize_file_name() function strips directory separators and other dangerous characters. The realpath() check is an additional layer that catches edge cases sanitize_file_name() might miss.
Mistake 4: Writing PHP Files From User Input
Never generate PHP code from user input. If a plugin creates a PHP config file, the values must be strictly validated. A single injection can give an attacker full control of the server.
// EXTREMELY DANGEROUS:
$value = $_POST['setting'];
$php = "<?php define('MY_SETTING', '{$value}');";
$wp_filesystem->put_contents( $path, $php, FS_CHMOD_FILE );
// If $value is: '); system('rm -rf /'); //
// The generated file becomes:
// <?php define('MY_SETTING', ''); system('rm -rf /'); //');
// Safe: Use var_export for type-safe output.
$value = sanitize_text_field( $_POST['setting'] ?? '' );
$exported = var_export( $value, true );
$php = "<?phpndefine( 'MY_SETTING', {$exported} );n";
$wp_filesystem->put_contents( $path, $php, FS_CHMOD_FILE );
The var_export() function produces valid PHP literals with proper escaping. For strings, it wraps the value in single quotes and escapes internal single quotes. This is far safer than string interpolation.
Even with var_export(), exercise extreme caution. If you can avoid writing PHP files entirely and use JSON or serialized options instead, that is almost always the better choice.
Mistake 5: Hardcoding Paths Instead of Using Constants
WordPress provides several path constants. Using them makes your code portable across different installation structures.
// Fragile: Assumes standard directory structure.
$path = '/var/www/html/wp-content/uploads/my-plugin/file.txt';
// Correct: Use WordPress constants.
$path = WP_CONTENT_DIR . '/uploads/my-plugin/file.txt';
// Even better: Use the uploads directory function.
$upload_dir = wp_upload_dir();
$path = trailingslashit( $upload_dir['basedir'] ) . 'my-plugin/file.txt';
The wp_upload_dir() function respects the UPLOADS constant and the upload_path option, handling non-standard configurations automatically.
Mistake 6: Ignoring the Context Directory in request_filesystem_credentials()
The fourth parameter of request_filesystem_credentials() is the context directory. Many developers pass false or an empty string, which means WordPress cannot optimize the credentials check for a specific directory.
// Suboptimal: No context, so WordPress tests against ABSPATH.
$creds = request_filesystem_credentials( $url, '', false, false );
// Better: Specify the exact directory you will write to.
$creds = request_filesystem_credentials( $url, '', false, WP_CONTENT_DIR . '/my-plugin/' );
With a specific context directory, WordPress can determine whether credentials are truly needed for that location. Some server configurations allow direct writes to certain directories but not others.
Mistake 7: Not Creating Parent Directories
$wp_filesystem->put_contents() will fail silently if the parent directory does not exist. Always ensure the directory structure is in place before writing.
$dir = dirname( $file_path );
if ( ! $wp_filesystem->is_dir( $dir ) ) {
// wp_mkdir_p() uses the Filesystem API internally when available.
wp_mkdir_p( $dir );
}
The wp_mkdir_p() function creates directories recursively, similar to mkdir() with the recursive flag. It handles the creation of all intermediate directories in the path.
Mistake 8: Using the API for High-Frequency Writes
The Filesystem API is not designed for high-frequency write operations. If your plugin logs every page view or writes analytics data on every request, you should not use the Filesystem API for that. The overhead of reading the existing file, appending data, and writing the full content back is too high for a request that should complete in milliseconds.
For high-frequency writes, use the database. The $wpdb class is optimized for concurrent access and handles locking at the database level. If you need file-based output for performance reasons (like a page cache), write to the file directly and accept the plugin review implications, or better yet, use a well-known caching API like WP_Object_Cache.
Mistake 9: Storing Sensitive Data Without Protection
When writing files that contain API keys, passwords, or other sensitive data, the file must be protected from direct HTTP access. This means placing it outside the web root when possible, or adding .htaccess / Nginx rules to block access.
// Create protected directory.
$protected_dir = WP_CONTENT_DIR . '/my-plugin-data/';
if ( ! $wp_filesystem->is_dir( $protected_dir ) ) {
$wp_filesystem->mkdir( $protected_dir, FS_CHMOD_DIR );
// Apache protection.
$wp_filesystem->put_contents(
$protected_dir . '.htaccess',
"Order deny,allownDeny from all",
FS_CHMOD_FILE
);
// Prevent directory listing (works on any server).
$wp_filesystem->put_contents(
$protected_dir . 'index.php',
'<?php // Silence is golden.',
FS_CHMOD_FILE
);
}
// For Nginx, you need to add a location block in the server config:
// location ~* /my-plugin-data/ { deny all; }
Note that .htaccess only works on Apache with AllowOverride enabled. On Nginx servers, you need server configuration changes. This is why storing sensitive data in files is generally less secure than storing it in the database as an encrypted option.
Performance Considerations
The Filesystem API adds overhead compared to raw PHP file functions. On the Direct transport, the overhead is minimal: an extra function call wrapper and a chmod() operation after each write. On FTP transports, the overhead is significant because each operation involves network communication.
If you need to perform multiple file operations in sequence (creating a directory, writing several files, setting permissions), try to batch them logically and check the transport method first. For the FTP transports, every call to put_contents(), exists(), or is_dir() generates network traffic.
global $wp_filesystem;
// Inefficient: Multiple round trips on FTP.
if ( ! $wp_filesystem->is_dir( $dir ) ) {
$wp_filesystem->mkdir( $dir );
}
if ( ! $wp_filesystem->exists( $dir . '/index.php' ) ) {
$wp_filesystem->put_contents( $dir . '/index.php', '<?php // Silence.', FS_CHMOD_FILE );
}
if ( ! $wp_filesystem->exists( $dir . '/.htaccess' ) ) {
$wp_filesystem->put_contents( $dir . '/.htaccess', 'Deny from all', FS_CHMOD_FILE );
}
// Better: Fewer checks, accept idempotent operations.
if ( ! $wp_filesystem->is_dir( $dir ) ) {
$wp_filesystem->mkdir( $dir );
$wp_filesystem->put_contents( $dir . '/index.php', '<?php // Silence.', FS_CHMOD_FILE );
$wp_filesystem->put_contents( $dir . '/.htaccess', 'Deny from all', FS_CHMOD_FILE );
}
In the second version, the protection files are only written when the directory is first created. This trades a tiny risk (the files might get accidentally deleted without being recreated) for better performance on FTP transports.
For plugins that only need to write files during configuration changes or on manual triggers, performance is rarely a concern. The user is already waiting for a settings page to load. An extra 100ms for an FTP write is invisible in that context.
Compatibility Notes
The Filesystem API has been part of WordPress since version 2.5, released in 2008. The core API is extremely stable. The method signatures have not changed in over a decade. Code written for WordPress 3.0 will still work on the latest version.
However, there are a few version-specific considerations:
The $wp_filesystem->chmod() method accepts octal permissions. On some FTP servers, the SITE CHMOD command is not supported. In those cases, chmod() silently fails. This is usually not a problem because the file was created with the correct permissions by the FTP user.
The $wp_filesystem->dirlist() method returns an associative array where keys are filenames and values are arrays with details like 'name', 'type' (f for file, d for directory), 'size', 'lastmod', and 'lastmodunix'. The exact keys available depend on the transport. The Direct transport provides all fields, while FTP transports may omit some depending on the server’s directory listing format.
Since WordPress 5.2, the wp_is_file_mod_allowed() function checks whether file modifications are allowed at all. The DISALLOW_FILE_MODS constant (set in wp-config.php) can disable all file modifications, including plugin and theme updates. If your plugin writes files, check this constant first:
if ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS ) {
return new WP_Error(
'file_mods_disabled',
'File modifications are disabled on this site.'
);
}
Putting It All Together
The WordPress Filesystem API exists to solve a real problem that affects real hosting environments. While it adds complexity compared to raw PHP file functions, that complexity exists for good reasons: correct file ownership, cross-server compatibility, and security.
Here is a checklist for using the API correctly in your plugins:
1. Always load wp-admin/includes/file.php before calling WP_Filesystem().
2. Use request_filesystem_credentials() with the correct context directory.
3. Check every return value from filesystem methods.
4. Sanitize any user input that influences file paths or content.
5. Use atomic writes for critical files like configurations.
6. Protect sensitive directories with .htaccess and index.php.
7. Handle the AJAX/cron case by checking for the Direct transport or pre-stored credentials.
8. Never write PHP files from unsanitized user input.
9. Use wp_upload_dir() and WordPress path constants instead of hardcoded paths.
10. Test your file operations with mock objects or virtual filesystems.
The Filesystem API is one of those WordPress features that seems overly complicated until you understand the problem it solves. Once you have worked with enough hosting environments and seen the ownership problems that direct writes cause, the API starts to feel less like bureaucratic overhead and more like a necessary tool. Use it correctly, and your plugins will work reliably across the full range of WordPress hosting environments, from shared cPanel accounts to containerized cloud deployments.
Rachel Torres
Senior WordPress developer and core contributor. Specializes in WordPress internals, performance optimization, and PHP best practices. Runs a WordPress consultancy in Austin, Texas.