PHP Toolkit

Filesystem

One Filesystem interface across local disk, in-memory trees, SQLite databases, and ZIP archives. Forward-slash paths everywhere — even on Windows — so the same code runs in tests, in production, and inside read-only ZIPs.

composer require wp-php-toolkit/filesystem

Code that touches the filesystem is hard to test, hard to port to Windows, and impossible to point at non-disk storage without rewriting it. Swap LocalFilesystem for InMemoryFilesystem in tests and your suite stops touching /tmp; swap it for SQLiteFilesystem and your "files" become rows in a portable database; swap it for ZipFilesystem and you can read inside an archive with the same calls.

Every backend uses forward slashes regardless of host OS. No DIRECTORY_SEPARATOR juggling, no Windows-only test failures, no surprises when a path moves between backends.

In-memory tree

The fastest backend. No disk I/O, no cleanup, no test-isolation problems.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\InMemoryFilesystem;

$fs = InMemoryFilesystem::create();
$fs->put_contents( '/hello.txt', 'Hello, world!' );
echo $fs->get_contents( '/hello.txt' );

Test code without touching disk

Code that takes a Filesystem parameter, instead of calling file_get_contents() directly, can be tested against an InMemoryFilesystem. The test sets up files in memory, exercises the function, and asserts on what got written — no temp directories, no cleanup.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\Filesystem;
use WordPress\Filesystem\InMemoryFilesystem;

function bump_version( Filesystem $fs, $path ) {
	$json = json_decode( $fs->get_contents( $path ), true );
	list( $maj, $min, $patch ) = explode( '.', $json['version'] );
	$json['version'] = $maj . '.' . $min . '.' . ( (int) $patch + 1 );
	$fs->put_contents( $path, json_encode( $json ) );
}

$fs = InMemoryFilesystem::create();
$fs->put_contents( '/package.json', '{"version":"1.2.3"}' );
bump_version( $fs, '/package.json' );

echo $fs->get_contents( '/package.json' ) . "\n";

Local disk with a chrooted root

LocalFilesystem::create($root) is implicitly chrooted: every path resolves relative to $root and a ../ cannot escape. Reach for it when a request path or CLI argument names a file inside one project directory.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\LocalFilesystem;

$root = sys_get_temp_dir() . '/toolkit-' . uniqid();
$fs   = LocalFilesystem::create( $root );

$fs->mkdir( '/uploads', array( 'recursive' => true ) );
$fs->put_contents( '/uploads/note.txt', 'Hi from local disk.' );

echo $fs->get_contents( '/uploads/../uploads/note.txt' ) . "\n";

$fs->rmdir( '/', array( 'recursive' => true ) );
echo "exists after cleanup? " . ( is_dir( $root ) ? 'yes' : 'no' ) . "\n";

SQLite as a portable file store

The whole tree lives in one SQLite database file. Use it for self-contained scratch storage that survives process boundaries without leaving loose files behind.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\SQLiteFilesystem;

$fs = SQLiteFilesystem::create( ':memory:' );
$fs->mkdir( '/posts', array( 'recursive' => true ) );
for ( $i = 1; $i <= 3; $i++ ) {
	$fs->put_contents( "/posts/post-{$i}.md", "# Post {$i}\n\nBody {$i}." );
}

foreach ( $fs->ls( '/posts' ) as $name ) {
	$first = strtok( $fs->get_contents( '/posts/' . $name ), "\n" );
	echo "{$name}: {$first}\n";
}

Copy a tree across backends

The killer composability move: copy_between_filesystems() streams files chunk-by-chunk from any source to any target. Pull a ZIP into SQLite, snapshot SQLite to disk, mirror disk into RAM — all the same call.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\InMemoryFilesystem;
use WordPress\Filesystem\LocalFilesystem;
use WordPress\Filesystem\SQLiteFilesystem;
use function WordPress\Filesystem\copy_between_filesystems;

$root  = sys_get_temp_dir() . '/copytree-' . uniqid();
$local = LocalFilesystem::create( $root );
$local->mkdir( '/site/posts', array( 'recursive' => true ) );
$local->put_contents( '/site/posts/2024-01.md', '# Hello 2024' );
$local->put_contents( '/site/index.html', '<h1>Home</h1>' );

$sqlite = SQLiteFilesystem::create( ':memory:' );
copy_between_filesystems( array(
	'source_filesystem' => $local,
	'source_path'       => '/site',
	'target_filesystem' => $sqlite,
	'target_path'       => '/snapshot',
) );

$mem = InMemoryFilesystem::create();
copy_between_filesystems( array(
	'source_filesystem' => $sqlite,
	'source_path'       => '/snapshot',
	'target_filesystem' => $mem,
	'target_path'       => '/copy',
) );

echo "in memory after two copies:\n";
echo "  posts: " . implode( ', ', $mem->ls( '/copy/posts' ) ) . "\n";
echo "  index: " . $mem->get_contents( '/copy/index.html' ) . "\n";

$local->rmdir( '/', array( 'recursive' => true ) );

Atomic write via tempfile rename

Write to a sibling tempfile, then rename — that's how you avoid leaving a half-written file on crash. rename() is atomic within a single filesystem.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use WordPress\Filesystem\Filesystem;
use WordPress\Filesystem\LocalFilesystem;

function atomic_put_contents( Filesystem $fs, $path, $bytes ) {
	$tmp = $path . '.tmp.' . bin2hex( random_bytes( 4 ) );
	$fs->put_contents( $tmp, $bytes );
	$fs->rename( $tmp, $path );
}

$root = sys_get_temp_dir() . '/atomic-' . uniqid();
$fs   = LocalFilesystem::create( $root );

$fs->put_contents( '/config.json', '{"v":1}' );
atomic_put_contents( $fs, '/config.json', '{"v":2}' );

echo "config: " . $fs->get_contents( '/config.json' ) . "\n";
echo "no .tmp leftovers: " . count( $fs->ls( '/' ) ) . " entries in root\n";

$fs->rmdir( '/', array( 'recursive' => true ) );

Path helpers that behave the same on Windows

Unix path semantics apply on every host OS. This matters for abstract paths such as a SQLite key or a ZIP entry name because those paths do not live on a real drive.

<?php
require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';

use function WordPress\Filesystem\wp_join_unix_paths;
use function WordPress\Filesystem\wp_unix_dirname;
use function WordPress\Filesystem\wp_unix_path_resolve_dots;

echo wp_join_unix_paths( '/var/www', '/site/', '/index.php' ) . "\n";
echo wp_unix_dirname( '/a/b/c/d.txt', 2 ) . "\n";
echo wp_unix_path_resolve_dots( '/a/b/../c/./d/../e' ) . "\n";

See also