rocketc31 / voltn-sdk
Unofficial, framework-agnostic PHP SDK for the Voltn (formerly NetExplorer) file-storage API: OAuth2, folders, files, streamed and TUS uploads.
Requires
- php: ^8.2
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.0
- psr/http-factory-implementation: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.59
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/psr7: ^2.7
- league/flysystem: ^3.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^1.11 || ^2.0
- phpunit/phpunit: ^11.0
Suggests
- illuminate/filesystem: ^11.0 || ^12.0 || ^13.0 — Laravel `voltn` disk driver
- illuminate/support: ^11.0 || ^12.0 || ^13.0 — Laravel `voltn` disk driver
- league/flysystem: ^3.0 — required for RocketC31\Voltn\Flysystem\VoltnAdapter
Provides
None
Conflicts
None
Replaces
None
README
A framework-agnostic PHP SDK for the Voltn file-storage / EDM API (formerly NetExplorer): folders, files (including streamed uploads/downloads and large-file TUS transfers), root spaces, file tokens, and the full OAuth2 flow. API reference: https://api.voltn.eu/v4/.
Unofficial. This is a community project. It is not affiliated with, endorsed by, or supported by Voltn. "Voltn" and "NetExplorer" are trademarks of their respective owner.
On top of the framework-agnostic SDK, the package ships an optional
Flysystem v3 adapter and a Laravel
voltn disk driver, so that spatie/laravel-backup (and anything else built
on Flysystem) can target Voltn the same way it targets S3 or Dropbox. Both
are opt-in: the core SDK does not depend on Flysystem or Laravel.
Requirements
-
PHP 8.2+
-
A PSR-18 HTTP client and PSR-17 factories. If your application doesn't already have one (e.g. via Symfony HttpClient or Guzzle), install Guzzle:
composer require guzzlehttp/guzzle
php-http/discoverywill find it automatically — you don't need to wire anything up yourself.
Installation
composer require rocketc31/voltn-sdk
Until the package is published on Packagist, install it as a VCS repository:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/RocketC31/voltn-sdk" }
]
}
Quickstart
Fire-and-forget: client_credentials
This is the mode a backend integration with no end user in the loop (e.g. a scheduled backup job) will typically use. No token storage is required — the SDK obtains and refreshes tokens on its own for as long as the process lives.
use RocketC31\Voltn\ClientBuilder; $client = ClientBuilder::create('https://tenant.voltn.example') ->withClientCredentials('client-id', 'client-secret') ->build(); $folder = $client->folders()->get(1, depth: 1); foreach ($folder->getFiles() as $file) { echo $file->getName(), ' (', $file->size(), ' bytes)', PHP_EOL; }
Full interactive flow (authorization code, optionally with PKCE)
use RocketC31\Voltn\Auth\Pkce; use RocketC31\Voltn\ClientBuilder; // Step 1: build the OAuth2 client to get an authorization URL. $client = ClientBuilder::create('https://tenant.voltn.example') ->withOAuth2('client-id', 'client-secret') // secret omitted entirely for a public client using PKCE ->build(); $pkce = Pkce::generate(); // optional, for public clients $authorizationUrl = $client->oauth2()->getAuthorizationUrl( redirectUri: 'https://app.example.com/callback', state: bin2hex(random_bytes(16)), pkce: $pkce, ); // Redirect the user to $authorizationUrl. Persist $pkce->codeVerifier // (e.g. in session) until the callback comes back. // Step 2: in your callback handler, exchange the code for a token. $token = $client->oauth2()->exchangeAuthorizationCode( code: $_GET['code'], redirectUri: 'https://app.example.com/callback', codeVerifier: $pkce->codeVerifier, // or clientSecret: '...' for a confidential client ); // Step 3: persist $token (e.g. in session) and build the "real" client // with a TokenStorage so subsequent requests auto-refresh it. use RocketC31\Voltn\Auth\TokenStorage; $storage = /* your TokenStorage implementation, e.g. session-backed */; $storage->set($token); $client = ClientBuilder::create('https://tenant.voltn.example') ->withOAuth2('client-id', 'client-secret') ->withTokenStorage($storage) ->build();
Fully manual
You manage the AccessToken yourself (obtained however you like) and hand
it to the SDK. No refresh or retry-on-401 logic is engaged in this mode —
a 401 propagates as an AuthenticationException.
use RocketC31\Voltn\Auth\AccessToken; use RocketC31\Voltn\ClientBuilder; $token = AccessToken::fromArray($someTokenArrayYouLoadedYourself); $client = ClientBuilder::create('https://tenant.voltn.example') ->withAccessToken($token) ->build();
Streaming uploads and downloads
Uploads and downloads work over PSR-7 StreamInterface and never buffer a
full file's content into memory.
// Download. $stream = $client->files()->download($fileId); $destination = fopen('/path/to/local/file', 'wb'); while (!$stream->eof()) { fwrite($destination, $stream->read(8192)); } fclose($destination); // Simple upload (creates a new version automatically if the name already // exists in the target folder). use GuzzleHttp\Psr7\Utils; $content = Utils::streamFor(fopen('/path/to/local/file.pdf', 'rb')); $file = $client->files()->upload($folderId, 'file.pdf', $content, 'application/pdf');
Large files: TUS chunked upload
For large files, prefer $client->tus(), which bootstraps a session via
Voltn's POST /file/tus and then speaks the standard
TUS 1.0 protocol (creation +
core extensions) to transfer the content in configurable chunks, with an
optional progress callback:
use RocketC31\Voltn\Upload\UploadOptions; $content = Utils::streamFor(fopen('/path/to/huge-file.zip', 'rb')); $file = $client->tus()->upload( target: $folderId, filename: 'huge-file.zip', content: $content, size: filesize('/path/to/huge-file.zip'), options: new UploadOptions( chunkSize: 8 * 1024 * 1024, onProgress: fn (int $sent, int $total) => printf("%d / %d bytes\n", $sent, $total), ), );
Previewing and downloading from a browser
Your OAuth2 access token must never reach a browser. Instead, the SDK asks Voltn for a single-purpose file token and returns a URL built around it, which you can hand to the front end:
// Iframe `src` or new tab: Voltn renders PDFs, images and audio/video, // and redirects office documents to the configured editing platform. $previewUrl = $client->tokens()->previewUrl($fileId); // Direct download from Voltn: the content doesn't transit through your server. $downloadUrl = $client->tokens()->downloadUrl($fileId);
Each call generates a fresh token, so build these URLs on demand (e.g.
in the controller that serves the page) rather than storing them. If you
need the raw token, createFileToken() accepts a FileTokenType
(Preview, Edit or Download):
use RocketC31\Voltn\Model\FileTokenType; $token = $client->tokens()->createFileToken($fileId, FileTokenType::Edit); $token->getToken();
Path-based lookups and quotas
$client->sync() wraps Voltn's synchronisation endpoints, which let you
address items by path instead of walking folders id by id:
use RocketC31\Voltn\Model\ObjectType; // One call whatever the depth (a trailing "/" is added for folders). $folder = $client->sync()->folderAt($rootFolderId, 'backups/my-site'); $file = $client->sync()->fileAt($rootFolderId, 'backups/my-site/2026-10-01.zip'); // The other way round: id -> path relative to the root. $path = $client->sync()->objectPath($rootFolderId, ObjectType::File, $fileId); // Is a folder below a root? (true / false; 403 = below it but not accessible) $client->sync()->isChildOf($folderId, $rootFolderId); // Platform, user and folder quotas (bytes; null quota = unlimited). $quotas = $client->sync()->quotas($folderId); $quotas->getFolder()?->getRemaining();
Deleting
Deleted items go to the trash by default (recoverable). Pass
trash: false to delete permanently, e.g. when rotating backups so the
trash doesn't fill up the quota:
use RocketC31\Voltn\Model\FileVersionScope; $client->files()->delete($fileId); // trash, all versions $client->files()->delete($fileId, trash: false); // permanent $client->files()->delete($fileId, versions: FileVersionScope::None); // current version only $client->folders()->delete($folderId, trash: false);
Every version of a file shares the same getGuid(), whereas getId()
designates one version.
Flysystem adapter
Requires league/flysystem ^3.0:
composer require league/flysystem
use League\Flysystem\Filesystem; use RocketC31\Voltn\Flysystem\VoltnAdapter; $adapter = new VoltnAdapter( $client, // a RocketC31\Voltn\Client $rootFolderId, // every Flysystem path is relative to this folder trash: true, // false = permanent deletes chunkedUploadThreshold: 50 * 1024 * 1024, // bigger contents go through TUS chunkSize: 8 * 1024 * 1024, ); $filesystem = new Filesystem($adapter); $filesystem->writeStream('backups/my-site/2026-10-01.zip', fopen('/tmp/backup.zip', 'rb'));
Behaviour worth knowing:
- Paths are resolved in a single call each through
$client->sync(); missing parent folders are created on write...cannot escape the root. - Writing to an existing path creates a new Voltn version of the file (the platform's native behaviour) rather than replacing it.
- Streams whose size is known and larger than
chunkedUploadThresholdare uploaded through TUS; everything else in a single streamed multipart upload. Nothing is buffered fully into memory. - Visibility is not supported:
setVisibility()/visibility()throw. - MIME types are derived from the file extension (Voltn does not expose one); the file must still exist.
- Deleting a missing file or directory is a no-op; deleting the root is
refused.
move()is a copy followed by a delete of the source.
Laravel
The voltn disk driver is registered automatically (package
auto-discovery) when illuminate/support and illuminate/filesystem
(Laravel 11, 12 or 13) and league/flysystem are installed.
// config/filesystems.php 'disks' => [ 'voltn' => [ 'driver' => 'voltn', 'base_uri' => env('VOLTN_BASE_URI'), // https://tenant.voltn.example 'client_id' => env('VOLTN_CLIENT_ID'), 'client_secret' => env('VOLTN_CLIENT_SECRET'), 'root' => env('VOLTN_ROOT_FOLDER_ID'), // id of the folder used as the disk root 'trash' => false, // permanent deletes, e.g. for backup rotation // Optional: // 'chunked_upload_threshold' => 52428800, // bytes, TUS above this // 'chunk_size' => 8388608, // bytes per TUS chunk // 'timeout' => 300, // seconds per HTTP request, transfer included (0 = none) // 'connect_timeout' => 5, // seconds ], ],
Storage::disk('voltn')->put('reports/2026-10.csv', $csv);
base_uri, client_id, client_secret and root are required (an
InvalidArgumentException is thrown otherwise). The disk authenticates with
client_credentials; the access token is cached, encrypted with the
application key, in the default cache store (CacheTokenStorage), so that
successive requests and jobs don't fetch a new token every time. When
guzzlehttp/guzzle is installed it is used with the configured timeouts;
otherwise the PSR-18 client is auto-discovered. timeout bounds each HTTP
request as a whole: keep it large enough for one chunk (or one upload below
chunked_upload_threshold) and for downloads on your bandwidth, or set it to
0 to disable it.
With spatie/laravel-backup
Add the disk to the backup destinations:
// config/backup.php 'destination' => [ 'disks' => ['voltn'], ],
Use 'trash' => false on the disk: old backups removed by the cleanup task
are then deleted permanently instead of piling up in the Voltn trash, which
would otherwise keep eating the quota.
Impersonation
If your application acts on behalf of other Voltn users, set a
default As: header:
$client = ClientBuilder::create('https://tenant.voltn.example') ->withClientCredentials('client-id', 'client-secret') ->withImpersonation($userId) ->build();
Exception handling
Every error response is mapped to a typed exception, all extending
RocketC31\Voltn\Exception\VoltnException:
| HTTP status | Exception |
|---|---|
| n/a (no response at all — connection/timeout) | TransportException |
| 401 | AuthenticationException |
| 403 | AuthorizationException |
| 404 | NotFoundException |
| 5xx | ServerException |
| anything else unexpected, or an undecodable 2xx JSON body | UnexpectedResponseException |
Voltn does not guarantee a consistent error response body shape (a 500 in particular "may or may not include a body"), so every accessor is defensive:
use RocketC31\Voltn\Exception\VoltnException; try { $client->files()->get($fileId); } catch (VoltnException $e) { $e->getStatusCode(); // ?int $e->getJson(); // ?array — decoded body, if any and if it was a JSON object/array $e->getApiMessage(); // ?string — best-effort human-readable message $e->getRawBody(); // ?string — raw response body, if any }
FolderClient::exists() and FileClient::exists() catch NotFoundException
for you and return a plain bool.
A note on the API base path
Voltn's per-tenant API is served at https://{platform}/api
(no version segment despite what the documentation site's own URL might
suggest); "module" in Voltn's own docs is just a placeholder word
for "whatever resource path applies" (folder, file, roots, ...), not
a configurable segment. This SDK asks only for the tenant host (e.g.
https://tenant.voltn.example) and appends /api itself for all
regular resource calls (folders, files, roots, tokens).
OAuth2 endpoints (/oauth2/authorize, /oauth2/token) are the documented
exception: they sit at the tenant root, not under /api, and the SDK
talks to them there directly.
TUS chunked transfers are the other documented exception: they run against
https://{platform}/api/tus directly, which the SDK also handles for you.
Development
composer install vendor/bin/phpunit # unit tests (integration tests are skipped unless env vars are set) vendor/bin/phpstan analyse # static analysis (level 8) vendor/bin/php-cs-fixer fix --dry-run --diff # code style check
Integration tests are opt-in and run against a real Voltn tenant:
VOLTN_TEST_BASE_URI=https://tenant.voltn.example \ VOLTN_TEST_CLIENT_ID=... \ VOLTN_TEST_CLIENT_SECRET=... \ vendor/bin/phpunit --testsuite=integration
Scope
Deliberately out of scope for this SDK (v1):
- Trash / recycle bin endpoints.
- OAuth2 app-management endpoints (registering apps, resetting secrets, listing/revoking tokens).
- Rate-limit handling — Voltn does not document any rate limiting.
- A
mimeType()accessor onFile— Voltn only exposes a coarsefile_type(image/document/video) for preview purposes, not a real MIME type. The Flysystem adapter derives MIME types from the file extension instead.
License
MIT. See LICENSE.md.