Skip to content

File System

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Machinjiri FileSystem component provides a single, consistent API for reading, writing, moving, copying, and managing files across multiple storage backends. It follows the adapter pattern: a FileSystem facade wraps a swappable FileSystemContract implementation (the adapter), so your application code never needs to know whether it's writing to a local disk, an FTP server, Google Drive, or OneDrive.

// Identical code regardless of the underlying storage
$fs = $manager->disk('local');
$fs->write('reports/q1.pdf', $pdfContents);

$cloud = $manager->disk('google');
$cloud->write('reports/q1.pdf', $pdfContents); // same API

Key Benefits

  • Uniform API — one set of methods for every backend.
  • Pluggable adapters — swap storage by changing configuration, not code.
  • Streams first — read and write large files without exhausting memory.
  • Disk manager — define named disks (like Laravel's filesystem) and resolve them lazily.
  • Cloud-ready — first-class Google Drive (v3) and OneDrive (Microsoft Graph v1.0) adapters.
  • Auth helpers — pluggable token providers (Google service account, Graph client credentials).
  • Resilience — optional rate limiting, caching, and structured logging.
  • Path safety — cloud adapters normalize paths to prevent traversal outside the configured root.

Why This Component?

PHP's built-in filesystem functions (file_get_contents, fopen, rename, copy) only work locally. When you need the same application logic to support local files and cloud storage, you'd normally end up with branching code (if local { ... } elseif ftp { ... }). This component collapses those branches into one interface.

Feature Native PHP Machinjiri FS
Local files ✅ ✅
FTP Manual ✅
Google Drive ❌ ✅
OneDrive ❌ ✅
Streams for all backends Manual ✅
Named disks (config) ❌ ✅
Path traversal protection Manual ✅ (cloud)
Rate limiting ❌ ✅
Caching metadata ❌ ✅ (cloud)

Architecture Overview

                  ┌────────────────────────────┐
                  │     FileSystemManager      │  (disk resolver)
                  │  - reads config            │
                  │  - caches disk instances   │
                  └─────────────┬──────────────┘
                                │ returns
                                ▼
                  ┌────────────────────────────┐
                  │        FileSystem          │  (facade)
                  │  - implements Contract     │
                  │  - delegates to adapter    │
                  └─────────────┬──────────────┘
                                │ delegates to
                                ▼
                  ┌────────────────────────────┐
                  │   FileSystemContract       │  (interface)
                  └─────────────┬──────────────┘
                                │ implemented by
        ┌───────────────┬───────┴───────┬──────────────────┐
        ▼               ▼               ▼                  ▼
  LocalAdapter    FtpAdapter     CloudAdapter        (your adapter)
                                 (abstract)
                                   │
                    ┌──────────────┴──────────────┐
                    ▼                             ▼
                GoogleDrive                    OneDrive
                    │                             │
                    └──────────┬──────────────────┘
                               ▼
                     CloudClientInterface
                               │
                               ▼
                       HttpCloudClient
                               │
                               ▼
                     HttpRequest (underlying client)

Layer responsibilities

Layer Responsibility
FileSystemManager Reads config, resolves disk names to FileSystem instances, caches them.
FileSystem (facade) Implements FileSystemContract, forwards every call to the adapter.
Adapters (LocalAdapter, FtpAdapter, CloudAdapter subclasses) Do the actual I/O.
CloudAdapter Normalizes paths, defines primitives (fetchMetadata, uploadStream, etc.), and implements the full FileSystemContract in terms of those primitives.
CloudClientInterface / HttpCloudClient Abstract HTTP transport (auth, rate limiting, logging).
TokenProviderInterface Supply OAuth bearer tokens.

Quick Start (Beginner)

1. Define your disks

use Mlangeni\Machinjiri\Core\FileSystem\FileSystemManager;

$config = [
    'default' => 'local',

    'disks' => [
        'local' => [
            'driver' => 'local',
            'root'   => __DIR__ . '/storage',
        ],

        'ftp' => [
            'driver'   => 'ftp',
            'host'     => 'ftp.example.com',
            'port'     => 21,
            'username' => 'user',
            'password' => 'secret',
            'root'     => '/public_html',
            'passive'  => true,
            'ssl'      => false,
            'timeout'  => 90,
        ],
    ],
];

$manager = new FileSystemManager($config);

2. Grab a disk and use it

$fs = $manager->disk('local');            // or disk() for default

$fs->write('hello.txt', 'Hello, world!');

if ($fs->exists('hello.txt')) {
    echo $fs->read('hello.txt');           // "Hello, world!"
    echo $fs->size('hello.txt');           // 13
}

$fs->copy('hello.txt', 'backup/hello.txt');
$fs->move('backup/hello.txt', 'archive/hello.txt');
$fs->delete('archive/hello.txt');

print_r($fs->listContents('', true));

That's it. The same calls work against disk('ftp').


Configuration

Configuration is a plain PHP array. Each disk requires a driver key and may supply driver-specific options.

Disk config schema

Key Applies to Description
driver all local, ftp, or a cloud driver you register.
root local, ftp, cloud Filesystem root / remote base directory.
host, port, username, password ftp Connection credentials.
ssl ftp Use ftp_ssl_connect (FTPS).
passive ftp Passive mode (usually true).
timeout ftp Connection timeout in seconds.
client cloud A pre-built CloudClientInterface (optional).
root cloud Prefix stripped/added to every path.

Example: multiple disks

$config = [
    'default' => 'local',
    'disks' => [
        'local'   => ['driver' => 'local', 'root' => '/var/app/storage'],
        'backups' => ['driver' => 'local', 'root' => '/mnt/backups'],
        'cdn'     => ['driver' => 'ftp', /* ... */],
    ],
];

disk('backups') and disk('local') return different adapters over different roots. The manager caches each by name, so repeated calls return the same instance.


Core Concepts

The contract

All adapters implement Mlangeni\Machinjiri\Core\FileSystem\Contracts\FileSystem:

interface FileSystem
{
    public function read(string $path): string;
    public function readStream(string $path);                        // resource
    public function write(string $path, string $contents, array $config = []): bool;
    public function writeStream(string $path, $resource, array $config = []): bool;
    public function exists(string $path): bool;
    public function delete(string $path): bool;
    public function move(string $source, string $destination): bool;
    public function copy(string $source, string $destination): bool;
    public function listContents(string $directory = '', bool $recursive = false): array;
    public function size(string $path): int;
    public function lastModified(string $path): int;
    public function getVisibility(string $path): string;
    public function setVisibility(string $path, string $visibility): bool;
}

Paths are always relative to the disk root and use forward slashes: reports/2024/q1.pdf.

Visibility

getVisibility / setVisibility accept 'public' and 'private'.

  • Local — public = mode 0644, private = 0600.
  • FTP — attempts ftp_chmod (may be unsupported on some servers).
  • Google Drive / OneDrive — "public" means an anonymous sharing link exists.

Streams

Use streams for large files:

$src = fopen('/tmp/huge.zip', 'rb');
$fs->writeStream('uploads/huge.zip', $src);
fclose($src);

$handle = $fs->readStream('uploads/huge.zip');
while (!feof($handle)) {
    echo fread($handle, 8192);
}
fclose($handle);

For local disks, readStream returns an rb file handle. For FTP and cloud, it returns a php://temp buffer or a streamed response.


The FileSystem API

Method Returns Throws on failure
read($path) string MachinjiriException
readStream($path) resource MachinjiriException
write($path, $contents, $config = []) bool MachinjiriException
writeStream($path, $resource, $config = []) bool MachinjiriException
exists($path) bool never
delete($path) bool MachinjiriException
move($src, $dst) bool MachinjiriException
copy($src, $dst) bool MachinjiriException
listContents($dir = '', $recursive = false) array MachinjiriException (cloud) / [] (local missing dir)
size($path) int MachinjiriException
lastModified($path) int (Unix timestamp) MachinjiriException
getVisibility($path) 'public' | 'private' MachinjiriException
setVisibility($path, $visibility) bool MachinjiriException
getAdapter() FileSystemContract —

$config accepted by write / writeStream

Key Values Applies to
visibility 'public', 'private' all
conflict 'replace', 'rename', 'fail' cloud (Google, OneDrive)

listContents return shape

[
    [
        'type'         => 'file' | 'dir',
        'path'         => 'relative/path/from/root',
        'basename'     => 'name.ext',
        'size'         => 1234,        // files only
        'lastModified' => 1700000000,  // Unix timestamp
    ],
    // ...
]

Note: directory size/lastModified fields are only populated by cloud adapters; local adapter returns them only for files (and directory entries only at the top level).


Adapters

LocalAdapter

Wraps PHP's local filesystem.

  • Root directory is created automatically if missing (mode 0755).
  • Parent directories are auto-created on write / writeStream / move / copy.
  • If a file is read-only, the adapter makes it writable before write/move/copy/delete (adds owner-write bit).
  • Uses LOCK_EX on writes.
  • Extras (not on the interface): append($path, $contents, $config = []) and appendStream($path, $resource, $config = []).
/** @var LocalAdapter $local */
$local = $manager->disk('local')->getAdapter();
$local->append('log.txt', "new line\n");

Caution: append / appendStream are on LocalAdapter only — not available through the FileSystem facade or on other adapters. Access via getAdapter().

FtpAdapter

Uses PHP's FTP extension.

  • Establishes a connection in the constructor — constructing throws if the server is unreachable or login fails.
  • Supports FTPS via ssl => true.
  • Root is chdir'd on connect.
  • copy() is implemented as read() + write() (FTP has no native copy).
  • listContents() parses ftp_rawlist output — may be fragile on non-standard FTP servers.
  • getVisibility() always returns 'public'; setVisibility() attempts ftp_chmod.
  • Connection is closed in __destruct().

CloudAdapter (abstract base)

Subclasses (GoogleDrive, OneDrive) supply primitives, and CloudAdapter provides the full interface:

Primitive (abstract) Purpose
fetchMetadata($path): ?array Metadata or null if not found
fetchContents($path): string Download as string
fetchStream($path): resource Download as stream
uploadContents($path, $contents, $config) Upload string
uploadStream($path, $resource, $config) Upload stream
removePath($path): bool Delete
movePath($src, $dst): bool Move/rename
copyPath($src, $dst): bool Copy
listPath($path, $recursive): array Directory listing
applyVisibility($path, $visibility): bool Public/private toggle

It also provides:

  • Path normalization collapsing . and .., preventing traversal.
  • Optional cache (setCache) for metadata (cacheGet/cacheSet/cacheForget).
  • Optional logger (setLogger) — logs via $logger->{level}($msg, $ctx) if the method exists.
  • Constants: VISIBILITY_PUBLIC, VISIBILITY_PRIVATE, CONFLICT_REPLACE, CONFLICT_RENAME, CONFLICT_FAIL.

Intermediate Usage

Listing a directory tree

$items = $fs->listContents('uploads', true);
foreach ($items as $item) {
    printf("%s %s (%d bytes)\n", $item['type'], $item['path'], $item['size'] ?? 0);
}

Writing with visibility

$fs->write('public/logo.png', $data, ['visibility' => 'public']);

Copying between disks

The API only copies within a disk. To copy between disks, bridge with streams:

$src = $manager->disk('local')->readStream('big.zip');
$manager->disk('ftp')->writeStream('backups/big.zip', $src);
fclose($src);

Checking existence without exceptions

exists() never throws — use it to guard.

if (!$fs->exists('config/app.json')) {
    $fs->write('config/app.json', '{}');
}

Advanced Usage

Writing with conflict policies (cloud)

$fs->write('report.pdf', $pdf, [
    'conflict' => 'rename',  // or 'replace', 'fail'
]);
  • replace (default) — overwrite existing file.
  • fail — throw if file exists (409).
  • rename — append a counter (report (1).pdf).

Streaming chunked uploads

Cloud adapters implement resumable chunked uploads:

  • Google Drive: 8 MiB chunks, PUT with Content-Range (tolerates 308 Resume Incomplete).
  • OneDrive: 1.6 MiB chunks (multiple of 320 KiB, Graph requirement), follows nextExpectedRanges.

Always pass a seekable stream for best results:

$handle = fopen('/tmp/video.mp4', 'rb');
$fs->writeStream('videos/video.mp4', $handle);
fclose($handle);

Caching cloud metadata

use Mlangeni\Machinjiri\Core\Artisans\Caching\CacheManager;

$cache = new CacheManager(/* ... */);
$adapter = $manager->disk('google')->getAdapter();
$adapter->setCache($cache);  // PSR-16-shaped: get/set/delete

Google Drive caches metadata for 300 seconds; OneDrive invalidates per-path on write.

Logging

$logger = LoggerFactory::make(/* ... */);
$manager->disk('google')->getAdapter()->setLogger($logger);

Both CloudAdapter and HttpCloudClient use the logger. HttpCloudClient logs cloud.request with method, uri, status, duration.


Cloud Storage (Google Drive & OneDrive)

Google Drive setup

use Mlangeni\Machinjiri\Core\FileSystem\Cloud\Adapters\GoogleDrive;
use Mlangeni\Machinjiri\Core\FileSystem\Cloud\HttpCloudClient;
use Mlangeni\Machinjiri\Core\FileSystem\Cloud\TokenProviders\GoogleServiceAccountTokenProvider;
use Mlangeni\Machinjiri\Core\Http\HttpRequest;

$http   = new HttpRequest(/* ... */);
$client = new HttpCloudClient($http);

$provider = new GoogleServiceAccountTokenProvider(
    clientEmail: 'service-account@project.iam.gserviceaccount.com',
    privateKey:  file_get_contents('/path/to/key.pem'),
    scope:       'https://www.googleapis.com/auth/drive'
);
$client->setTokenProvider($provider);

$drive = new GoogleDrive($client, root: 'app-data');
$drive->write('docs/readme.txt', 'hello');

Key behaviours:

  • Path resolution walks the Drive hierarchy, memoizing IDs in $idCache for the request.
  • Uploads:
    • uploadContents uses multipart (uploadType=multipart).
    • uploadStream uses resumable (uploadType=resumable).
  • Conflict handling: emulated via delete-then-create (replace), pre-check (fail), or name-uniquing loop (rename).
  • Visibility: "public" = an anyone reader permission exists.
  • getVisibility override inspects permissions directly (cached metadata doesn't include it).

OneDrive setup

use Mlangeni\Machinjiri\Core\FileSystem\Cloud\Adapters\OneDrive;
use Mlangeni\Machinjiri\Core\FileSystem\Cloud\TokenProviders\GraphClientCredentialsProvider;

$client = new HttpCloudClient($http);
$client->setTokenProvider(new GraphClientCredentialsProvider(
    tenantId:     getenv('AZURE_TENANT_ID'),
    clientId:     getenv('AZURE_CLIENT_ID'),
    clientSecret: getenv('AZURE_CLIENT_SECRET'),
));

$onedrive = new OneDrive($client, driveId: 'b!abcdEFGH...', root: 'Documents');
$onedrive->write('notes.txt', 'hello');

Key behaviours:

  • Small uploads (≤ 4 MB) go via simple PUT /content.
  • Larger uploads create an upload session and send chunks.
  • Conflict behavior maps directly to Graph's @microsoft.graph.conflictBehavior (replace, rename, fail).
  • Copy is async — a 202 Accepted is treated as success.
  • Visibility toggles an anonymous view link.

Bringing your own cloud client

CloudAdapter only needs a CloudClientInterface (with send() and stream()). Implement it to use any HTTP transport, or extend HttpCloudClient.


Authentication & Token Providers

Both providers implement TokenProviderInterface and cache tokens in memory, refreshing 5 minutes before expiry.

GoogleServiceAccountTokenProvider

Builds a JWT (RS256), signs with openssl_sign, exchanges it at https://oauth2.googleapis.com/token for a bearer token.

new GoogleServiceAccountTokenProvider(
    clientEmail: '...',
    privateKey:  '-----BEGIN PRIVATE KEY-----...',
    scope:       'https://www.googleapis.com/auth/drive'
);

GraphClientCredentialsProvider

Uses the OAuth2 client credentials grant against https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token with scope https://graph.microsoft.com/.default.

new GraphClientCredentialsProvider(
    tenantId: '...',
    clientId: '...',
    clientSecret: '...'
);

Production note: these providers use raw curl_init instead of your application HTTP client, so they bypass retries/proxies/telemetry configured on HttpRequest. Swap them for your own provider if you need full HTTP abstraction.


Rate Limiting & Logging

Client-side rate limiting

$client->withRateLimit(requestsPerSecond: 10.0, burst: 20);

Uses a token bucket (TokenBucket) so bursts up to burst are allowed, then sustained at requestsPerSecond.

Logging

$client->setLogger($logger); // propagates to underlying HTTP client too
  • HttpCloudClient logs each request as cloud.request.
  • CloudAdapter logs via log($level, $message, $context) if the logger has that method.

Extending the Component

Writing a custom adapter

Implement the contract directly:

use Mlangeni\Machinjiri\Core\FileSystem\Contracts\FileSystem;

class S3Adapter implements FileSystem
{
    public function read(string $path): string { /* ... */ }
    public function readStream(string $path) { /* ... */ }
    // ... all other methods
}

Then add a match arm in FileSystemManager::resolve():

$adapter = match ($driver) {
    'local' => new LocalAdapter($diskConfig['root'] ?? getcwd()),
    'ftp'   => new FtpAdapter($diskConfig),
    's3'    => new S3Adapter($diskConfig),        // your driver
    default => throw new MachinjiriException("Unsupported driver: {$driver}", 500),
};

Extending CloudAdapter

Far less work — implement only the primitives:

class S3Cloud extends CloudAdapter
{
    protected function fetchMetadata(string $path): ?array { /* ... */ }
    protected function fetchContents(string $path): string { /* ... */ }
    protected function fetchStream(string $path) { /* ... */ }
    protected function uploadContents(string $path, string $contents, array $config = []): void { /* ... */ }
    protected function uploadStream(string $path, $resource, array $config = []): void { /* ... */ }
    protected function removePath(string $path): bool { /* ... */ }
    protected function movePath(string $source, string $destination): bool { /* ... */ }
    protected function copyPath(string $source, string $destination): bool { /* ... */ }
    protected function listPath(string $path, bool $recursive): array { /* ... */ }
    protected function applyVisibility(string $path, string $visibility): bool { /* ... */ }
}

You inherit path normalization, caching, logging, and interface wiring.


Error Handling

All failures throw Mlangeni\Machinjiri\Core\Exceptions\MachinjiriException. The second constructor argument is used as an HTTP-ish status code:

Code Meaning in this component
400 Invalid argument (bad visibility)
401 Authentication failure (FTP login)
404 File/directory not found
409 Conflict (cloud fail policy)
500 General runtime failure (I/O, HTTP 5xx, config)
use Mlangeni\Machinjiri\Core\Exceptions\MachinjiriException;

try {
    $fs->read('missing.txt');
} catch (MachinjiriException $e) {
    echo $e->getMessage();   // "File does not exist: missing.txt"
    echo $e->getCode();      // 404
}

exists() is the exception-free way to probe paths.


Best Practices

  1. Always use streams for large files. read() / write() load the whole payload into memory. Use readStream / writeStream.
  2. Guard with exists() rather than catching 404s.
  3. Use writeStream for cloud uploads. Google Drive and OneDrive's string uploads use multipart bodies, which duplicate memory.
  4. Close streams you open. Adapters don't close resources they didn't create.
  5. Use named disks per concern (backups, cdn, uploads) instead of one giant disk.
  6. Set explicit visibility when writing public assets — don't rely on defaults.
  7. Use conflict => 'rename' for user-uploaded files to avoid silent overwrites.
  8. Set a logger in production — cloud calls are expensive; observability matters.
  9. Rate-limit cloud clients shared by background jobs (withRateLimit).
  10. Check getAdapter() type before calling adapter-specific methods like append().

Troubleshooting

FTP extension is required

Install and enable ext-ftp:

sudo apt install php-ftp    # Debian/Ubuntu
# or compile with --with-ftp

Could not connect to FTP server

  • Verify host, port, and firewall rules.
  • Try ssl => true if the server requires FTPS (usually port 990 or explicit TLS).
  • Increase timeout.

FTP root directory does not exist

The root config is chdir'd right after login. Make sure the path exists relative to the login home.

Path not found: ... (cloud)

  • Confirm the parent hierarchy exists. Cloud adapters do not auto-create directories — you must create folder items yourself (via the provider's API or pre-provisioned folders).
  • For Google Drive, ensure the service account's scope includes drive.
  • For OneDrive, verify driveId is correct.

Google Drive did not return an upload session URI

The Location header was missing — likely an auth or permission error. Inspect the response body via logging.

Copy is async (OneDrive)

copyPath returns true on 202. The destination may not be visible immediately. Poll exists() or fetchMetadata() before depending on it.

listContents returns nothing (FTP)

ftp_rawlist formats vary. Some servers require ftp_pasv(..., true) (already the default) or return different column counts. Consider a custom FTP adapter for non-standard servers.

Token expiry / 401s

Both token providers refresh 5 minutes early. If you see 401s, check:

  • Clock skew on your server.
  • scope correctness.
  • That the provided client is actually passing the Authorization header (it does unless you already supplied one).

Cannot make file writable

The process user lacks chmod rights (common on mounted shares). Fix ownership or run with sufficient privileges.


API Reference

FileSystemManager

Method Signature Notes
__construct (array $config = []) Expects default + disks.
disk (?string $name = null): FileSystem Cached by name; throws MachinjiriException on bad config.

FileSystem (facade)

Delegates every interface method 1:1 to the wrapped adapter. Additional:

Method Returns
getAdapter() FileSystemContract

FileSystemContract (interface)

See The FileSystem API.

LocalAdapter

Implements FileSystem plus:

Method Signature
append (string $path, string $contents, array $config = []): bool
appendStream (string $path, $resource, array $config = []): bool

FtpAdapter

Implements FileSystem. Constructor: (array $config).

Config keys: host, port, username, password, root, ssl, passive, timeout.

CloudAdapter

Implements FileSystem. Constructor: (CloudClientInterface $client, string $root = '', ?object $cache = null, ?object $logger = null).

Setters: setCache, setLogger.

Constants: VISIBILITY_PUBLIC, VISIBILITY_PRIVATE, CONFLICT_REPLACE, CONFLICT_RENAME, CONFLICT_FAIL.

GoogleDrive

Constructor: (CloudClientInterface $client, string $root = '').

  • Multipart upload for strings, resumable for streams.
  • Supports conflict config.
  • getVisibility inspects permissions.

OneDrive

Constructor: (CloudClientInterface $client, string $driveId, string $root = '').

  • Simple PUT ≤ 4 MB, upload session > 4 MB.
  • Graph conflictBehavior for conflict config.
  • copy is asynchronous (202).

HttpCloudClient

Method Signature
__construct (HttpRequest $http, array $defaultHeaders = [])
setLogger (object $logger): self
setTokenProvider (?TokenProviderInterface $provider): self
withRateLimit (float $requestsPerSecond, int $burst = 10): self
send (string $method, string $uri, array $options = []): CloudResponse
stream (string $method, string $uri, array $options = []): resource

$options keys: headers (assoc), query (assoc), body (array or string).

CloudResponse

Method Returns
status() int
headers() array
header($name) ?string
body() string
ok() bool (2xx)
json() array

TokenProviderInterface

public function getToken(): array; // ['token' => '...', 'expires_at' => 1234567890]

Clone this wiki locally