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.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnidrive omnidrive/local
Requirements
- PHP 8.2 or later.
omnisense/omnidriverequiresleague/flysystem(^3.0) andpsr/container(^1.1 or ^2.0), nothing else: no framework, no HTTP client. Its test drive needsleague/flysystem-memory(suggested), its conformance suitephpunit/phpunit.- Each provider requires its Flysystem adapter and what that adapter needs:
omnidrive/ftptheftpextension,omnidrive/s3AsyncAws (async-aws/simple-s3, which calls S3 throughsymfony/http-client),omnidrive/webdavsabre/dav (cURL),omnidrive/dropboxGuzzle,omnidrive/gdriveGoogle'sgoogle/apiclientand 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.
Proposer une modification 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.
Proposer une modification 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();$configisname => ['factory' => ..., 'options' => [...]]get(string $name): Drivebuilt on the first call, with $nameas the drive's name, and kepthas(),names()from the configuration, in its order options(string $name): arraythe options a drive is configured with create(string $name, array $overrides = []): Drivebuilt afresh with $overridesover its options (array_replace); not keptall(): array<string, Drive>every drive, built factories(): list<string>the factories installed No "x" drive; configured: uploads, archive.andNo "s3" factory for the "archive" drive; installed: local.areInvalidConfigExceptions. -
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']) |
Proposer une modification 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.
Proposer une modification 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.
Proposer une modification 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).
Proposer une modification 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:nullin theEntry.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.
Proposer une modification 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.
Proposer une modification 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).
Proposer une modification 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'sfactorynames; a missing required option is theInvalidConfigExceptioncreate()raises. Credentials that come in alternatives (a refresh token or an access token) are checked inbuild(), with anInvalidConfigExceptionthat 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: falsewhen 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'sDropboxAdapterdoes (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 thepermissionsoption.
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.phpusingDriveContractTestsagainst a server (the harness's, or one named byOMNIDRIVE_<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 indocker/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,
suggestand the providers table of this manual.
Proposer une modification 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=1runs 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.