minhyung / flysystem-mybox
Flysystem adapter for Naver MYBOX, built on the minhyung/mybox SDK.
Requires
- php: ^8.2
- ext-mbstring: *
- league/flysystem: ^3.29
- league/mime-type-detection: ^1.0
- minhyung/mybox: ^0.1.1
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.51
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- league/flysystem-adapter-test-utilities: ^3.29
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-intl: Folds Hangul file names to NFC so names created on macOS match what MYBOX stores
README
English | 한국어
A Flysystem v3 adapter for
Naver MYBOX, built on the
minhyung/mybox SDK. It passes
Flysystem's own adapter conformance suite.
use League\Flysystem\Filesystem; use Minhyung\Flysystem\Mybox\MyboxAdapter; use Minhyung\Mybox\MyboxClient; $filesystem = new Filesystem(new MyboxAdapter(MyboxClient::create($_ENV['MYBOX_PAT']))); $filesystem->write('reports/2026-08.csv', $csv); echo $filesystem->read('reports/2026-08.csv');
Requirements
- PHP 8.2+
- A MYBOX personal access token (MYBOX web → 설정 → 계정 및 개인 액세스 토큰 관리)
ext-intlis optional but recommended, so Hangul names created on macOS (NFD) match what MYBOX stores (NFC)
Installation
composer require minhyung/flysystem-mybox
The SDK speaks PSR-18, and brings no HTTP client of its own. If your project has none yet:
composer require guzzlehttp/guzzle
Usage
Confining the adapter to a folder
$adapter = new MyboxAdapter($client, 'app-data/uploads');
Every path is resolved under that folder, and it is created on the first write. Nothing outside it is reachable, which makes this the safest way to share an account between an application and a human.
Options
use Minhyung\Flysystem\Mybox\Enum\DeletionMode; use Minhyung\Flysystem\Mybox\MyboxAdapterOptions; $adapter = new MyboxAdapter($client, options: new MyboxAdapterOptions( deletionMode: DeletionMode::Purge, visibility: League\Flysystem\Visibility::PUBLIC, ));
| Option | Default | What it does |
|---|---|---|
deletionMode |
Trash |
Trash moves deleted files to the MYBOX trash, like the web UI. Purge also empties them from it. |
visibility |
private |
What visibility() reports. MYBOX stores no such thing — see Limitations. |
failOnSetVisibility |
true |
Whether setVisibility() throws or is a silent no-op. |
unknownSize |
Buffer |
What writeStream() does when a stream's length cannot be read. Fail refuses instead of buffering. |
bufferThresholdBytes |
2 MiB | Below this a buffered upload stays in memory; above it, php://temp spills to disk. |
listPageSize |
1000 | Entries per listing request. The maximum MYBOX allows, and ten times its own default. |
cacheTtlSeconds |
10 | How long a cached directory listing is trusted. |
cacheMaxDirectories |
128 | How many directories the cache holds before evicting the least recently used. |
strictTemporaryUrlExpiry |
true |
Whether to refuse a temporary URL asked to live longer than MYBOX allows. |
lockedRetries |
2 | Retries when MYBOX answers 423, which it does briefly after an interrupted upload. |
Uploading a large file without buffering
MYBOX reserves an upload against a declared byte length and answers HTTP 500 if
the bytes disagree, so the adapter never guesses. It reads the length off a
seekable regular file; for anything else — a socket, a pipe, an upload arriving
over the network — it buffers through php://temp first. Skip that by saying
how long the payload is:
$filesystem->writeStream('video.mp4', $stream, ['size' => $lengthInBytes]);
Temporary URLs
$url = $filesystem->temporaryUrl('reports/2026-08.csv', new DateTimeImmutable('+5 minutes'));
The URL is single-use. A second request for it fails, and MYBOX ignores the expiry you ask for — it grants ten minutes. Hand it to one client immediately; never put it in a cached page, an email, or a job that might be retried.
With Laravel
The package is framework-agnostic. Register it as a disk driver in a service provider:
use Illuminate\Support\Facades\Storage; use League\Flysystem\Filesystem; use Minhyung\Flysystem\Mybox\MyboxAdapter; use Minhyung\Mybox\MyboxClient; Storage::extend('mybox', function (array $app, array $config) { $adapter = new MyboxAdapter(MyboxClient::create($config['token']), $config['root'] ?? ''); return new \Illuminate\Filesystem\FilesystemAdapter(new Filesystem($adapter), $adapter, $config); });
// config/filesystems.php 'mybox' => [ 'driver' => 'mybox', 'token' => env('MYBOX_PAT'), 'root' => env('MYBOX_ROOT', ''), ],
How path resolution works
MYBOX addresses everything by an opaque resource id, not by a path, and its only path-aware endpoint — search — is capped at ten calls a minute and served from an index that lags a just-created folder. So the adapter never uses search. It walks down from the drive root with listing calls, and keeps the whole listing rather than the one id it was after.
That matters because a listing already answers exists, size, last-modified and type for every child at once:
$filesystem->fileExists('reports/2026-08.csv'); // walks: 2 requests $filesystem->fileSize('reports/2026-08.csv'); // 0 requests $filesystem->lastModified('reports/2026-08.csv'); // 0 requests $filesystem->mimeType('reports/2026-08.csv'); // 0 requests $filesystem->fileExists('reports/2026-07.csv'); // 0 requests
Cached listings are per-adapter-instance, per-process, and trusted for ten
seconds. That window is what bounds the one failure this cannot detect: another
client renaming a folder, which leaves the id valid but pointing elsewhere. Call
$adapter->clearCache() after mutating the drive through the SDK directly, or
pass new NullDirectoryCache() to turn caching off entirely — at the cost of
three listing round-trips for every fileExists('a/b/c.txt').
Limitations
These are properties of the MYBOX API, not gaps in the adapter.
- No visibility. MYBOX has no per-file permission model of any kind.
visibility()returns whatever you configured;setVisibility()throws rather than pretending. Thevisibilityoption onwrite()is accepted and ignored, so Laravel'sStorage::put($path, $contents, 'public')does not break. - No public URLs. There is no sharing endpoint, so
PublicUrlGeneratoris deliberately not implemented —temporaryUrl()would be a lie in a page that gets cached. Serve files through your own controller and configure Flysystem'sPrefixPublicUrlGeneratorif you need stable URLs. - No checksums. MYBOX exposes no hash.
Filesystem::checksum()still works; it downloads the file and hashes it locally, which the adapter leaves visible rather than hiding behind aChecksumProvider. - No mime types. The API reports only a coarse category, so mime types are
detected from the file extension. Pass your own
MimeTypeDetectorto change that. move()is not atomic. MYBOX splits it: one call changes the parent, another changes the name, and the second has no overwrite flag — so an occupied destination is deleted first. A crash in between leaves the file at an intermediate path.- Deleting fills the trash. With the default
DeletionMode::Trash, deleted files keep counting against the account quota until MYBOX auto-empties the trash. UseDeletionMode::Purge, or set an auto-delete window with the SDK'sDriveApi::setTrashAutoDeleteDays(). - Rate limits. Downloads are 500–50,000 a day depending on plan; deletes and most other calls 60–240 a minute. The listing cache exists largely to keep you under them.
- File names are case-insensitive. Writing
case.txtinto a folder that already holdsCase.txtoverwrites it, and MYBOX keeps the original spelling — so the file comes back fromlistContents()asCase.txt. The adapter folds case when matching, so reading it back by either spelling works.
Measured against a live account and recorded in docs/adapter-notes.md: every special character Flysystem tests for is accepted in a file name, zero-byte uploads work, and a folder listing takes the maximum page size of 1000.
Contributing
composer test # unit suite, no network composer analyse # PHPStan level 9 composer cs # PHP-CS-Fixer, dry run
See CONTRIBUTING.md. Note that the integration suite talks to a real MYBOX account and is off by default.
License
MIT. See LICENSE.