Skip to content

omnisense/omnidrive

Omnidrive: one contract for reading and writing files wherever they are kept - a directory of this machine, an S3 bucket, an SFTP (SSH) or FTP server - by the name the application gives each storage; Flysystem is the engine behind it, never in its signatures.

Floor
Family
Version
1.x
Conformance suite
runs, CI green
Verified for real
Verified in part
Licence
MIT
composer require omnisense/omnidrive:1.x-dev

Omnidrive is one PHP contract for reading and writing files wherever they are kept - a directory of this machine, an S3 bucket, an SFTP, FTP or WebDAV server, an Azure container, a Dropbox or Google Drive folder - by the name the application gives each storage. The application type-hints Omnidrive\Drive (one storage) and Omnidrive\Drives (the storages by name), and nothing else:

$uploads = $drives->get('uploads');

$uploads->write('avatars/1.png', $bytes, ['visibility' => 'public']);
$uploads->read('avatars/1.png');                                   // the bytes
$uploads->publicUrl('avatars/1.png');                              // /uploads/avatars/1.png
$drives->get('archive')->temporaryUrl('2026/report.pdf', new DateTimeImmutable('+1 hour'));
foreach ($drives->get('archive')->list('2026', deep: true) as $entry) {
    echo $entry->path, ' ', $entry->size, "\n";
}
$uploads->capabilities();                                          // local? public addresses? signed ones?

league/flysystem 3 is the engine: every drive is an Omnidrive\Flysystem\FlysystemDrive over a Flysystem operator, and each provider package maps its options onto an existing Flysystem adapter - none is rewritten. Flysystem appears in no signature of the contract, and its exceptions become the contract's (DriveException, FileNotFoundException), the engine's kept as getPrevious(). The core requires league/flysystem (^3.0) and psr/container, and nothing of a framework. A framework that already has its storages gives them as drives through omnibridge, rather than configuring them twice (Hosts).

When to use it

  • An application whose files may move from a local directory to a bucket or a server without its code changing: the configuration names the storage, the code reads and writes a Drive.
  • Code shared across hosts (a Symfony site, its Laravel twin, a worker): each host gives its own storages as Drives, by the same names.
  • Several storages at once - public uploads on a CDN-fronted bucket, private documents with signed links, backups on an SFTP server - each asked what it can do (capabilities()) before it is.

It is not a synchronisation tool, a file manager or a CDN; it keeps no index of its own, and a drive reaches its server only when an operation asks it to.

Providers

Eight packages. The capabilities are what each drive's capabilities() answers, probed in the harness on 2026-10-25 with its required options; "with X" means only once option X is set. "Visibility" is whether the drive keeps a visibility per file.

