flytachi / winter-sdk-s5w
PHP SDK for S5W, the file store and CDN: buckets, folders, uploads with resumable chunking and deduplication, image processing, typed entities and errors.
Requires
- php: >=8.4
- composer-runtime-api: ^2.0
- ext-curl: *
- flytachi/winter-cast: ^1.3
- psr/log: ^3.0
Requires (Dev)
- squizlabs/php_codesniffer: @stable
Suggests
- flytachi/winter-logger: A PSR-3 logger to pass to S5w: requests and upload events at debug, retries as warnings.
Provides
None
Conflicts
None
Replaces
None
README
Winter SDK for S5W
The PHP client for S5W — a file store and CDN in one container.
Upload anything, any size; get typed objects back and typed errors when something goes wrong.
Quick start · Uploading · Folders and files · Errors · Documentation
You pass a path, you get a File. The SDK decides whether to send it in one request or in
chunks, retries a chunk that broke, skips the transfer when the bucket already holds the
same bytes, and turns every server refusal into an exception of a specific type.
use Flytachi\Winter\SDK\S5w\S5w; $s5w = S5w::connect('https://s5.example.com', 's5w_9f3c…'); $file = $s5w->files()->upload('/tmp/report.pdf', folder: 'docs'); $file->id; // "FXOkqegbdrNRWQgc" — how the file is addressed everywhere $file->privateUrl; // https://s5.example.com/p/FXOkqegbdrNRWQgc
The same upload() call takes a multi-gigabyte file: it goes in resumable chunks, survives a
dropped connection and is not sent at all if the content is already in the bucket.
Highlights
- ⏫ Any size, one call — up to 16 MiB in one request, larger in chunks; you don't choose, the SDK does.
- 🔁 Survives a broken connection — a failed chunk is retried after asking the server what it already holds: nothing sent twice, nothing lost.
- ⏯ Resumable — keep the session id, continue after a crash or a restart with
resume(). - ♻️ Deduplication — content the bucket already has is not transferred at all.
- 🖼 Images on upload — resize and re-encode to
webp/avif/jpeg/pngwith a fluentImageOptions. - 🗂 Folders and files — visibility, retention, cache policy; search, sort, move, rename, delete.
- 🧱 Typed all the way — immutable entities, enums instead of magic numbers, an exception class per HTTP status.
- 🌱 Made for Winter — a bean in five lines, several buckets as named beans, PSR-3 logging.
- 🔒 The key stays secret — never in logs, never in a
var_dump().
Requirements
| Version | Why | |
|---|---|---|
| PHP | >= 8.4 |
readonly classes, enums, promoted properties |
ext-curl |
any | all traffic goes through cURL; files are sent as CURLFile |
| S5W server | API v1 |
the SDK talks to /v1/* |
flytachi/winter-cast (HTTP) and
psr/log are installed by Composer. Any PSR-3
logger is optional.
Installation
composer require flytachi/winter-sdk-s5w
You need two values from the S5W instance: its address and a bucket key issued in the admin panel. The bucket is decided by the key — it is never configured in the SDK.
| Key | Opens |
|---|---|
FULL |
everything: check(), folders(), files(), uploads() |
BASIC |
check() and /p/{id} delivery only |
Quick start
$s5w = S5w::connect(getenv('S5W_URL'), getenv('S5W_KEY')); // is the key good, and what is behind it? $key = $s5w->check(); echo "{$key->bucket->name}: {$key->storage->free} bytes free\n"; // a folder — created once, reused afterwards $folder = $s5w->folders()->ensure('reports'); // upload from memory and from disk $csv = $s5w->files()->uploadContent("date,total\n2026-11-01,42\n", 'november.csv', folder: $folder); $notes = $s5w->files()->upload('/tmp/notes.txt', folder: $folder); // list, rename, delete foreach ($s5w->files()->list(folder: $folder) as $file) { echo $file->name, "\n"; } $notes = $s5w->files()->rename($notes, 'Q4-notes.txt'); // the id does not change $s5w->files()->delete($csv);
In a Winter application
Declare the client as a bean — the container builds it once per worker:
#[Configuration] final class S5wConfiguration { #[Bean] public function s5w( #[Value('S5W_URL')] string $url, #[Value('S5W_KEY')] string $key, ): S5w { return new S5w(new S5wConfig($url, $key), logger: LoggerFactory::getLogger('S5W')); } }
#[Autowired] private S5w $s5w;
Several buckets are several named beans (#[Bean(name: 's5w.media')], injected with
#[Inject('s5w.media')]).
Uploading
// into a folder, under a name of your own $s5w->files()->upload($path, folder: 'docs', name: 'Contract 42.pdf'); // a file from a request: pass the original name, or the temporary one is stored $s5w->files()->upload($upload['tmp_name'], folder: 'avatars', name: $upload['name']); // resize and re-encode an image on the server use Flytachi\Winter\SDK\S5w\Option\ImageOptions; $file = $s5w->files()->upload($path, imageOptions: ImageOptions::webp(80)->fit(512, 512)); $file->processed->operations; // ["resize:512x384", "encode:webp@80"]
| File size | How it is sent |
|---|---|
| up to 16 MiB | one multipart request |
| larger | chunks via /v1/uploads: retried, resumable, deduplicated by SHA-256 |
The threshold is S5wConfig::$chunkThreshold.
Large files: progress and resume
use Flytachi\Winter\SDK\S5w\Entity\UploadSession; use Flytachi\Winter\SDK\S5w\Exception\UploadInterruptedException; try { $file = $s5w->uploads()->send( '/backups/db.sql', folder: 'backups', onStart: fn(UploadSession $s) => $job->saveUploadId($s->id), onProgress: fn(int $sent, int $total) => printf("%d%%\n", $sent * 100 / $total), ); } catch (UploadInterruptedException $e) { // the network never came back; the session lives on the server for 24 h $file = $s5w->uploads()->resume($e->sessionId(), '/backups/db.sql'); }
A broken chunk is retried up to three times; before each retry the SDK asks the server how
many bytes it holds, so nothing is sent twice or lost. The step-by-step API —
start(), append(), status(), complete(), abort() — is there when the loop has to be
yours.
Folders and files
use Flytachi\Winter\SDK\S5w\Entity\Folder; use Flytachi\Winter\SDK\S5w\Enum\{FileSort, Retention}; $s5w->folders()->create('tmp-exports', retention: Retention::WEEK); // files delete themselves $s5w->folders()->ensure('avatars', public: true); // served without a key $page = $s5w->files()->list(folder: 'docs', search: 'invoice', sort: FileSort::SIZE); // Page<File> foreach ($s5w->files()->all(folder: Folder::ROOT) as $file) { /* every page, lazily */ } $s5w->files()->move($file, 'archive'); // or Folder::ROOT $s5w->files()->update($file, 'Q4-final.pdf', 'archive'); // name and folder in one request
The folder decides who sees a file: a public folder serves it at publicUrl without a key.
A file in the bucket root is public.
Errors
Everything the SDK throws extends S5wException; branch on the class, keep the message for logs.
try { $s5w->files()->rename($file, 'report.pdf'); } catch (NotFoundException) { // 404 — no such file } catch (ConflictException) { // 409 — the name is taken in the folder } catch (ValidationException $e) { // 422 — $e->errors(): per-field messages } catch (TransportException) { // the server did not answer — worth retrying later }
| Exception | When |
|---|---|
TransportException |
no HTTP answer: DNS, refused connection, timeout |
ResponseException |
an answer, but not an S5W one (not JSON, a field missing) |
UnauthorizedException · ForbiddenException |
401 · 403 — the key is unknown, disabled, expired, or BASIC |
NotFoundException · ConflictException |
404 · 409 |
PayloadTooLargeException · ValidationException |
413 (quota full) · 422 |
ServerException |
5xx |
UploadInterruptedException |
chunked upload stopped after its retries — resume() it |
UploadUnconfirmedException |
the answer to complete was lost; the file may exist |
Logging
Pass any PSR-3 logger. You get one debug line per request and the chunked-upload events
(retries as warning). Values are filled into the message by the SDK itself, so lines read
right with any logger. The key never reaches a log or a var_dump().
GET v1/check → 200 (21 ms)
upload b3b53a23-… started: backup.sql, 100725660 bytes in chunks of 8388608
PATCH v1/uploads/b3b53a23-… → 200 (869 ms)
upload b3b53a23-… completed: backup.sql → file VVWEoDe7rh0FGbCx
Status
The SDK covers checking the key, folders, files and uploads. Not in the SDK yet:
downloading (download(), contents()) and temporary links (links()). Until then use the
ready-made privateUrl / publicUrl of a File, or the
delivery and
links API directly.
Documentation
Full documentation (Russian for now): winterframe.net/packages/sdk-s5w.
Every class and public method in the code carries an @link to its section.
| Guide | What's inside |
|---|---|
| Installation | requirements, keys, plain PHP and Winter setup |
| Quick start | one end-to-end scenario |
| Mental model | one client = one bucket, snapshots, ids |
| Winter integration | beans, several buckets, logging, async |
| Uploading files | disk, request, memory, images, duplicates |
| Large files | progress, resume, deduplication |
| Step-by-step uploads | your own chunk loop |
| Folders and files | rules, search, move, rename, delete |
| Error handling | what to catch, what to retry |
| API reference | every method and argument |
| Entities · Options & enums · Exceptions | fields, values, hierarchy |
| Upload pipeline · HTTP & logging | how it works inside |
License
MIT — Flytachi