krafsys / vercy-storage
A tiny, framework-agnostic local file storage and upload-handling library for PHP.
Requires
- php: ^8.1
- ext-fileinfo: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A tiny, framework-agnostic local file storage and upload-handling library for PHP. No globals, no facades, no service container — construct it, configure it, use it.
$storage = new Storage(root: __DIR__ . '/storage/app'); $path = $storage->store(UploadedFile::capture('avatar'), 'avatars'); $url = $storage->url($path);
Features
- Uploads, done safely — validated against a max size and a MIME-type allow-list, checked against the file's sniffed content type rather than the spoofable client-supplied header.
$_FILES, tamed —UploadedFile::capture()normalizes PHP's awkward$_FILESshape, including grouped/array inputs (name="photos[]") into plain objects.- Testable —
UploadedFile::fake()lets you exercise upload code in unit tests without a real HTTP request, whichmove_uploaded_file()otherwise requires. - Path-traversal safe — every path is checked before it touches the filesystem.
- Zero dependencies — just PHP's
fileinfoextension (bundled with PHP by default). - A clear exception hierarchy — catch
StorageExceptiongenerically, or a specific subclass (FileNotFoundException,InvalidPathException,UploadValidationException,UploadFailedException).
Requirements
- PHP 8.1+
ext-fileinfo(enabled by default in virtually all PHP installs)
Installation
composer require krafsys/vercy-storage
Configuration
Everything is passed to the constructor — there's no config file, no environment
variable magic, and nothing global. Create one Storage instance (or several, if you
need more than one disk) wherever your app wires up its dependencies:
use Vercy\Storage\Storage; $storage = new Storage( root: __DIR__ . '/storage/app', // absolute path — created automatically if missing urlPrefix: '/files', // used by url() to build links back to stored files maxUploadSize: 5 * 1024 * 1024, // bytes; enforced in store(); 0 disables the check allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], // [] = allow any type );
All four parameters accept named arguments, so you can skip the ones you're happy to leave at their default:
$storage = new Storage(root: __DIR__ . '/storage/app'); // everything else defaults
If you need a second disk — say, one for public avatars and one for private documents — just construct a second instance with different settings. There's no registry to register it in.
$avatars = new Storage(root: __DIR__ . '/storage/avatars', urlPrefix: '/avatars'); $documents = new Storage(root: __DIR__ . '/storage/private-docs', allowedMimeTypes: ['application/pdf']);
Usage
Basic file operations
These don't involve uploads at all — useful for writing logs, caching generated content, exporting reports, etc.
$storage->put('reports/2026-09.csv', $csvContents); $storage->exists('reports/2026-09.csv'); // true $storage->get('reports/2026-09.csv'); // raw contents $storage->size('reports/2026-09.csv'); // bytes $storage->mimeType('reports/2026-09.csv'); // "text/csv" (sniffed) $storage->delete('reports/2026-09.csv'); // true — also true if it was already gone
put() creates any missing intermediate directories automatically, so
$storage->put('a/b/c/file.txt', $data) works the first time, no mkdir() needed.
Handling a single file upload
A complete round trip — HTML form, PHP handler, and what to do with the result:
<form method="POST" action="/profile/avatar" enctype="multipart/form-data"> <input type="file" name="avatar" accept="image/*"> <button type="submit">Upload</button> </form>
use Vercy\Storage\Storage; use Vercy\Storage\UploadedFile; use Vercy\Storage\Exception\UploadValidationException; use Vercy\Storage\Exception\UploadFailedException; $storage = new Storage( root: __DIR__ . '/storage/app', allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxUploadSize: 2 * 1024 * 1024, // 2MB ); $file = UploadedFile::capture('avatar'); if ($file === null) { // The field wasn't submitted at all — different from an invalid/failed upload. http_response_code(422); exit('Please choose a file.'); } try { $path = $storage->store($file, 'avatars'); } catch (UploadValidationException $e) { // Too large, or not an allowed image type. $e->getMessage() is safe to show the user. http_response_code(422); exit($e->getMessage()); } catch (UploadFailedException $e) { // The upload itself errored out (partial upload, no tmp dir, etc.) or the // move failed. Log $e->getMessage(); don't necessarily show it to the user. error_log($e->getMessage()); http_response_code(500); exit('Something went wrong, please try again.'); } // Persist $path (e.g. "avatars/9f3a1c...c2.jpg") on the user's record in your database. echo 'Uploaded! View it at: ' . $storage->url($path);
Handling multiple files at once
For <input type="file" name="photos[]" multiple>, capture() returns an array of
UploadedFile instead of a single instance — check with is_array():
$photos = UploadedFile::capture('photos'); // array<UploadedFile>|UploadedFile|null $storedPaths = []; if (is_array($photos)) { foreach ($photos as $photo) { try { $storedPaths[] = $storage->store($photo, 'gallery'); } catch (UploadValidationException|UploadFailedException $e) { // Decide per-file: skip this one and continue, or abort the whole batch. error_log("Skipping {$photo->getClientOriginalName()}: {$e->getMessage()}"); } } }
This also works for nested/grouped inputs like name="gallery[0][image]" —
capture() returns a matching nested array of UploadedFile objects.
Replacing a file
A common pattern — e.g. swapping a user's avatar — is to delete the old file only after the new one has been stored successfully, so a failed upload never leaves the user without an avatar:
$oldPath = $user->avatar_path; // e.g. "avatars/old-file.jpg", or null $newPath = $storage->store(UploadedFile::capture('avatar'), 'avatars'); $user->avatar_path = $newPath; $user->save(); if ($oldPath !== null) { $storage->delete($oldPath); }
Listing files in a directory
$storage->files('avatars'); // ["avatars/1a2b...c3.jpg", "avatars/9f3a...c2.jpg", ...]
Not recursive — it only lists files directly inside the given directory, not
subdirectories. Returns [] for a directory that doesn't exist (no exception).
Serving files back
The library only handles storing files. How you serve them back depends on your app — two common approaches:
Through your app — works anywhere, zero web-server configuration needed. Good default, especially early on:
// e.g. a route like GET /files/{path} $path = $_GET['path'] ?? ''; try { $contents = $storage->get($path); } catch (\Vercy\Storage\Exception\FileNotFoundException) { http_response_code(404); exit('Not found'); } header('Content-Type: ' . $storage->mimeType($path)); header('Content-Length: ' . strlen($contents)); header('Cache-Control: public, max-age=31536000'); echo $contents;
Because fullPath() (used internally by get(), exists(), and mimeType()) rejects
any path containing .., this route is already safe against path-traversal attempts
via the path query parameter.
Directly from the web server — skips PHP entirely, better for high-traffic sites.
Point root at a directory inside (or symlinked into) your public webroot, and set
urlPrefix to match:
$storage = new Storage( root: __DIR__ . '/public/storage', // inside the webroot urlPrefix: '/storage', // matches the public path );
# if you'd rather keep the real files outside the webroot, symlink instead:
ln -s ../storage/app public/storage
Building links
$storage->url('avatars/9f3a1c...c2.jpg'); // "/files/avatars/9f3a1c...c2.jpg"
Just string concatenation with the configured urlPrefix — swap in your CDN domain by
setting urlPrefix to a full URL instead of a path, e.g.
urlPrefix: 'https://cdn.example.com/files'.
API Reference
Storage
new Storage( string $root, // absolute path files are written under (created if missing) string $urlPrefix = '/files', // prefix used by url() int $maxUploadSize = 5_242_880, // bytes; 0 disables the check array $allowedMimeTypes = [], // empty = allow any type )
| Method | Description |
|---|---|
store(UploadedFile $file, string $dir, ?string $name = null) |
Validate + move an upload into storage. Auto-names the file unless $name is given. Returns the stored relative path. |
put(string $path, string $contents) |
Write raw string contents. |
get(string $path) |
Read raw contents. Throws FileNotFoundException if missing. |
exists(string $path) |
bool |
delete(string $path) |
bool — returns true even if the file was already gone. |
size(string $path) |
Bytes, or 0 if missing. |
mimeType(string $path) |
Sniffed MIME type. Throws FileNotFoundException if missing. |
files(string $dir = '') |
Sorted list of relative file paths directly inside $dir (not recursive). |
fullPath(string $path) |
Absolute filesystem path. Throws InvalidPathException on an empty path or ... |
url(string $path) |
$urlPrefix . '/' . $path, for building a link back to the file. |
UploadedFile
| Method | Description |
|---|---|
UploadedFile::capture(string $key) |
Reads $_FILES[$key]. Returns null, a single UploadedFile, or a (possibly nested) array of them for grouped inputs. |
UploadedFile::fromArray(array $file) |
Wrap a raw $_FILES-shaped array yourself. |
UploadedFile::fake(string $path, ?string $originalName = null, ?string $mimeType = null) |
Wrap an existing file on disk as a fake upload — for tests. |
isValid() |
true only for a genuine, error-free upload (or a fake() pointing at a file that exists). |
getClientOriginalName(), getClientOriginalExtension(), getSize(), getError(), getErrorMessage() |
Self-explanatory. |
getMimeType() |
Sniffed from content, not the client-supplied header. |
hashName() |
Random, collision-proof filename preserving the original extension. |
Testing this package
composer install
composer test
Testing uploads in your application
Real uploads rely on move_uploaded_file(), which only works during an actual HTTP
request — is_uploaded_file() deliberately refuses to treat any other file as a valid
upload, even a real one sitting on disk. That's exactly why UploadedFile::fake()
exists: it bypasses that check so you can exercise your own upload-handling code
(controllers, form handlers, etc.) in a normal PHPUnit test, without spinning up a real
HTTP request.
use Vercy\Storage\Storage; use Vercy\Storage\UploadedFile; public function testAvatarUploadIsStoredAndLinkedToTheUser(): void { $storage = new Storage(root: sys_get_temp_dir() . '/test-storage'); $upload = UploadedFile::fake( path: __DIR__ . '/fixtures/sample-avatar.jpg', originalName: 'avatar.jpg', mimeType: 'image/jpeg', ); $path = $storage->store($upload, 'avatars'); $this->assertTrue($storage->exists($path)); $this->assertStringStartsWith('avatars/', $path); }
To test validation failures (oversized files, disallowed types), just configure the
Storage instance restrictively for that test and assert the exception:
$storage = new Storage(root: $tempDir, maxUploadSize: 100); // 100 bytes $upload = UploadedFile::fake($pathToA200ByteFixture); $this->expectException(\Vercy\Storage\Exception\UploadValidationException::class); $storage->store($upload, 'uploads');
Security notes
- Every path passed to
fullPath()(and thereforeget(),put(),exists(),delete(),mimeType()) is rejected if it's empty or contains... - MIME-type checking uses
mime_content_type()(viaext-fileinfo) to sniff the file's actual content rather than trusting the browser-suppliedContent-Typeheader — but this is a basic check, not a substitute for antivirus scanning or a hardened upload pipeline if you're accepting files from untrusted users in production. - PHP's own
upload_max_filesize/post_max_sizeini settings cap uploads before this library (or your code) ever runs — raise those too if you need larger files than your server currently allows.
License
MIT — see LICENSE.