Package Factory name Required options local publicUrls temporaryUrls Visibility Engine
omnidrive/local local root yes with public_url - yes (Unix modes) league/flysystem-local
omnidrive/s3 s3 bucket - yes (the bucket's address, or public_url) yes (presigned) the object's ACL, where the bucket keeps them league/flysystem-async-aws-s3
omnidrive/sftp sftp host, username - with public_url - yes (Unix modes) league/flysystem-sftp-v3
omnidrive/ftp ftp host, username, password - with public_url - yes (Unix modes) league/flysystem-ftp
omnidrive/webdav webdav base_uri - yes (the file's WebDAV address, or public_url) - - league/flysystem-webdav
omnidrive/azure azure container - with public_container or public_url yes (read-only SAS) - azure-oss/storage-blob-flysystem
omnidrive/dropbox dropbox refresh_token (with app_key, app_secret) or access_token - with public_url yes (Dropbox's link, four hours at most) - spatie/flysystem-dropbox
omnidrive/gdrive gdrive refresh_token (with client_id, client_secret) or service_account - with public_url - yes (shared with anyone who has the link) masbug/flysystem-google-drive-ext

The core adds a drive in memory for tests, Omnidrive\Testing\InMemoryDriveFactory (memory, league/flysystem-memory): no local, public addresses with public_url, a visibility per file. Not provided: OneDrive (the README says why). Each provider's own manual lists its options, credentials and behaviour.

How this manual is organised

  • Installation: requirements, a first script in plain PHP, other storages.
  • Hosts: the storages of Symfony (flysystem-bundle) and Laravel (config/filesystems.php) as drives, through omnibridge.
  • Drives: the contract method by method, Entry, Capabilities, Visibility, factories, Config, the registry, FlysystemDrive.
  • Usage: files (read, write, stream, list, move, metadata) and addresses and visibility (public, temporary, local paths).
  • Configuration reference: every option of every factory.
  • Errors: the exceptions, when each is thrown, what to catch.
  • Testing: a drive in memory, the conformance suite test by test.
  • The Docker harness: every provider and a server of each kind.
  • Writing a provider.
  • Verified, and not.

Propose a change docs/index.md

Installation

composer require omnisense/omnidrive omnidrive/local

Requirements

  • PHP 8.2 or later.
  • omnisense/omnidrive requires league/flysystem (^3.0) and psr/container (^1.1 or ^2.0), nothing else: no framework, no HTTP client. Its test drive needs league/flysystem-memory (suggested), its conformance suite phpunit/phpunit.
  • Each provider requires its Flysystem adapter and what that adapter needs: omnidrive/ftp the ftp extension, omnidrive/s3 AsyncAws (async-aws/simple-s3, which calls S3 through symfony/http-client), omnidrive/webdav sabre/dav (cURL), omnidrive/dropbox Guzzle, omnidrive/gdrive Google's google/apiclient and Guzzle. Their manuals list them.

A first script

A whole script, in plain PHP - no framework, no container:

<?php

require __DIR__.'/vendor/autoload.php';

use Omnidrive\Exception\FileNotFoundException;
use Omnidrive\Local\LocalDriveFactory;
use Omnidrive\Registry;
use Omnidrive\Visibility;

$drives = new Registry([new LocalDriveFactory()], [
    'uploads' => ['factory' => 'local', 'options' => ['root' => __DIR__.'/var/uploads', 'public_url' => '/uploads']],
]);
$uploads = $drives->get('uploads');

$uploads->write('notes/hello.txt', 'Hello.', ['visibility' => Visibility::Private]);
echo $uploads->read('notes/hello.txt'), "\n";                       // Hello.
echo $uploads->mimeType('notes/hello.txt'), "\n";                   // text/plain
echo $uploads->publicUrl('notes/hello.txt'), "\n";                  // /uploads/notes/hello.txt
echo $uploads->localPath('notes/hello.txt'), "\n";                  // .../var/uploads/notes/hello.txt

foreach ($uploads->list('notes') as $entry) {
    echo $entry->path, ' ', $entry->size, " bytes\n";               // notes/hello.txt 6 bytes
}

try {
    $uploads->read('notes/missing.txt');
} catch (FileNotFoundException $e) {
    echo $e->getMessage(), "\n";                                    // The "uploads" drive could not read "notes/missing.txt": ...
}

$uploads->deleteDirectory('notes');
$ php first.php
Hello.
text/plain
/uploads/notes/hello.txt
/scratch/var/uploads/notes/hello.txt
notes/hello.txt 6 bytes
The "uploads" drive could not read "notes/missing.txt": Unable to read file from location: notes/missing.txt. file_get_contents(/scratch/var/uploads/notes/missing.txt): Failed to open stream: No such file or directory

(run on 2026-10-25 in the harness, PHP 8.4, the script in /scratch)

The pieces: a factory per provider package (LocalDriveFactory), the registry built from the factories and each drive's options by name, and the drive it gives. The message of a DriveException names the drive, the operation and the path, then what the engine was told (here PHP's own warning). Nothing is created before the first write: the local drive makes its root when it is built (unless lazy_root), the remote ones connect on their first operation.

Other storages

Another storage is another provider and another entry; the code that reads and writes does not change:

composer require omnidrive/s3 omnidrive/sftp omnidrive/ftp
$drives = new Registry([new LocalDriveFactory(), new S3DriveFactory(), new SftpDriveFactory(), new FtpDriveFactory()], [
    'uploads' => ['factory' => 's3', 'options' => ['bucket' => 'uploads', 'endpoint' => 'http://minio:9000', 'path_style' => true, 'key' => getenv('S3_KEY'), 'secret' => getenv('S3_SECRET')]],
    'backup' => ['factory' => 'sftp', 'options' => ['host' => 'backup.example.org', 'username' => 'site', 'private_key' => '/run/secrets/id_ed25519', 'root' => '/srv/backup']],
    'legacy' => ['factory' => 'ftp', 'options' => ['host' => 'ftp.example.org', 'username' => 'site', 'password' => getenv('FTP_PASSWORD'), 'root' => '/www']],
]);

A drive connects on its first operation, not when it is built. Options come as configuration and environment variables give them: "true", "0644", "a,b" are read as a boolean, a mode, a list.

Each drive's options are in Configuration and in its provider's manual.

In a framework

In a framework, the drives are those the framework already has: hosts. omnibridge/symfony gives the storages of league/flysystem-bundle as Omnidrive\Drives, omnibridge/laravel the disks of config/filesystems.php; neither needs an omnidrive configuration of its own.

Propose a change docs/installation.md

Hosts

A framework already configures its storages. Omnidrive does not configure them a second time: the host gives them as drives, and the application reads them through Omnidrive\Drives. One owner per piece - the framework's configuration names the storages, the bridge turns them into drives, omnidrive is how they are used.

Omnidrive\Flysystem\OperatorDrives is the piece both hosts use: Drives over a PSR-11 container of Flysystem operators, each wrapped once in a FlysystemDrive - a local adapter's root read from it (localPath()), its public and temporary addresses as its configuration and its adapter give them.

Symfony

omnibridge/symfony registers Omnidrive\Drives (and Omnidrive\Flysystem\OperatorDrives) over a service locator of the services tagged flysystem.storage by their storage name: the storages of league/flysystem-bundle's configuration, by the names config/packages/flysystem.yaml gives them.

# config/packages/flysystem.yaml - unchanged
flysystem:
    storages:
        local.uploads: { local: { directory: '%kernel.project_dir%/var/storage/uploads' } }
        s3.uploads: { asyncaws: { client: 'async_aws.s3.minio', bucket: 'site', prefix: 'uploads' } }
public function __construct(private readonly Drives $drives) {}

$this->drives->get('s3.uploads')->write('avatars/1.png', $bytes);

What omnibridge/symfony registers (config/services.php), where Omnidrive\Flysystem\OperatorDrives exists:

Service
Omnidrive\Flysystem\OperatorDrives over tagged_locator('flysystem.storage', 'storage'): each service tagged flysystem.storage, by its tag's storage attribute
Omnidrive\Drives (public alias) the same object

Nothing to configure: no omnidrive.yaml exists. names() lists the locator's storages, has() asks the locator, get() wraps the operator once in a FlysystemDrive named by the storage. A storage that is not a Flysystem operator is an InvalidConfigException; an unknown name too (No "x" drive; the host has: local.uploads, s3.uploads.).

What a drive over a host's operator can do is read from the operator (Flysystem\Introspection): local when its adapter is league/flysystem-local's (its root is read from the adapter, for localPath()), publicUrls when the operator has a public URL generator, a public_url in its configuration or an adapter that generates them, temporaryUrls when it has a temporary URL generator or an adapter that signs. What cannot be read is reported as not there, never guessed. flysystem-bundle's own options (public_url, visibility, directory_visibility) are the storage's; omnidrive's per-drive options (permissions, retain_visibility) do not apply to a host's storages: set their equivalents in the bundle's configuration.

The application's code is the same as in plain PHP: type-hint Drives (or a Drive built with a factory service: (new Definition(Drive::class))->setFactory([new Reference(Drives::class), 'get']), as omnibridge's omnimap wiring does for its cache). omnibridge/symfony's Tests/DrivesTest.php checks it: the tagged storage memory.uploads is the drive of that name, and a file written through the drive is in the host's own storage.

Laravel

omnibridge/laravel binds Omnidrive\Drives over the disks of config/filesystems.php: a drive is the disk of that name, or the disk omnibridge.storages maps a name to ('local.uploads' => 'public'), each disk's own Flysystem operator.

// config/omnibridge.php - only the names; the disks stay in config/filesystems.php
return [
    'storages' => [
        'local.uploads' => 'public',      // omnibase's (or the application's) storage name => Laravel's disk
        's3.private' => 's3',
    ],
];
use Omnidrive\Drives;

public function __construct(private readonly Drives $drives) {}

$this->drives->get('local.uploads')->write('avatars/1.png', $bytes);   // on the "public" disk

Omnibridge\Laravel\OmnibridgeServiceProvider binds, where Omnidrive\Flysystem\OperatorDrives exists, the singleton OperatorDrives over Omnibridge\Laravel\Media\DiskStorages and aliases Omnidrive\Drives to it. DiskStorages is a PSR-11 container of operators: get($name) is Storage::disk($disk)->getDriver(), the disk being omnibridge.storages[$name] or the name itself; has($name) is whether config('filesystems.disks.<disk>') exists; its names are those omnibridge.storages maps, then every disk's. A local disk's drive is local (its root read from the adapter), a bucket's is not. omnibridge/laravel's Tests/DrivesTest.php checks it: the names in that order, a file written through local.uploads on the disk behind it, its local path, and omnibase's Base\Service\Flysystem reading the same Drives object.

Another host, or none

A host of one's own gives its storages the same way: new OperatorDrives($container) over any PSR-11 container of Flysystem operators, with the list of names when the container cannot list them (new OperatorDrives($container, ['uploads', 'archive']); without a list, names() asks getProvidedServices() when the container has it, else answers []). With no framework at all, the Registry is Drives built from factories (Installation).

omnisense/omnibase

omnibase's Base\Service\Flysystem (its storages local.cache, local.uploads, local.wysiwyg...) reads them through Omnidrive\Drives since omnibase 4.0: whichever host it runs on, its files are those drives. Its FlysystemInterface is kept, deprecated, for one release; new code type-hints Omnidrive\Drives.

Propose a change docs/hosts.md

Drives

The contract

Omnidrive\Drive is one storage; Omnidrive\Drives the storages by name (get, has, names). Registry is Drives built from factories and options; Flysystem\OperatorDrives is Drives over the Flysystem operators a host already has.

Paths are relative to the drive's root and / separated; a leading / is the same path. A write replaces the file and makes the directories it needs. A missing file or directory deleted is no error; a missing file read, measured, moved or copied is a FileNotFoundException.

Exception When
DriveException an operation failed: $operation (read, write, delete, move, copy, list, exists, directory, metadata, checksum, visibility, public_url, temporary_url), $drive, $path; the engine's exception as getPrevious()
FileNotFoundException (a DriveException) the file is not there
NotSupportedException the drive does not do that - a public address without public_url on SFTP, a temporary one where the provider signs none (S3, Azure and Dropbox sign them)
InvalidConfigException an unknown drive or factory, a required option missing

All implement Omnidrive\Exception\OmnidriveException.

capabilities() says beforehand what the drive can do besides reading and writing:

local S3 SFTP FTP WebDAV Azure Dropbox Google Drive memory
local (localPath()) yes - - - - - - - -
publicUrls with public_url yes (the bucket's address, or public_url) with public_url with public_url yes (the file's WebDAV address, or public_url) with public_container or public_url with public_url with public_url with public_url
temporaryUrls - yes (signed) - - - yes (a read-only SAS) yes (Dropbox's link, four hours at most) - -
a visibility per file yes the object's ACL, where the bucket keeps them yes yes - - - yes (shared with anyone who has the link) yes

A provider whose adapter's own public address would be a credential - Azure's, for a private container, is a SAS valid a thousand years - passes false as drive()'s $adapterPublicUrls: the drive then gives a public address only with public_url, and says so in its capabilities.

Options every drive takes

Option
public_url the prefix files are served from (/uploads, https://cdn.example.org), or a list of them - Flysystem shards the paths over it
visibility what a file is written with when the write does not say (public, private; Flysystem's default otherwise)
directory_visibility the same for a directory
retain_visibility whether a copy keeps its source's visibility (true); false for a server that cannot report it
permissions local, SFTP and FTP: the Unix mode of each visibility, {file: {public: 0644, private: 0600}, dir: {public: 0755, private: 0700}}, octal strings accepted

Each provider's manual lists its own; Configuration lists them all.

Drive, method by method

Omnidrive\Drive is an interface; every drive the providers and the hosts give is an Omnidrive\Flysystem\FlysystemDrive. Paths below are relative to the drive's root.

Method Returns
name(): string the name the drive is configured with (the registry's key, the host's storage name)
capabilities(): Capabilities what it can do besides reading and writing
read(string $path): string the contents FileNotFoundException when the file is not there
readStream(string $path) a resource for large files; the caller closes it
write(string $path, string $contents, array $options = []): void replaces the file, makes missing directories; options visibility (a Visibility or public/private), directory_visibility, and whatever the engine takes (mimetype, an S3 ContentType...)
writeStream(string $path, $stream, array $options = []): void the same from a stream; not a resource: DriveException. An engine may close the stream once written (Azure's, Dropbox's HTTP clients): check is_resource() before closing it
exists(string $path): bool whether a file is there; a directory is not a file
directoryExists(string $path): bool
delete(string $path): void a file that is not there is no error
deleteDirectory(string $path): void the directory and everything under it; one that is not there is no error
createDirectory(string $path, array $options = []): void option directory_visibility; on a flat namespace the adapter decides what a directory is: Azure's makes nothing, a directory exists while it holds a blob
list(string $path = '', bool $deep = false): iterable<Entry> lazily files and directories, in the storage's order; $deep goes down every directory
move(string $from, string $to): void, copy(string $from, string $to): void within the drive; a missing source is a FileNotFoundException
metadata(string $path): Entry size, last change, visibility and media type at once (four calls to a remote drive); a visibility or type the drive cannot read is null
size(), mimeType(), lastModified() int, string, DateTimeImmutable (UTC) one call each
checksum(string $path, string $algorithm = 'md5'): string hex digest any algorithm hash() knows when the engine reads and hashes the file; an engine without checksums: NotSupportedException
visibility(string $path): Visibility, setVisibility(string $path, Visibility $v): void a drive that keeps none: DriveException
publicUrl(string $path): string where anyone fetches the file, whether it is there or not; none: NotSupportedException
temporaryUrl(string $path, \DateTimeInterface $expiresAt): string a signed address serving the file until $expiresAt; none: NotSupportedException
localPath(string $path): ?string the file's path on this machine, whether it is there or not; null for a remote drive

FlysystemDrive also has operator(): FilesystemOperator, the engine, for a host that hands it to a library speaking Flysystem itself, and FlysystemDrive::of(string $name, FilesystemOperator $operator), a drive over a host's operator with its local root and capabilities read from it.

Entry

What a listing or metadata() gives of a file or a directory: what the drive told, null where it told nothing (a listing usually gives no media type, a bucket no directory's date).

Property / method Type
path string relative to the drive's root, without a leading /
directory bool false for a file
size ?int bytes; null for a directory
lastModified ?\DateTimeImmutable UTC
visibility ?Visibility
mimeType ?string
isFile(): bool !directory
basename(): string the last segment of the path
toArray(): array the properties, lastModified as ATOM, visibility as its string

Capabilities

Omnidrive\Model\Capabilities (final readonly): local (localPath() gives a file of this machine), publicUrls (publicUrl() answers), temporaryUrls (temporaryUrl() answers), all false by default; toArray(). A provider's drive is given them by its factory (FlysystemDriveFactory::drive()): local when it passes a local root, publicUrls when a public_url is set or the adapter generates public URLs (and the factory lets it), temporaryUrls when the adapter signs. The index lists them per provider.

Visibility

Omnidrive\Visibility, a string-backed enum: Public (public: anyone may read - a public object, a file the web server serves, mode 0644) and Private (private: the drive's own user, 0600, a private object). Visibility::of(Visibility|string $v) accepts a case or its name in any case, trimmed (an unknown name is PHP's ValueError).

Drives and the registry

Omnidrive\Drives is the storages by name: get(string $name): Drive (an unknown name is an InvalidConfigException), has(string $name): bool, names(): list<string> (as far as they are known). Two implementations:

  • Omnidrive\Registry, built from factories and options:

    Method
    new Registry(iterable $factories, array $config) factories keyed by getName(); $config is name => ['factory' => ..., 'options' => [...]]
    get(string $name): Drive built on the first call, with $name as the drive's name, and kept
    has(), names() from the configuration, in its order
    options(string $name): array the options a drive is configured with
    create(string $name, array $overrides = []): Drive built afresh with $overrides over its options (array_replace); not kept
    all(): array<string, Drive> every drive, built
    factories(): list<string> the factories installed

    No "x" drive; configured: uploads, archive. and No "s3" factory for the "archive" drive; installed: local. are InvalidConfigExceptions.

  • Omnidrive\Flysystem\OperatorDrives, over a host's operators (Hosts).

Factories and Config

Omnidrive\DriveFactoryInterface: getName(): string (the factory a configuration names) and create(array $options = [], ?string $name = null): Drive ($name is the drive's name, the factory's when null). Omnidrive\DriveFactory implements both: createConfig($options) makes a Config of the options and calls the factory's populate(), which sets its name (omnidrive.factory_name), its title (omnidrive.factory_title), its required options (omnidrive.required_options) and the defaults of the others; create() checks the required ones (missing, '' or []: The "s3" drive needs: bucket.) and calls build(Config $c, string $name). Nothing connects there: a drive reaches its server on its first operation (the Google Drive provider's root given as a path of names is the one exception: the folder is looked up when the drive is built).

Omnidrive\Flysystem\FlysystemDriveFactory is the providers' base: drive($name, $adapter, $c, ?string $localRoot = null, bool $adapterPublicUrls = true) wraps an adapter in a Filesystem with the options every drive takes and returns the FlysystemDrive; unixVisibility($c) builds Flysystem's PortableVisibilityConverter from permissions and directory_visibility.

Omnidrive\Config reads options as configuration and environment variables give them:

Method
defaults(array $defaults): self sets each key not set yet
validateNotEmpty(list<string> $keys): void the required check above
get(string $key, mixed $default = null)
string(string $key): ?string a scalar as a string, null when unset or ''
int(string $key, int $default = 0): int a numeric value
octal(string $key, int $default): int an integer as it is (0644), or a string of digits read as octal ("0644", "644")
bool(string $key, bool $default = false): bool $default when unset or ''; true for true and 1, true, yes, on
list(string $key): list<string> an array, or a comma-separated string, split and trimmed ("a, b" is ['a', 'b'])

Propose a change docs/drives.md

Files

Reading, writing, streaming, listing, moving and measuring files: the same calls on every drive. The script below runs on a local drive; put another drive's options in the registry and it runs unchanged (what a drive cannot do is in Addresses and visibility and in each provider's manual).

<?php

require __DIR__.'/vendor/autoload.php';

use Omnidrive\Exception\FileNotFoundException;
use Omnidrive\Local\LocalDriveFactory;
use Omnidrive\Registry;
use Omnidrive\Visibility;

$drives = new Registry([new LocalDriveFactory()], [
    'documents' => ['factory' => 'local', 'options' => ['root' => sys_get_temp_dir().'/documents', 'visibility' => 'private']],
]);
$documents = $drives->get('documents');

// Strings and streams; a write makes the directories it needs and replaces the file.
$documents->write('2026/invoices/F-0001.txt', "Invoice F-0001\nTotal: 120.00 EUR\n");
$documents->write('2026/invoices/F-0001.txt', "Invoice F-0001\nTotal: 125.00 EUR\n");
$in = fopen('php://temp', 'w+b');
fwrite($in, str_repeat('x', 200_000));
rewind($in);
$documents->writeStream('2026/scans/F-0001.bin', $in, ['visibility' => Visibility::Public]);
fclose($in);
$out = $documents->readStream('2026/scans/F-0001.bin');
echo strlen(stream_get_contents($out)), " bytes streamed back\n";
fclose($out);

// Listings, lazily: shallow, then deep.
foreach ($documents->list('2026') as $entry) {
    echo $entry->directory ? 'dir  ' : 'file ', $entry->path, "\n";
}
foreach ($documents->list('2026', deep: true) as $entry) {
    if ($entry->isFile()) {
        printf("%-28s %7d %s %s\n", $entry->path, $entry->size, $entry->visibility?->value, $entry->lastModified?->format('Y-m-d'));
    }
}

// Copy, move, directories.
$documents->copy('2026/invoices/F-0001.txt', '2026/archive/F-0001.txt');
$documents->move('2026/scans/F-0001.bin', '2026/archive/F-0001.bin');
$documents->createDirectory('2027');
var_dump($documents->exists('2026/scans/F-0001.bin'), $documents->directoryExists('2027'), $documents->exists('2027'));

// Metadata, one call each, or all at once.
$path = '2026/archive/F-0001.txt';
echo $documents->size($path), ' ', $documents->mimeType($path), ' ', $documents->visibility($path)->value, "\n";
echo $documents->checksum($path), ' ', $documents->checksum($path, 'sha1'), "\n";
echo json_encode($documents->metadata($path)->toArray()), "\n";
$documents->setVisibility($path, Visibility::Public);
echo decoct(fileperms($documents->localPath($path)) & 0777), "\n";

try {
    $documents->size('2026/missing.txt');
} catch (FileNotFoundException $e) {
    echo $e->operation, ' | ', $e->drive, ' | ', $e->path, "\n";
}

$documents->deleteDirectory('2026');
$documents->deleteDirectory('2027');
$documents->delete('2026/never-there.txt');   // no error
echo count([...$documents->list('')]), " entries left\n";
200000 bytes streamed back
dir  2026/scans
dir  2026/invoices
2026/scans/F-0001.bin         200000 public 2026-10-25
2026/invoices/F-0001.txt          33 private 2026-10-25
bool(false)
bool(true)
bool(false)
33 text/plain private
46770a01e06c03739d3e2322d1ad2f8c 309ff63a492625f6faafe49cef8c93221a5f70cf
{"path":"2026\/archive\/F-0001.txt","directory":false,"size":33,"lastModified":"2026-10-25T23:51:14+00:00","visibility":"private","mimeType":"text\/plain"}
644
metadata | documents | 2026/missing.txt
0 entries left

(run on 2026-10-25 in the harness, PHP 8.4)

Writing

write($path, $contents, $options) and writeStream($path, $stream, $options) replace the file when it is there and make the directories the path needs. The options:

Option
visibility Visibility::Public, Visibility::Private, or public / private: the file's; the drive's visibility option when not given (the drive above writes private unless told)
directory_visibility the same, for the directories the write makes
the engine's own passed to Flysystem's Config as they are: mimetype (most adapters), S3's ContentType, CacheControl...

A stream is read from its current position; rewind it first. Some engines close it once sent (Azure's and Dropbox's HTTP clients): close it only when is_resource() is still true.

Reading

read() gives the whole contents as a string, readStream() a resource the caller closes - for large files. A file that is not there is a FileNotFoundException, whose operation, drive and path say what was asked (Errors). exists() asks for a file (a directory is not one), directoryExists() for a directory.

Listing

list($path, $deep) yields Entry objects lazily: each is fetched as the loop asks, so a large bucket is not read in one go - and a failure may come in the middle of the loop, as a DriveException of operation list. Entries come in the storage's order (sort them yourself), paths relative to the drive's root. A shallow listing gives the directory's files and directories; deep: true every file and directory below. What an entry carries depends on the drive: a local directory's listing has sizes, dates and visibilities, a bucket's no directory dates, most no media type.

Moving, copying, directories

copy($from, $to) and move($from, $to) work within one drive; between two drives, read from one and write to the other (readStream() then writeStream()). A copy keeps its source's visibility unless the drive's retain_visibility is false (needed for a server that cannot report a file's, as vsftpd for files below a subdirectory). createDirectory() makes a directory with its parents; deleteDirectory() removes one with everything under it. Deleting a file or a directory that is not there is no error.

Metadata and checksums

size(), mimeType(), lastModified() (a DateTimeImmutable in UTC) and visibility() are one call each; metadata() asks all four and returns an Entry (a visibility or a type the drive cannot read is null there, where the single call would throw). checksum($path, $algorithm) is a hex digest, MD5 by default; the engine reads the file and hashes it with any algorithm PHP's hash() knows, and the adapters give a checksum of their own under a name of theirs: etag on S3 (the object's ETag), dropbox on Dropbox (its content_hash).

Paths

A path is relative to the drive's root and /-separated; a leading / is the same path. Flysystem normalises it (whitespace, ./) and refuses one that climbs out of the root (..), as a DriveException whose previous exception is Flysystem's PathTraversalDetected.

Propose a change docs/usage-files.md

Addresses and visibility

Besides reading and writing, a drive may give three things, each said beforehand by capabilities(): a public address (publicUrls), a signed address that expires (temporaryUrls), and the file of this machine behind a path (local). The script below runs in the harness against its MinIO (S3) and OpenSSH (SFTP) servers.

<?php

require __DIR__.'/vendor/autoload.php';

use AsyncAws\SimpleS3\SimpleS3Client;
use Omnidrive\Exception\NotSupportedException;
use Omnidrive\Local\LocalDriveFactory;
use Omnidrive\Registry;
use Omnidrive\S3\S3DriveFactory;
use Omnidrive\Sftp\SftpDriveFactory;

$minio = ['region' => 'us-east-1', 'endpoint' => 'http://minio:9000', 'path_style' => true, 'key' => 'omnidrive', 'secret' => 'omnidrive-harness'];
$drives = new Registry([new LocalDriveFactory(), new S3DriveFactory(), new SftpDriveFactory()], [
    'public' => ['factory' => 'local', 'options' => ['root' => sys_get_temp_dir().'/public/media', 'public_url' => 'https://cdn.example.org/media']],
    'private' => ['factory' => 's3', 'options' => ['bucket' => 'invoices', 'prefix' => 'site'] + $minio],
    'backup' => ['factory' => 'sftp', 'options' => ['host' => 'sftp', 'username' => 'omnidrive', 'password' => 'omnidrive-harness', 'root' => '/upload']],
]);
// The harness's MinIO starts empty.
try {
    (new SimpleS3Client(['region' => 'us-east-1', 'endpoint' => 'http://minio:9000', 'pathStyleEndpoint' => 'true', 'accessKeyId' => 'omnidrive', 'accessKeySecret' => 'omnidrive-harness']))->createBucket(['Bucket' => 'invoices'])->resolve();
} catch (Throwable) {
}

foreach ($drives->names() as $name) {
    echo $name, ' ', json_encode($drives->get($name)->capabilities()->toArray()), "\n";
}

$drives->get('public')->write('logo.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>');
echo $drives->get('public')->publicUrl('logo.svg'), "\n";
echo $drives->get('public')->localPath('logo.svg'), "\n";

$invoices = $drives->get('private');
$invoices->write('2026/F-0001.pdf', '%PDF-1.4 ...');
echo $invoices->publicUrl('2026/F-0001.pdf'), "\n";
$link = $invoices->temporaryUrl('2026/F-0001.pdf', new DateTimeImmutable('+10 minutes'));
echo strtok($link, '?'), "?...\n";
echo file_get_contents($link), "\n";                                  // served while the signature lasts
echo @file_get_contents(strtok($link, '?')) === false ? "the plain address is refused\n" : "served\n";
var_dump($invoices->localPath('2026/F-0001.pdf'));

try {
    $drives->get('backup')->temporaryUrl('dump.sql', new DateTimeImmutable('+10 minutes'));
} catch (NotSupportedException $e) {
    echo $e->getMessage(), "\n";
}
$invoices->deleteDirectory('2026');
$drives->get('public')->delete('logo.svg');
public {"local":true,"publicUrls":true,"temporaryUrls":false}
private {"local":false,"publicUrls":true,"temporaryUrls":true}
backup {"local":false,"publicUrls":false,"temporaryUrls":false}
https://cdn.example.org/media/logo.svg
/tmp/public/media/logo.svg
http://minio:9000/invoices/site/2026/F-0001.pdf
http://minio:9000/invoices/site/2026/F-0001.pdf?...
%PDF-1.4 ...
the plain address is refused
NULL
The "backup" drive does not give temporary addresses: its adapter signs none.

(run on 2026-10-25 in the harness, the S3 drive on MinIO with path-style addresses, the SFTP one on atmoz/sftp)

Public addresses

publicUrl($path) is where anyone fetches the file: the drive's public_url option followed by the path, or the address the adapter gives (an S3 bucket's own, a WebDAV file's), whether the file is there or not and whether it is public or not - the address is not a permission. With a list of prefixes (public_url: [https://a.cdn.example.org, https://b.cdn.example.org]) Flysystem shards the paths over them, a path always on the same one. A drive with neither throws NotSupportedException (The "backup" drive does not give public addresses: no "public_url" configured and its adapter gives none.).

The Azure drive is the exception to "the adapter's own": for a private container that address is a SAS valid a thousand years, so the drive gives a public address only with public_container (the container is readable anonymously) or public_url.

Temporary addresses

temporaryUrl($path, $expiresAt) signs an address that serves the file until $expiresAt, to whoever holds it: S3's presigned GET (the lifetime asked, as the service accepts it), Azure's read-only SAS (signed with the account key), Dropbox's temporary link (four hours at most: a later $expiresAt is a DriveException). The other drives throw NotSupportedException. Keep a temporary address out of logs: it is a credential until it expires.

Local paths

localPath($path) is the file's path on this machine for a local drive (whether the file is there or not), null for every other: hand it to a library that only opens files (an image processor, ZipArchive), and fall back to readStream() and a temporary file when it is null.

Visibility per drive

A visibility is Visibility::Public or Visibility::Private. What it means depends on the drive:

Drive Public Private Kept per file
local, SFTP, FTP the file's Unix mode for public (0644, directories 0755) 0600, 0700 yes; the modes are the permissions option
S3 the object's ACL public-read private where the bucket keeps ACLs (a bucket made on AWS since April 2023 refuses them unless its Object Ownership allows them; MinIO answers 501)
Google Drive shared with anyone who has the link, as a reader not shared yes
WebDAV, Azure, Dropbox - - no: a write's visibility is accepted and ignored, visibility() and setVisibility() are a DriveException
memory as given as given yes

The drive's visibility and directory_visibility options are what a write uses when it does not say; setVisibility() changes one file's. metadata() gives visibility: null where the drive keeps none.

Propose a change docs/usage-addresses.md

Configuration reference

Every option below is read from the factories' code at this commit (populate() and what build() reads). Options reach a factory through create($options) or a Registry's options; under a framework the storages are the framework's own and these options do not apply (Hosts). Values may be given as environment variables give them: "true"/"1" for a boolean, "0644" for a mode, "a,b" for a list, "" for "not set".

The registry

new Registry(iterable $factories, array $config);
// $config: ['uploads' => ['factory' => 'local', 'options' => ['root' => '...']], ...]

A later factory of the same name replaces an earlier one. The drive's name is the configuration's key.

Common options

Read by FlysystemDriveFactory::drive() for every provider (and the memory drive):

Option Type Default
public_url string or list none the prefix files are served from (/uploads, https://cdn.example.org), or several (Flysystem shards the paths over them); set, publicUrls is on
visibility public or private the adapter's (Flysystem's: public for files on most adapters; S3: private) what a file is written with when the write does not say
directory_visibility public or private private (local, SFTP, FTP); public (S3) the same for a directory
retain_visibility bool true (Flysystem's) whether a copy keeps its source's visibility; false for a server that cannot report it
permissions map {file: {public: 0644, private: 0600}, dir: {public: 0755, private: 0700}} local, SFTP, FTP only: the Unix mode of each visibility; octal strings accepted, a mode left out keeps its default

local

new LocalDriveFactory().

Option Type Default
root string required the directory behind the drive's paths (made when the drive is built)
lazy_root bool false the root made on the first write instead
lock bool true an exclusive lock (LOCK_EX) while a file is written
links disallow or skip disallow a symbolic link met in a listing is an error, or left out

s3

new S3DriveFactory(?Symfony\Contracts\HttpClient\HttpClientInterface $http = null) (AsyncAws's own client when null).

Option Type Default
bucket string required
region string us-east-1
endpoint string AWS's another S3 service (http://minio:9000, https://s3.fr-par.scw.cloud)
path_style bool false the bucket in the path, not in the host name
key, secret string none: AsyncAws's own chain (environment, ~/.aws, the instance's role)
session_token string none
prefix string '' every path under it

sftp

new SftpDriveFactory().

Option Type Default
host, username string required
password string none
private_key string none a path to the key, or the key itself
passphrase string none the key's
use_agent bool false the SSH agent's keys
port int 22
root string /
host_fingerprint string none the server's key checked (SHA256:... or the MD5 form phpseclib gives)
timeout int 10 seconds
max_tries int 4 connection attempts

ftp

new FtpDriveFactory(); requires ext-ftp.

Option Type Default
host, username, password string required
port int 21
root string /
ssl bool false FTPS (explicit)
passive bool true
ignore_passive_address bool the adapter's ignore the address a server behind NAT gives for passive mode
timeout int 90 seconds
utf8 bool false
binary bool true binary transfers, else ASCII
system_type unix or windows asked of the server
recurse_manually bool true a deep listing walked directory by directory
timestamps_on_unix_listings bool false

webdav

new WebdavDriveFactory().

Option Type Default
base_uri string required the collection behind the drive's paths, an absolute address (InvalidConfigException otherwise)
username, password string none sent only with a username
auth basic, digest, ntlm, any basic another value: InvalidConfigException
prefix string '' every path under it
proxy string none
manual_copy, manual_move bool false a server without COPY or MOVE: read and written again

azure

new AzureDriveFactory().

Option Type Default
container string required
connection_string string none the portal's
account_name, account_key string none instead of a connection string (one or the other is required: InvalidConfigException)
endpoint string https://<account>.blob.core.windows.net with an account name
prefix string ''
public_container bool false the container's blobs readable anonymously: their own address is public

dropbox

new DropboxDriveFactory(?GuzzleHttp\ClientInterface $http = null).

Option Type Default
refresh_token string none with app_key and app_secret (else InvalidConfigException)
app_key, app_secret string none
access_token string none instead of a refresh token; one or the other is required
prefix string ''
team_member_id string none
namespace_id string none
chunk_size int Spatie\Dropbox\Client::MAX_CHUNK_SIZE (150 MB) bytes per request of a large upload

gdrive

new GdriveDriveFactory(?GuzzleHttp\ClientInterface $http = null).

Option Type Default
client_id, client_secret, refresh_token string none an OAuth client and an account's refresh token
service_account string or array none instead: a service account's JSON key - its path, its JSON or the array; one or the other is required
subject string none with a service account: the Workspace user it acts as
root string the Drive's root a path of names (looked up when the drive is built), or an ID with display_paths: false
shared_drive_id, shared_folder_id string none
display_paths bool true paths are names; false: file IDs
permanent_delete bool false false: a deleted file goes to the bin

memory

new InMemoryDriveFactory() (Omnidrive\Testing): no option of its own, the common ones above; requires league/flysystem-memory (InvalidConfigException naming it otherwise).

Propose a change docs/configuration.md

Errors

Every exception of the family implements Omnidrive\Exception\OmnidriveException (an interface extending \Throwable):

Exception Extends Thrown when
DriveException \RuntimeException an operation failed: the engine's FilesystemException (a server refused, did not answer, a permission, a path climbing out of the root), a writeStream() given something that is not a stream
FileNotFoundException DriveException the file asked for is not there (read, stream, size, type, date, checksum, visibility, move, copy)
NotSupportedException \LogicException the drive does not do that: a public address without one, a temporary address from a drive that signs none, a checksum from an engine that computes none
InvalidConfigException \InvalidArgumentException an unknown drive or factory, a required option missing, an option's value refused (WebDAV's auth, Azure's credentials, Dropbox's or Google's token without its client, a service account's file unreadable)

DriveException keeps what was asked:

Property
operation one of DriveException::OPERATIONS' keys: read, write, delete, move, copy, list, exists, directory, metadata, checksum, visibility, public_url, temporary_url
drive the drive's name
path the path asked (the source, for a move or a copy)
getPrevious() the engine's exception (Flysystem's UnableToReadFile...), itself holding the server's

Its message is The "<drive>" drive could not <operation> "<path>": <reason>, the reason every distinct message of the chain joined with -: what the engine was told by its server.

use Omnidrive\Exception\DriveException;
use Omnidrive\Exception\FileNotFoundException;

try {
    $pdf = $drives->get('invoices')->read("2026/$number.pdf");
} catch (FileNotFoundException) {
    throw new NotFoundHttpException();                     // the application's 404
} catch (DriveException $e) {
    $logger->error($e->getMessage(), ['operation' => $e->operation, 'drive' => $e->drive]);
    throw $e;                                              // the storage is down: a 5xx
}

How a failure becomes "not found"

A failed operation on a file is asked again with exists(): when the file is not there, the failure is a FileNotFoundException, else a DriveException. Whatever makes exists() answer false therefore makes the failure "not found":

On S3, a request refused with HTTP 403 (wrong keys, no permission) is a DriveException, exists() included; only a 404 is a missing file - which AWS answers only to a caller allowed s3:ListBucket (omnidrive/s3's page).

On Dropbox, a refresh token Dropbox refuses fails every operation, exists() included, with a DriveException naming the refusal (omnidrive/dropbox's TokenRefusedException as its previous).

Operations that need no file (a write, a listing, a directory made) are DriveExceptions. exists() and directoryExists() throw a DriveException of operation exists only when the engine itself fails to answer.

What is not an exception

  • Deleting a file or a directory that is not there.
  • metadata() of a file whose visibility or media type the drive cannot read: null in the Entry.
  • localPath() on a remote drive: null.

Outside the family

PHP's ValueError from Visibility::of() with a name other than public or private (a write's visibility option of another value). Flysystem's exceptions never reach the application through Drive, but a host that uses FlysystemDrive::operator() gets Flysystem's own.

Propose a change docs/errors.md

Testing

Testing an application that reads and writes through omnidrive, and the conformance suite every drive passes. Both ship with the core (Omnidrive\Testing, autoloaded with it).

A drive in memory

Omnidrive\Testing\InMemoryDriveFactory (factory name memory) is a drive in this process's memory over league/flysystem-memory: what is written is read back until the process ends, nothing touches a disk or a network. It takes the options every drive takes (public_url, visibility...) and keeps a visibility per file; it gives no local path and no temporary address. Without league/flysystem-memory (composer require --dev league/flysystem-memory) building it is an InvalidConfigException that says so.

Configure the drive the code uses with the memory factory in the tests, under the same name:

<?php

use Omnidrive\Drive;
use Omnidrive\Drives;
use Omnidrive\Registry;
use Omnidrive\Testing\InMemoryDriveFactory;
use PHPUnit\Framework\TestCase;

/** The application's code: it only knows Drives. */
final class AvatarStore
{
    public function __construct(private readonly Drives $drives)
    {
    }

    public function save(int $user, string $png): string
    {
        $uploads = $this->drives->get('uploads');
        $uploads->write("avatars/$user.png", $png, ['visibility' => 'public']);

        return $uploads->publicUrl("avatars/$user.png");
    }
}

final class AvatarStoreTest extends TestCase
{
    public function testAnAvatarIsKeptAndServed(): void
    {
        $drives = new Registry([new InMemoryDriveFactory()], [
            'uploads' => ['factory' => 'memory', 'options' => ['public_url' => '/uploads']],
        ]);

        self::assertSame('/uploads/avatars/7.png', (new AvatarStore($drives))->save(7, "\x89PNG..."));
        self::assertSame("\x89PNG...", $drives->get('uploads')->read('avatars/7.png'));
        self::assertSame('public', $drives->get('uploads')->visibility('avatars/7.png')->value);
    }
}
$ vendor/bin/phpunit --testdox tests/
Avatar Store
 ✔ An avatar is kept and served

Scratch Contract
 ✔ A file is written read and deleted
 ✔ Streams are written and read
 ✔ A file that is not there is not found
 ✔ A directory is listed shallow and deep
 ✔ A file is moved and copied
 ✔ Directories are made and deleted
 ✔ Metadata and checksum are the file
 ✔ Visibility is written and changed
 ✔ Addresses and local paths are what the capabilities say

OK (10 tests, 45 assertions)

(run on 2026-10-25 in the harness, PHPUnit 11.5, with the provider of Writing a provider and its contract test)

For a code path that needs a file of this machine (localPath()), use a local drive on a temporary directory instead (new LocalDriveFactory() with root under sys_get_temp_dir()), and delete it in tearDown(). For a failure, a drive whose engine fails: the memory drive cannot be told to fail; an S3 drive given a MockHttpClient answering 500 can (new S3DriveFactory(new MockHttpClient(...))).

In a Symfony or Laravel test

Under Symfony, configure the storage in the test environment as flysystem-bundle's memory adapter (config/packages/test/flysystem.yaml): Omnidrive\Drives gives it by the same name. Under Laravel, Storage::fake('public') replaces the disk, and the drive mapped to it reads the fake (Hosts). omnibridge/symfony's Tests/DrivesTest.php (a memory.uploads storage) and omnibridge/laravel's Tests/DrivesTest.php (local disks) test the bridges this way; Storage::fake() was not run with omnidrive for this page. Resolve the drive after faking the disk: OperatorDrives keeps a drive once it has given it.

Conformance suite: each test

Omnidrive\Testing\DriveContractTests is a PHPUnit trait: a test case uses it and gives the drive (a provider's, or an application's drive of its own). Each test works under a directory of its own (omnidrive-contract-<random>), deleted after the test, so a shared bucket or server is left as it was. It reaches whatever the drive reaches: the providers run it offline on the local and memory drives, live against the harness's servers (*LiveTest), and against a stand-in API for Dropbox.

Hook Default
drive(): Drive abstract the drive under test; called once per test
keepsVisibility(): bool true false for a drive that keeps no visibility per file (S3 without ACLs, WebDAV, Azure, Dropbox): the visibility test is then skipped, after its write
Test What it checks
testAFileIsWrittenReadAndDeleted a file not there, written, there, read back, 13 bytes, text/plain; written again replaces it; deleted, gone; deleted again, no error
testStreamsAreWrittenAndRead 300 000 random bytes written from a stream and read back as one, their size
testAFileThatIsNotThereIsNotFound read() of a missing file is a FileNotFoundException with operation read, the drive's name and the path; size() too
testADirectoryIsListedShallowAndDeep three files at three depths: the shallow listing is exactly one file and one directory, the deep one holds every file, a listed file has its size and basename
testAFileIsMovedAndCopied a copy leaves the source; a move removes it; moving a missing file is a FileNotFoundException
testDirectoriesAreMadeAndDeleted a directory made with its parent exists and is not a file; deleted with a file in it, both gone
testMetadataAndChecksumAreTheFile metadata() of an HTML file: a file, 31 bytes, text/html, its date within two minutes of the test's clock; its MD5 checksum
testVisibilityIsWrittenAndChanged written Private, read Private; set Public, read Public (skipped when keepsVisibility() is false)
testAddressesAndLocalPathsAreWhatTheCapabilitiesSay publicUrl() and temporaryUrl() hold the file's name when declared, are a NotSupportedException when not; localPath() is an existing file with the contents when local, null otherwise

A provider adds what is particular to its service beside it (Azure overrides the directory test: its namespace is flat; S3 and Azure add a signed address fetched and the plain one refused). Requires phpunit/phpunit in the provider's (or the application's) require-dev.

Propose a change docs/testing.md

The Docker harness

docker/ runs this package with every omnidrive/* provider installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNIDRIVE_PLUGINS=../.. is in docker/.env - and a server of each kind for the drives to reach: MinIO (S3), OpenSSH (SFTP, atmoz/sftp), Pure-FTPd (FTP, built from docker/ftp), Apache's mod_dav (WebDAV, bytemark/webdav) and Azurite (Azure Blob Storage). Their accounts are throwaway ones, in compose.yml (Azurite's is its documented development account). A Nextcloud is there too, not started by default (--profile nextcloud). Dropbox and Google Drive have no stand-in server: their live tests and bare --live reach them only with an account's tokens in .env.

cd docker && cp .env.dist .env
docker compose run --rm omnidrive bare
docker compose run --rm omnidrive bare --live
docker compose run --rm omnidrive test
docker compose down                       # the servers stopped, their network removed
Command
bare plain PHP: the registry built by hand, a drive per factory installed; the memory and local drives used whole, the others built but not reached; what PHP loaded (exit 1 if a framework's class is among it)
bare --live the same, and the S3, SFTP, FTP, WebDAV and Azure drives used whole against the harness's servers - or yours, named in .env; Dropbox and Google Drive with tokens in .env
bare --json the whole report: every step's answer, every class and file loaded
test every package's tests; the live ones (*LiveTest, BareTest's remote case) against the servers
composer ..., php ..., sh as they are, in the harness

"Used whole" is: a file written and read back, its size and type, a 1 MB stream written and read, a deep listing, a copy and a move, its metadata and MD5 checksum, its public address, a temporary one signed and fetched (S3), its local path opened (local), everything deleted. An address is printed without its query string: a signed one's key and signature stay out of the output.

$ docker compose run --rm omnidrive bare --live
s3      s3 (Omnidrive\Flysystem\FlysystemDrive): local NO, public addresses yes, temporary addresses yes
  write          yes
  size_type      [46,"text/plain"]
  stream         yes
  list           ["big.bin","hello.txt"]
  copy_move      yes
  checksum       yes
  public_url     http://minio:9000/omnidrive/bare/bare-3376bb/hello.txt
  temporary_url  {"url":"http://minio:9000/omnidrive/bare/bare-3376bb/hello.txt?...","signed":true,"served":true}
  delete         yes
...
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (...): none

(an excerpt; the run of 2026-10-25 gave the same for S3 and used the memory, local, SFTP, FTP, WebDAV and Azure drives whole too, every step yes; Dropbox and Google Drive were built, not reached: no tokens)

The services

Service Image Account The drive's variables
omnidrive omnisense/omnidrive-harness (docker/Dockerfile: php:8.4-cli, the ftp extension, Composer 2) - mounts the core at /omnidrive/core, the providers' checkouts read-only at /omnidrive/plugins, the volume harness at /harness
minio minio/minio:latest omnidrive / omnidrive-harness (root) OMNIDRIVE_S3_ENDPOINT (http://minio:9000), _REGION (us-east-1), _BUCKET (omnidrive), _KEY, _SECRET, _PATH_STYLE (1)
sftp atmoz/sftp:alpine omnidrive / omnidrive-harness, chrooted, writing under /upload OMNIDRIVE_SFTP_HOST, _PORT, _USERNAME, _PASSWORD, _ROOT (/upload)
ftp omnisense/omnidrive-harness-ftp (docker/ftp/Dockerfile: Pure-FTPd, passive ports 30000-30009) omnidrive / omnidrive-harness, chrooted OMNIDRIVE_FTP_HOST, _PORT, _USERNAME, _PASSWORD, _ROOT
webdav bytemark/webdav:latest (Apache's mod_dav, basic authentication) omnidrive / omnidrive-harness OMNIDRIVE_WEBDAV_URI (http://webdav/), _USERNAME, _PASSWORD
azurite mcr.microsoft.com/azure-storage/azurite:latest (blob service only) Azurite's documented development account devstoreaccount1 OMNIDRIVE_AZURE_CONNECTION_STRING, _CONTAINER (omnidrive)
nextcloud nextcloud:apache (SQLite), profile nextcloud, not started by default omnidrive / omnidrive-harness (admin) OMNIDRIVE_WEBDAV_URI=http://nextcloud/remote.php/dav/files/omnidrive/

docker/.env.dist lists every variable: point them at servers of your own (a Scaleway bucket, a real SFTP host) and bare --live and the live tests use those instead; each test works under a directory of its own and removes it. Dropbox (OMNIDRIVE_DROPBOX_REFRESH_TOKEN, _APP_KEY, _APP_SECRET or _ACCESS_TOKEN, _PREFIX) and Google Drive (OMNIDRIVE_GDRIVE_CLIENT_ID, _CLIENT_SECRET, _REFRESH_TOKEN or _SERVICE_ACCOUNT, _SHARED_DRIVE_ID, _ROOT) are reached only with an account's tokens there.

How it starts

docker/entrypoint.sh runs docker/harness/setup.php (writes /harness/composer.json and phpunit.xml: the core and each provider of docker/harness/plugins.php as path repositories from their checkouts, or git clones of https://github.com/glitchr-omnisense/omnidrive-<slug>.git; league/flysystem-memory, PHPUnit 11 and symfony/http-client; one test suite per package), installs or updates when composer.json changed, then docker/harness/wait.php waits up to 30 seconds for each server the variables name to accept connections (a server that never answers is reported, not waited for again). bare builds the drives of docker/harness/config/drives.php, one per factory installed: those with a server or an account named are live, the others are built with placeholder options on .invalid hosts and never reached.

Running a script of one's own

The examples of this manual ran this way, the script in a directory mounted at /scratch beside a vendor link to the harness's (ln -s /harness/vendor scratch/vendor), so that require __DIR__.'/vendor/autoload.php' works as in an application:

cd docker
docker compose run --rm -v "$PWD/../scratch:/scratch" omnidrive php /scratch/example.php
docker compose run --rm -v "$PWD/../scratch:/scratch" omnidrive test --testdox /scratch/tests
docker compose down                       # the servers that run started

docker compose run starts the servers (depends_on) and leaves them running after the command: docker compose down stops and removes them and their network; the harness volume (the installation, Composer's cache) stays until docker compose down -v. The service images run as linux/amd64 on an ARM machine (Docker warns, emulation runs them).

Run on 2026-10-25: test 136 tests, 545 assertions, green, 22 skipped - the Dropbox and Google Drive live tests (no tokens) and the visibility test of the four drives that keep none (S3 without OMNIDRIVE_S3_ACL, WebDAV, Azure, Dropbox's stand-in).

Propose a change docs/harness.md

Writing a provider

A provider is a factory: it builds the league's adapter from its options, and Flysystem\FlysystemDriveFactory::drive() the drive around it. The WebDAV factory, simplified (the real one also splits base_uri into the client's origin and the adapter's prefix, and maps auth):

final class WebdavDriveFactory extends FlysystemDriveFactory
{
    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnidrive.factory_name' => 'webdav',
            'omnidrive.factory_title' => 'A WebDAV server',
            'omnidrive.required_options' => ['base_uri'],
        ]);
    }

    protected function build(Config $c, string $name): Drive
    {
        return $this->drive($name, new WebDAVAdapter(new Client(['baseUri' => $c->string('base_uri')])), $c);
    }
}

Its tests use Omnidrive\Testing\DriveContractTests and give the drive; each test works under a directory of its own, removed afterwards:

final class WebdavLiveTest extends TestCase
{
    use DriveContractTests;

    protected function drive(): Drive
    {
        return (new WebdavDriveFactory())->create(['base_uri' => getenv('WEBDAV_URI')]);
    }
}

A whole provider

A factory of one's own and its contract test, both in one file here; this ran green in the harness on 2026-10-25 (the suite's 9 tests, Testing):

<?php

use League\Flysystem\Local\LocalFilesystemAdapter;
use Omnidrive\Config;
use Omnidrive\Drive;
use Omnidrive\Flysystem\FlysystemDriveFactory;
use Omnidrive\Testing\DriveContractTests;
use PHPUnit\Framework\TestCase;

/** A provider of one's own: a directory under the system's temporary one, named by an option. */
final class ScratchDriveFactory extends FlysystemDriveFactory
{
    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnidrive.factory_name' => 'scratch',
            'omnidrive.factory_title' => 'A scratch directory',
            'omnidrive.required_options' => ['folder'],
        ]);
    }

    protected function build(Config $c, string $name): Drive
    {
        $root = sys_get_temp_dir().'/'.basename((string) $c->string('folder'));

        return $this->drive($name, new LocalFilesystemAdapter($root, $this->unixVisibility($c)), $c, $root);
    }
}

