Search by

krafsys / vercy-storage

krafsys

A tiny, framework-agnostic local file storage and upload-handling library for PHP.

Package info

github.com/krafsys/vercy-storage

pkg:composer/krafsys/vercy-storage

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-22 03:54 UTC

This package is auto-updated.

Last update: 2026-09-22 04:14:08 UTC


README

Tests Latest Version License PHP Version

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 $_FILES shape, 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, which move_uploaded_file() otherwise requires.
  • Path-traversal safe — every path is checked before it touches the filesystem.
  • Zero dependencies — just PHP's fileinfo extension (bundled with PHP by default).
  • A clear exception hierarchy — catch StorageException generically, 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 therefore get(), put(), exists(), delete(), mimeType()) is rejected if it's empty or contains ...
  • MIME-type checking uses mime_content_type() (via ext-fileinfo) to sniff the file's actual content rather than trusting the browser-supplied Content-Type header — 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_size ini 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.