final class ScratchContractTest extends TestCase
{
    use DriveContractTests;

    protected function drive(): Drive
    {
        return (new ScratchDriveFactory())->create(['folder' => 'scratch-contract', 'public_url' => '/scratch']);
    }
}

What a provider decides

  • Its name and its required options (populate()): the factory name is what a configuration's factory names; a missing required option is the InvalidConfigException create() raises. Credentials that come in alternatives (a refresh token or an access token) are checked in build(), with an InvalidConfigException that names both.
  • Nothing connects in build(). The adapter is built; the server is reached on the first operation. HTTP clients come through the factory's constructor (S3: Symfony's contracts; Dropbox, Google Drive: Guzzle), never created inside when one is given.
  • Its capabilities, through drive(): pass the local root when its paths are files of this machine; pass $adapterPublicUrls: false when the adapter's own public address would be a credential (Azure's SAS of a thousand years); temporary addresses come from the adapter (TemporaryUrlGenerator).
  • Where the adapter parts from Flysystem's contract, a subclass of it fixes that, as omnidrive/dropbox's DropboxAdapter does (a missing file deleted is no error, a visibility not kept is reported as such, a checksum is the file's own).
  • Unix modes: unixVisibility($c) gives the adapter the permissions option.

Checklist

  • Tests/<Name>DriveTest.php: what is particular (options mapped onto the adapter, offline, with a mock client when the service is HTTP);
  • Tests/<Name>LiveTest.php using DriveContractTests against a server (the harness's, or one named by OMNIDRIVE_<NAME>_* variables; skipped without them), keepsVisibility() when the service keeps none;
  • the provider listed in the core's docker/harness/plugins.php (and a server in docker/compose.yml, with its variables in .env.dist, when one can run in a container);
  • docs/index.md: the service, credentials, every option, capabilities, behaviour, errors, verified and not;
  • the core's README, suggest and the providers table of this manual.

Propose a change docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-25 in the harness, the core and its eight providers from their checkouts, PHP 8.4.26, PHPUnit 11.5.57, Flysystem 3.x as Composer resolves it: 136 tests, 545 assertions, green, 22 skipped (the Dropbox and Google Drive live tests without tokens, and the visibility test of the drives that keep none).

  • The contract (DriveContractTests, Testing) on the memory and local drives, and live against the harness's servers: MinIO (S3, path style, a prefix), OpenSSH's SFTP (atmoz/sftp, a password), Pure-FTPd (passive mode), Apache's mod_dav (WebDAV, basic authentication) and Azurite (Azure Blob Storage); against a stand-in of Dropbox's HTTP API written from its documentation (Tests/Fixtures/FakeDropboxApi), with the refresh token's renewal after a 401.
  • The core: drives built once by name, an unknown drive or factory said by name, options read as environment variables give them (RegistryTest); a host's operators as drives, names given (OperatorDrivesTest); a local operator's root and files, another adapter with no local file and no address, the engine's failure kept behind the contract's exception, visibility options as the enumeration (FlysystemDriveTest); the memory drive's addresses (InMemoryDriveTest).
  • In plain PHP (BareTest, bin/bare --live): the registry built by hand, every reachable drive used whole, no class of a framework loaded (of Symfony only the HTTP client AsyncAws calls S3 through).
  • Offline per provider: S3's requests (path style, prefix, ACL headers, signatures, AWS's virtual-host address); Azure's SAS address and its public ones; Google Drive's token exchange and a service account's signed assertion; the options each requires.
  • Earlier, as the README states (2026-10-23 and 2026-10-24): a temporary S3 address and an Azurite SAS fetched, the plain addresses refused; Nextcloud's WebDAV (--profile nextcloud).

The capability table of the index was probed with capabilities() on 2026-10-25; every example marked as run was run in the harness that day and its output pasted.

What is not verified

  • Dropbox and Google Drive themselves: no account at hand; their live tests run with an account's tokens (OMNIDRIVE_DROPBOX_*, OMNIDRIVE_GDRIVE_*).
  • AWS S3 itself and the other S3 services (Scaleway, OVHcloud, R2): MinIO only; objects' ACLs (OMNIDRIVE_S3_ACL=1 runs that test against a bucket that keeps them).
  • Azure Blob Storage itself (Azurite only), an account with a hierarchical namespace.
  • SFTP with a private key or the SSH agent, the server's key checked; FTPS; Windows FTP servers; vsftpd's STAT (see Files); ownCloud, nginx's WebDAV, digest and NTLM authentication.
  • The hosts' pages (Hosts) were written from omnibridge's code and its DrivesTests, not run in an application for this manual.
  • The defects found while writing this manual, fixed since: a refused S3 request read as "not found" (omnidrive/s3), a refused Dropbox refresh token read as "not found" (omnidrive/dropbox) - each with its test on a recorded answer (Errors).

Responsibility

omnisense/omnidrive and its omnidrive/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by the services or the software they reach (Amazon Web Services, Microsoft, Dropbox, Google, Nextcloud, MinIO, any hosting company) nor by The PHP League, whose Flysystem they run on. Each service keeps its own terms and its own prices - a bucket's requests and transfers are billed by its provider. The MIT licence covers this code only. Whoever installs and runs these packages is responsible for the data they store and for the access they give to it - a public address or a temporary one serves the file to whoever holds it.

Propose a change docs/verified.md