Search by

phatnt93 / php-upload-helper

phatnt93

A modern, secure and fluent PHP file upload and image manipulation library with first-class multi-file array support, inspired by class.upload.php.

v1.0.0 2026-10-16 02:35 UTC

This package is auto-updated.

Last update: 2026-10-16 02:40:29 UTC


README

English | Tiếng Việt

Latest Version on Packagist GitHub Tests Action Status PHP Version License: MIT Total Downloads

A modern, secure, and fluent PHP file upload and image processing library with first-class multi-file array support, inspired by the legendary verot/class.upload.php.

Table of Contents

  1. Why Use PHP Upload Helper?
  2. Key Advantages Over class.upload.php
  3. System Requirements & Setup
  4. Installation
  5. Quick Start
  6. Real-World Practical Examples
  7. Comprehensive Action Pipeline & API Reference
  8. Framework Integration Guides
  9. Internationalization & Error Codes Catalog
  10. Security Best Practices & Server Hardening
  11. Feature Comparison with verot/class.upload.php
  12. Using with AI Coding Agents
  13. Callback Signatures & Extension Contracts
  14. FAQ & Troubleshooting
  15. Automated Testing & Code Quality
  16. License & Credits

1. Why Use PHP Upload Helper?

Handling file uploads in native PHP is notorious for friction and security hazards:

  • Chaotic Array Transposition: Native $_FILES splits multiple files into fragmented sub-arrays ($_FILES['photos']['name'][$i]), forcing developers to write repetitive matrix restructuring loops.
  • Client Header Trust Vulnerabilities: Checking $_FILES['file']['type'] is insecure because it is an untrusted client header trivially spoofed with cURL or Burp Suite.
  • Dangerous Extensions & Code Execution: Attackers easily bypass naive blacklists with .phtml, .php5, .phar, double extensions (shell.php.png), or null-byte injections (shell.php\0.jpg).
  • Stored XSS in SVGs: Uploaded SVGs containing <script> or inline onload handlers execute scripts in users' browsers when rendered directly.
  • Orphan File Pollution: In batch uploads, if 9 out of 10 files succeed and the 10th fails, native PHP leaves orphan files cluttering storage.
  • Verbose GD Syntax: Resizing or watermarking images using procedural GD functions requires dozens of boilerplate lines.

php-upload-helper provides an all-in-one, modern, type-safe Fluent Pipeline that encapsulates input normalization, deep binary sniffing, SVG sanitization, image processing, atomic storage, and batch rollback into elegant, chainable PHP 8.1+ syntax.

2. Key Advantages Over class.upload.php

Feature Native PHP (move_uploaded_file) verot/class.upload.php phatnt93/php-upload-helper
Multi-File Array Handling ❌ None (Requires manual matrix transposition) ❌ None (Requires manual matrix loops) 🟢 First-class native support for flat & nested arrays
PHP Compatibility Dependent on server PHP version Legacy PHP 4/5/7/8 procedural class 🟢 PHP 8.1 - 8.4+ with strict types and PSR-4
Architecture Low-level procedural primitives Monolithic (~6,000 lines single file) 🟢 Modular Action Pipeline (SRP, clean SOLID design)
MIME Validation Insecure client type header Blacklist & basic finfo 🟢 Strict Whitelist & Magic Bytes Sniffing
SVG Sanitization ❌ None (Stored XSS vulnerability) ❌ Vulnerable to Stored XSS 🟢 Built-in XML/SVG script sanitizer
Batch Rollback ❌ Manual file cleanup on error ❌ None (Leaves orphan files on failure) 🟢 All-or-nothing rollback on failure (rollbackOnError)
Alternative Inputs Limited to $_FILES Limited 🟢 $_FILES, PSR-7, Base64 URI, Remote URL, Local path
Image Drivers Procedural boilerplate Hardcoded GD calls 🟢 Driver Abstraction (GD default, Imagick ready)
Server Hardening ❌ Developer responsibility Partial (Extension blacklists) 🟢 Pixel flood, SSRF guard, double-ext, null-byte

3. System Requirements & Setup

  • PHP Compatibility: PHP >= 8.1 (tested on PHP 8.1, 8.2, 8.3, and 8.4 64-bit).
  • Core Extensions:
    • ext-fileinfo: Required for magic bytes binary MIME type inspection.
    • ext-mbstring: Required for multibyte string manipulation, Vietnamese slug conversion, and UTF-8 font text rendering.
    • ext-gd: Required for the default image manipulation engine.
  • Optional Extensions:
    • ext-imagick: For advanced image manipulation and vector rendering.
    • ext-exif: For automatic mobile photo orientation based on EXIF camera tags.

4. Installation

Install via Composer into your project:

composer require phatnt93/php-upload-helper

Ensure your php.ini has adequate upload boundaries:

file_uploads = On
upload_max_filesize = 50M
post_max_size = 60M
max_file_uploads = 50
memory_limit = 256M

5. Quick Start

Single File Upload

use UploadHelper\Upload;

// Process a single file from $_FILES['avatar']
$result = Upload::file($_FILES['avatar'])
    ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
    ->maxSize('5M')
    ->slugName()
    ->resize(400, 400, mode: 'crop')
    ->toWebp(quality: 85)
    ->saveTo('/var/www/uploads/avatars');

if ($result->isSuccess()) {
    echo "Saved to: " . $result->getFilePath();
    echo "Filename: " . $result->getFilename();
    echo "File size: " . $result->getFormattedSize();
} else {
    echo "Upload failed: " . $result->getErrorMessage();
}

Multi-File Array Upload (First-Class Support)

Say goodbye to manual foreach matrix restructuring for <input type="file" name="photos[]" multiple>!

use UploadHelper\Upload;

// Feed the raw $_FILES array directly:
$batch = Upload::files($_FILES['photos'])
    ->allowedExtensions(['jpg', 'png', 'webp'])
    ->maxSize('10M')
    ->each(function ($builder, $index) {
        $builder->randomName(16)
                ->resize(1200, 800, mode: 'fit')
                ->watermarkImage('/path/to/watermark.png', position: 'bottom-right', opacity: 70)
                ->saveTo('/var/www/uploads/gallery');
    })
    ->rollbackOnError(true) // Automatically deletes saved files if any file in the batch fails
    ->process();

if ($batch->allSuccessful()) {
    echo "All " . count($batch->getSuccessful()) . " files uploaded successfully!";
    foreach ($batch->getSuccessful() as $file) {
        echo "- " . $file->getFilename() . " (" . $file->getFormattedSize() . ")\n";
    }
} else {
    echo "Batch failed with " . count($batch->getFailed()) . " error(s):\n";
    foreach ($batch->getFailed() as $failed) {
        echo "- " . $failed->getOriginalName() . ": " . $failed->getErrorMessage() . "\n";
    }
}

Deeply Nested Multi-Dimensional Form Upload

Supports complex form inputs like <input type="file" name="catalog[category][items][0][attachments][]">:

use UploadHelper\Upload;

// Automatically flattens and normalizes any nested upload array:
$batch = Upload::files($_FILES['catalog'])
    ->allowedExtensions(['pdf', 'docx', 'jpg', 'png'])
    ->maxSize('20M')
    ->saveTo('/var/www/uploads/catalog')
    ->process();

foreach ($batch->getSuccessful() as $processed) {
    echo "Field: " . $processed->getFieldKey() . "\n";
    echo "Original: " . $processed->getOriginalName() . " -> Saved: " . $processed->getFilename() . "\n";
}

6. Real-World Practical Examples

Example 1: User Profile Avatar with Automatic Crop & WebP

Enforce a clean square avatar, strip harmful metadata, resize to 300x300, and save in modern WebP:

use UploadHelper\Upload;

$result = Upload::file($_FILES['avatar'])
    ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
    ->allowedMimes(['image/jpeg', 'image/png', 'image/webp'])
    ->maxSize('2M')
    ->dimensionBetween(minW: 100, minH: 100, maxW: 4000, maxH: 4000)
    ->autoOrient()                                // Fix orientation for mobile camera selfies
    ->resize(300, 300, mode: 'crop')             // Smart center crop
    ->toWebp(quality: 85)                        // High-efficiency WebP compression
    ->name('user_' . $userId . '_avatar')
    ->overwrite(true)                            // Update existing avatar
    ->saveTo('/var/www/app/public/uploads/avatars');

if ($result->isSuccess()) {
    $avatarUrl = '/uploads/avatars/' . $result->getFilename();
    // Update database with $avatarUrl
}

Example 2: E-Commerce Product Gallery with Atomic Batch Rollback

Upload product photos in batch, apply watermarks, convert to progressive JPEG, and rollback if any photo is invalid:

use UploadHelper\Upload;

$batch = Upload::files($_FILES['product_images'])
    ->allowedExtensions(['jpg', 'jpeg', 'png'])
    ->maxSize('8M')
    ->each(function ($builder, $i) use ($productId) {
        $builder->name("product_{$productId}_img_" . ($i + 1))
                ->resize(1200, 1200, mode: 'fit')
                ->watermarkImage(
                    watermarkPath: '/assets/img/watermark-logo.png',
                    position: 'bottom-right',
                    opacity: 75,
                    offsetX: 20,
                    offsetY: 20
                )
                ->toJpeg(quality: 88)
                ->saveTo("/var/www/app/storage/products/{$productId}");
    })
    ->rollbackOnError(true) // Crucial: clean disk if any file exceeds size or is corrupt
    ->process();

if ($batch->allSuccessful()) {
    foreach ($batch->getSuccessful() as $item) {
        $db->insertProductImage($productId, $item->getFilename());
    }
} else {
    // Zero disk pollution: all previously saved files were deleted automatically!
    $errors = $batch->getErrors();
    $_SESSION['flash_error'] = "Upload failed: " . implode(', ', array_column($errors, 'message'));
}

Example 3: Secure Document (PDF/Docx) Storage with Slug Naming

Safely accept resumes or contracts, sanitize filenames (including Vietnamese accents), and auto-increment duplicates:

use UploadHelper\Upload;

$result = Upload::file($_FILES['resume'])
    ->allowedExtensions(['pdf', 'docx', 'doc'])
    ->allowedMimes([
        'application/pdf',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'application/msword',
    ])
    ->maxSize('15M')
    ->slugName() // "Báo cáo tài chính quý 3.pdf" -> "bao-cao-tai-chinh-quy-3.pdf"
    ->autoIncrementDuplicate(true) // If exists: "bao-cao-tai-chinh-quy-3_1.pdf"
    ->saveTo('/var/www/app/storage/documents', chmod: 0750);

if ($result->isSuccess()) {
    echo "Saved safely at: " . $result->getFilePath();
}

Example 4: Sanitized SVG Vector Upload (Stored-XSS Immunity)

SVGs can carry malicious <script> tags. With sanitizeSvg(true), the parser strips all scripts, event handlers (onload, onclick), and javascript: URIs before saving:

use UploadHelper\Upload;

$result = Upload::file($_FILES['company_logo'])
    ->allowedExtensions(['svg'])
    ->allowedMimes(['image/svg+xml'])
    ->maxSize('1M')
    ->sanitizeSvg(true) // Strips <script>, <foreignObject>, onload, onerror, javascript:
    ->uuidName()        // Generates UUID v4 name
    ->saveTo('/var/www/app/public/logos');

if ($result->isSuccess()) {
    echo "Clean, XSS-free SVG saved: " . $result->getFilename();
}

Example 5: REST API / SPA Upload via Base64 or PSR-7

Accept image uploads from mobile apps, Vue/React Canvas, or PSR-7 HTTP requests:

use UploadHelper\Upload;

// 1. Mobile / Web Canvas Base64 Data URI:
$base64String = $requestPayload['avatar_base64']; // "data:image/png;base64,iVBORw0KGgo..."
$result = Upload::fromBase64($base64String, filename: 'canvas_drawing.png')
    ->maxSize('3M')
    ->resize(500, 500, mode: 'fit')
    ->toWebp(quality: 80)
    ->saveTo('/uploads/drawings');

// 2. PSR-7 UploadedFile (Slim, Laminas, Nyholm, Guzzle):
$psr7File = $request->getUploadedFiles()['profile_picture'];
$result = Upload::fromPsr7($psr7File)
    ->allowedExtensions(['jpg', 'png'])
    ->saveTo('/uploads/profiles');

// 3. Download from Remote URL:
$result = Upload::fromUrl('https://images.unsplash.com/photo-1579783900882-c0d3dad7b119')
    ->resize(800, 600)
    ->saveTo('/uploads/unsplash');

7. Comprehensive Action Pipeline & API Reference

Upload Factory API

Entry points from the UploadHelper\Upload facade:

// Standard Web Form Input
$builder = Upload::file($_FILES['avatar']);             // Single $_FILES element
$batch   = Upload::files($_FILES['photos']);            // Multi-file $_FILES element
$batch   = Upload::fromGlobals();                       // All $_FILES at once

// Alternative Sources
$builder = Upload::fromPath('/path/to/local/file.jpg');
$builder = Upload::fromBase64('data:image/png;base64,iVBORw0KGgo...', 'avatar.png');
$builder = Upload::fromUrl('https://example.com/asset.png', timeout: 15, maxDownloadBytes: 52_428_800);
$builder = Upload::fromPsr7($psr7UploadedFile);

Security & Validation Layer

All validation methods are available on UploadBuilder and BatchUploadBuilder:

Method Description Example
allowedExtensions(array $exts) Strict whitelist of allowed extensions (lowercase). ->allowedExtensions(['jpg', 'png', 'pdf'])
allowedMimes(array $mimes) Whitelist of MIME types validated via magic bytes. ->allowedMimes(['image/jpeg', 'image/png'])
maxSize(int|string $maxSize) Maximum file size in bytes or human string (K, M, G). ->maxSize('10M') or ->maxSize(10485760)
minSize(int|string $minSize) Minimum file size in bytes or human string. ->minSize('5K')
dimensionBetween(?int $minW, ?int $minH, ?int $maxW, ?int $maxH) Enforce bounding box constraints on images. ->dimensionBetween(minW: 200, maxW: 1920)
sanitizeSvg(bool $sanitize = true) Enable deep DOM XML sanitization to prevent Stored XSS. ->sanitizeSvg(true)
maxPixels(int $maxPixels) Prevent Decompression Bomb DoS by limiting max megapixels. ->maxPixels(40_000_000)
maxImageMemory(int|string $maxMemory) Limit estimated uncompressed RGBA RAM before GD loading. ->maxImageMemory('128M')
blockPdfJavaScript(bool $block = true) Block active JavaScript, /Launch, and /EmbeddedFiles in PDF. ->blockPdfJavaScript(true)
blockOfficeMacros(bool $block = true) Block VBA Macros (vbaProject.bin) in Office documents. ->blockOfficeMacros(true)
scanner(ScannerInterface $scanner) Real-time Antivirus scan via ClamAV daemon socket. ->scanner(new ClamAvScanner())

File Naming Strategies

Method Behavior Example Output
slugName() Converts original name to safe ASCII slug (supports Vietnamese). Ảnh_đẹp 2026.png -> anh-dep-2026.png
uuidName() Generates standard UUID v4. 550e8400-e29b-41d4-a716-446655440000.png
randomName(int $len = 16) Cryptographically secure hex string. a9b3f021e78c45d2.png
timestampName() Date and microsecond timestamp. 20261015_153045_8912.png
hashName(string $algo = 'sha256', int $len = 64) Content-addressable hash name of file body. e3b0c44298fc1c14...png
sha256Name(int $len = 64) Shortcut for SHA-256 hash naming. a8b3c4...png
name(string|Closure $name) Set fixed name or compute dynamic name via Closure. ->name('cover_image')
prefix(string $prefix) Prepends prefix to filename. ->prefix('thumb_')
suffix(string $suffix) Appends suffix before extension. ->suffix('_optimized')

Storage Engine & Collision Handling

Method Description
saveTo(string $destinationDir, int $chmod = 0755) Sets target directory, creates recursively with $chmod, and executes save.
overwrite(bool $overwrite = true) Replaces destination file if it already exists.
autoIncrementDuplicate(bool $auto = true) Automatically appends sequential suffix (_1, _2) if destination exists.
hardenDestination(bool $harden = true) Automatically generates .htaccess, web.config, and index.html to block script execution.

Image Manipulation (Resize Modes, Crop, Rotate, Watermark)

1. Resize Modes

$builder->resize(800, 600, mode: 'fit', background: '#FFFFFF');
  • fit (Default): Proportional scaling so the whole image fits inside $width × $height. Never distorts aspect ratio.
  • crop: Scales and centers the image to completely fill the $width × $height box, cropping excess parts.
  • pad: Fits the image proportionally inside the box and fills the outer space with $background (e.g. '#FFFFFF').
  • stretch: Distorts the image to match the exact dimensions.
  • withoutEnlarging(true): Prevents upscaling if original image is smaller than target dimensions.

2. Cropping, Rotating & Orientation

$builder->crop(400, 300, x: 10, y: 10);                           // Freeform rectangle
$builder->rotate(90.0, background: '#000000');                    // Custom angle rotation
$builder->flipHorizontal();                                      // Horizontal mirror
$builder->flipVertical();                                        // Vertical mirror
$builder->autoOrient();                                          // Read EXIF camera orientation
$builder->stripExif(keepOrientation: true);                     // Strip GPS and camera metadata for user privacy

3. Watermarking

// Image Overlay:
$builder->watermarkImage(
    watermarkPath: '/path/to/watermark.png',
    position: 'bottom-right', // 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'
    opacity: 80,              // 0 to 100%
    offsetX: 15,
    offsetY: 15,
);

// TrueType Text Watermark:
$builder->watermarkText(
    text: '© 2026 MyBrand',
    fontPath: '/path/to/font.ttf',   // Path to .ttf font file (uses built-in font if omitted)
    size: 16,
    color: '#FFFFFF',                // Hex color
    position: 'bottom-right',
    background: '#000000',           // Optional background padding block
    backgroundOpacity: 50,
    offsetX: 10,
    offsetY: 10,
);

Format Conversion & Compression

Method Output Format Key Options
toWebp(int $quality = 85) WebP $quality (1 - 100). Highly recommended for modern web.
toJpeg(int $quality = 85) JPEG $quality (1 - 100). Automatically saves in progressive format.
toPng() PNG Lossless compression with alpha channel preservation.
toAvif(int $quality = 80) AVIF Next-gen image format (requires PHP 8.1+ with AVIF GD support).

Color Filters & Visual Effects

$builder->grayscale();       // Convert to black and white
$builder->brightness(20);    // Brightness: -255 to +255
$builder->contrast(-10);     // Contrast: -100 to +100
$builder->blur();            // Gaussian blur
$builder->sharpen();         // Unsharp mask filter
$builder->invert();          // Invert image colors

SingleResult & ProcessedFile API

Calling ->saveTo() or ->process() on UploadBuilder returns a SingleResult:

$result = Upload::file($_FILES['photo'])->saveTo('/uploads');

if ($result->isSuccess()) {
    $file = $result->getFile(); // Instance of ProcessedFile
    
    echo $file->getOriginalName();      // e.g. "My Picture.png"
    echo $file->getFilename();          // e.g. "my-picture.webp"
    echo $file->getSavedPath();         // e.g. "/uploads/my-picture.webp"
    echo $file->getFilePath();          // Alias to getSavedPath()
    echo $file->getMime();              // e.g. "image/webp"
    echo $file->getExtension();          // e.g. "webp"
    echo $file->getSize();              // Size in bytes
    echo $file->getFormattedSize();     // e.g. "1.42 MB"
    echo $file->getWidth();             // e.g. 1200
    echo $file->getHeight();            // e.g. 800
    print_r($file->getDimensions());    // ['width' => 1200, 'height' => 800]
    print_r($file->toArray());          // Export array
} else {
    echo $result->getErrorMessage();    // Translated error message
    echo $result->getErrorCode();       // e.g. "ERR_FILE_TOO_LARGE"
    print_r($result->getErrors());      // ['code' => ..., 'message' => ..., 'context' => ...]

    // Technical detail for logs only (English, may contain paths) - never show it to end users:
    error_log($result->getErrors()['context']['reason'] ?? '');
}

BatchUploadBuilder & BatchResult API

Calling ->process() on BatchUploadBuilder returns a BatchResult:

$batch = Upload::files($_FILES['photos'])
    ->saveTo('/uploads/gallery')
    ->rollbackOnError(true)
    ->process();

$batch->allSuccessful();    // Returns true if all files succeeded
$batch->hasFailures();      // Returns true if one or more files failed
$batch->count();            // Total processed files count

// Access lists:
$batch->getSuccessful();    // Array of ProcessedFile objects
$batch->getFailed();        // Array of SingleResult error objects
$batch->getErrors();        // Array of error details keyed by original name

Multi-Variant Pipeline (Thumbnails & Previews)

Generate multiple derivative sizes, formats, or watermarked variants from a single uploaded file without re-uploading:

use UploadHelper\Upload;

$result = Upload::file($_FILES['avatar'])
    ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
    ->slugName()
    ->destination('/var/www/uploads/avatars/original') // Main/Original file destination
    // Variant 1: Square thumbnail for profile lists
    ->variant('thumb', function ($v) {
        $v->resize(150, 150, mode: 'crop')
          ->toWebp(quality: 80)
          ->saveTo('/var/www/uploads/avatars/thumbs');
    })
    // Variant 2: Mid-size preview for profile header
    ->variant('preview', function ($v) {
        $v->resize(600, 400, mode: 'fit')
          ->toWebp(quality: 85)
          ->saveTo('/var/www/uploads/avatars/previews');
    })
    ->process();

if ($result->isSuccess()) {
    echo "Original: " . $result->getFilePath() . "\n";
    
    // Access variants easily:
    $thumb = $result->getVariant('thumb');
    echo "Thumbnail: " . $thumb->getSavedPath() . " (" . $thumb->getWidth() . "x" . $thumb->getHeight() . ")\n";
    
    $preview = $result->getVariant('preview');
    echo "Preview: " . $preview->getSavedPath() . "\n";
    
    // Access all variants:
    foreach ($result->getVariants() as $name => $variantFile) {
        echo "- Variant '{$name}': " . $variantFile->getFilename() . "\n";
    }
}

Lifecycle Hooks & Event Callbacks

Attach hooks to intercept and react during the upload lifecycle:

Upload::file($_FILES['contract'])
    ->beforeProcess(function (UploadedFile $uploadedFile): void {
        // Runs before validation & processing
        Log::info("Starting upload for: " . $uploadedFile->getOriginalName());
    })
    ->onSuccess(function (ProcessedFile $processedFile, SingleResult $result): void {
        // Runs only when upload succeeded
        Database::recordUpload($processedFile->getFilename(), $processedFile->getSize());
        Notification::send("Upload complete!");
    })
    ->onError(function (string $errorMessage, string $errorCode, SingleResult $result): void {
        // Runs when validation or processing failed
        Log::warning("Upload failed [{$errorCode}]: {$errorMessage}");
    })
    ->afterProcess(function (SingleResult $result): void {
        // Always runs after process finishes (success or failure)
        Metrics::increment('uploads.attempted');
    })
    ->saveTo('/uploads/contracts');

8. Framework Integration Guides

Vanilla PHP Web Form

<!-- index.html -->
<form action="upload.php" method="POST" enctype="multipart/form-data">
    <label>Select Photos:</label>
    <input type="file" name="photos[]" multiple required>
    <button type="submit">Upload Gallery</button>
</form>
// upload.php
require_once __DIR__ . '/vendor/autoload.php';

use UploadHelper\Upload;

$batch = Upload::files($_FILES['photos'])
    ->allowedExtensions(['jpg', 'png', 'webp'])
    ->maxSize('5M')
    ->slugName()
    ->resize(1200, 800)
    ->toWebp(85)
    ->saveTo(__DIR__ . '/public/uploads')
    ->rollbackOnError(true)
    ->process();

if ($batch->allSuccessful()) {
    header('Location: /gallery.php?success=1');
} else {
    echo "Errors: " . json_encode($batch->getErrors());
}

Laravel Integration

In your Laravel Controller:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use UploadHelper\Upload;

class MediaController extends Controller
{
    public function uploadAvatar(Request $request)
    {
        $request->validate(['avatar' => 'required|file']);

        // Feed Symfony/Laravel UploadedFile directly via path:
        $file = $request->file('avatar');
        
        $result = Upload::fromPath($file->getRealPath(), $file->getClientOriginalName())
            ->allowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
            ->maxSize('5M')
            ->resize(400, 400, mode: 'crop')
            ->toWebp()
            ->saveTo(public_path('uploads/avatars'));

        if (!$result->isSuccess()) {
            return back()->withErrors(['avatar' => $result->getErrorMessage()]);
        }

        auth()->user()->update(['avatar' => $result->getFilename()]);
        return back()->with('status', 'Avatar updated!');
    }
}

Symfony Integration

In your Symfony Controller:

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use UploadHelper\Upload;

class UploadController extends AbstractController
{
    public function handleUpload(Request $request): Response
    {
        /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile */
        $uploadedFile = $request->files->get('document');

        $result = Upload::fromPath($uploadedFile->getRealPath(), $uploadedFile->getClientOriginalName())
            ->allowedExtensions(['pdf', 'docx'])
            ->maxSize('10M')
            ->slugName()
            ->saveTo($this->getParameter('kernel.project_dir') . '/var/uploads');

        if ($result->isSuccess()) {
            return $this->json(['status' => 'success', 'filename' => $result->getFilename()]);
        }

        return $this->json(['error' => $result->getErrorMessage()], 422);
    }
}

Phalcon Integration

In your Phalcon Controller:

namespace App\Controllers;

use Phalcon\Mvc\Controller;
use UploadHelper\Upload;

class FileController extends Controller
{
    public function uploadAction()
    {
        if ($this->request->hasFiles()) {
            $batch = Upload::fromGlobals($_FILES)
                ->allowedExtensions(['jpg', 'png', 'webp'])
                ->maxSize('8M')
                ->saveTo($this->config->application->uploadDir)
                ->process();

            if ($batch->allSuccessful()) {
                $this->flash->success("Uploaded " . count($batch->getSuccessful()) . " files!");
            } else {
                $this->flash->error("Failed to upload some files.");
            }
        }
    }
}

Slim / PSR-7 Frameworks

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use UploadHelper\Upload;

$app->post('/api/upload', function (Request $request, Response $response) {
    $uploadedFiles = $request->getUploadedFiles();
    $avatar = $uploadedFiles['avatar'] ?? null;

    if (!$avatar) {
        $response->getBody()->write(json_encode(['error' => 'No file uploaded']));
        return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
    }

    $result = Upload::fromPsr7($avatar)
        ->allowedExtensions(['jpg', 'png', 'webp'])
        ->maxSize('2M')
        ->resize(300, 300, mode: 'crop')
        ->toWebp()
        ->saveTo(__DIR__ . '/public/uploads');

    $response->getBody()->write(json_encode([
        'success'  => $result->isSuccess(),
        'filename' => $result->getFilename(),
        'error'    => $result->getErrorMessage(),
    ]));
    
    return $response->withHeader('Content-Type', 'application/json');
});

9. Internationalization & Error Codes Catalog

Setting the Active Locale

The default locale is English (en); Vietnamese (vi) ships with the library. Missing keys fall back to the active locale → en → the error code itself.

use UploadHelper\I18n\Translator;

// Switch the locale globally (format: "xx", "xxx" or "xx_YY"; anything else throws InvalidLocaleException):
Translator::setLocale('vi');

// Register or override translations for any locale:
Translator::addTranslations('es', [
    'ERR_FILE_TOO_LARGE'        => 'El archivo ({formatted_actual}) supera el máximo permitido ({formatted_max}).',
    'ERR_EXTENSION_NOT_ALLOWED' => 'La extensión .{extension} no está permitida.',
]);
Translator::setLocale('es');

Translator::has('ERR_FILE_TOO_LARGE', 'es'); // true

Messages returned by SingleResult::getErrorMessage() are translated with the active locale. Exceptions thrown inside the library always carry an English message; it is kept in getErrors()['context']['reason'] for logging. Unexpected internal errors are reported as ERR_INTERNAL with a generic message so that paths and internals never leak to end users.

Standardized Error Codes

The full message catalog lives in src/I18n/en.php and src/I18n/vi.php; both always contain the same keys (enforced by tests). Placeholders such as {extension} are filled from the error context.

Group Error codes
PHP upload errors ERR_UPLOAD_INI_SIZE, ERR_UPLOAD_FORM_SIZE, ERR_UPLOAD_PARTIAL, ERR_UPLOAD_NO_FILE, ERR_UPLOAD_NO_TMP_DIR, ERR_UPLOAD_CANT_WRITE, ERR_UPLOAD_EXTENSION, ERR_UPLOAD_UNKNOWN, ERR_NO_FILE
Extension & MIME ERR_EXTENSION_NOT_ALLOWED, ERR_MIME_NOT_ALLOWED, ERR_SECURITY_DANGEROUS_EXTENSION, ERR_SECURITY_DOUBLE_EXTENSION, ERR_SECURITY_NULL_BYTE, ERR_PATH_TRAVERSAL
Size & dimensions ERR_FILE_TOO_LARGE, ERR_FILE_TOO_SMALL, ERR_NOT_AN_IMAGE, ERR_DIMENSION_MIN_WIDTH, ERR_DIMENSION_MAX_WIDTH, ERR_DIMENSION_MIN_HEIGHT, ERR_DIMENSION_MAX_HEIGHT, ERR_DIMENSION_EXACT_WIDTH, ERR_DIMENSION_EXACT_HEIGHT, ERR_DIMENSION_RATIO, ERR_IMAGE_PIXEL_FLOOD
Content security ERR_INVALID_SVG_XML, ERR_PDF_JAVASCRIPT_DETECTED, ERR_PDF_LAUNCH_DETECTED, ERR_PDF_EMBEDDED_FILES_DETECTED, ERR_OFFICE_MACRO_DETECTED, ERR_VIRUS_DETECTED, ERR_SCANNER_ERROR
Remote URL (SSRF) ERR_SSRF_INVALID_URL, ERR_SSRF_DISALLOWED_SCHEME, ERR_SSRF_DISALLOWED_PORT, ERR_SSRF_DNS_RESOLVE_FAILED, ERR_SSRF_BLOCKED, ERR_URL_DOWNLOAD_FAILED, ERR_URL_DOWNLOAD_TOO_LARGE, ERR_URL_TOO_MANY_REDIRECTS
Storage & processing ERR_FILE_EXISTS, ERR_VARIANT_FAILED, ERR_PROCESS_FAILED, ERR_VALIDATION, ERR_INTERNAL

10. Security Best Practices & Server Hardening

  1. Store Uploads Outside the Web Root: Always store private files outside the public document root (e.g., /storage/uploads instead of /public/uploads) and serve them via a secure streaming controller with access authorization.

  2. Disable Script Execution in Upload Folders: If serving directly from Nginx, disable PHP execution in the public upload directory:

    # Nginx server block
    location ^~ /uploads/ {
        location ~ \.(php|phtml|phar|sh|pl|py|cgi)$ {
            deny all;
            return 403;
        }
    }

    For Apache, place an .htaccess file inside your upload directory:

    # Prevent executing any scripts in the uploads folder
    php_flag engine off
    <FilesMatch "\.(php|phtml|phar|sh|pl|cgi)$">
        Order Deny,Allow
        Deny from all
    </FilesMatch>
  3. Always Whitelist Extensions: Never use blacklists. Attackers continuously discover new executable suffixes (e.g., .php7, .pht, .shtml, .inc). Always pass explicit whitelists via ->allowedExtensions(['jpg', 'png', 'pdf']).

11. Feature Comparison with verot/class.upload.php

verot/class.upload.php Property UploadHelper Fluent Action
$foo->file_safe_name = true; ->slugName()
$foo->file_new_name_body = 'id'; ->name('id')
$foo->file_name_body_pre = 'pre_'; ->prefix('pre_')
$foo->file_name_body_add = '_post'; ->suffix('_post')
$foo->file_auto_rename = true; ->autoIncrementDuplicate()
$foo->file_overwrite = true; ->overwrite(true)
$foo->file_max_size = 5242880; ->maxSize('5M')
$foo->allowed = ['image/*']; ->allowedMimes(['image/jpeg', 'image/png'])
$foo->image_resize = true; ->resize($w, $h, mode: 'fit')
$foo->image_ratio_crop = true; ->resize($w, $h, mode: 'crop')
$foo->image_ratio_fill = true; ->resize($w, $h, mode: 'pad')
$foo->image_ratio_no_zoom_in = true; ->withoutEnlarging()
$foo->image_watermark = 'wm.png'; ->watermarkImage('/path/wm.png')
$foo->image_text = 'Copyright'; ->watermarkText('Copyright')
$foo->image_convert = 'webp'; ->toWebp()
$foo->image_greyscale = true; ->grayscale()
$foo->image_brightness = 20; ->brightness(20)
$foo->image_contrast = -10; ->contrast(-10)
$foo->dir_auto_create = true; Handled automatically in ->saveTo()

12. Using with AI Coding Agents

If you use AI coding assistants (Claude Code, GitHub Copilot, Cursor, ChatGPT, Codex) in your project, you can provide them with machine-optimized context files:

Prompt Example for Your Project Rules

Add this line to your project's .cursorrules, CLAUDE.md, or AGENTS.md:

"When implementing file uploads or image processing in PHP, use phatnt93/php-upload-helper. Refer to vendor/phatnt93/php-upload-helper/llms.txt for fluent API usage and validation recipes."

13. Callback Signatures & Extension Contracts

All callback signatures in php-upload-helper are strictly typed and committed to backwards-compatibility within major versions:

Method / Hook Callback Signature Timing / Purpose
variant(string $name, callable $configurator) callable(UploadBuilder $builder): void Configures a derivative image variant pipeline
each(callable $callback) (Batch) callable(UploadBuilder $builder, int $index): void Configures per-file rules in a batch
beforeProcess(callable $callback) (Single) callable(UploadedFile $file): void Executed prior to validation and pipeline actions
onSuccess(callable $callback) (Single) callable(ProcessedFile $file, SingleResult $result): void Executed only when file is successfully saved
onError(callable $callback) (Single) callable(string $errorMessage, string $errorCode, SingleResult $result): void Executed when validation or processing fails
afterProcess(callable $callback) (Single) callable(SingleResult $result): void Executed after pipeline finishes (success or failure)
beforeProcess(callable $callback) (Batch) callable(FileCollection $collection): void Executed prior to processing batch
onSuccess(callable $callback) (Batch) callable(BatchResult $result): void Executed when all files in batch succeed
onError(callable $callback) (Batch) callable(BatchResult $result): void Executed when one or more files in batch fail
afterProcess(callable $callback) (Batch) callable(BatchResult $result): void Executed after batch finishes (success or failure)
name(Closure $custom) Closure(UploadedFile $file): string Custom dynamic filename generator

Custom Extension Interfaces

To extend the library with custom logic:

  • Custom Actions: Implement UploadHelper\Contracts\ActionInterface (getName(): string, execute(UploadedFile $file, ActionContext $context): UploadedFile).
  • Custom Malware Scanners: Implement UploadHelper\Contracts\ScannerInterface (scan(string $filePath): bool, getThreatName(): ?string).
  • Custom Storage Backends: Implement UploadHelper\Contracts\StorageInterface (save(...), delete(...), exists(...)).
  • Custom Image Drivers: Implement UploadHelper\Contracts\ImageDriverInterface.

14. FAQ & Troubleshooting

Q: Why do I get an empty $_FILES or immediate UPLOAD_ERR_INI_SIZE?

A: The file exceeds either upload_max_filesize or post_max_size in your active php.ini. Note that post_max_size must always be larger than or equal to upload_max_filesize. For multi-file uploads, post_max_size must be large enough to contain the sum of all files in the batch request.

Q: Why does toWebp() or toAvif() fail on my server?

A: Check if your PHP GD build was compiled with WebP or AVIF support:

php -r "var_dump(function_exists('imagewebp'));"
php -r "var_dump(function_exists('imageavif'));"

If false, install libwebp-dev or libavif-dev and recompile/install the PHP GD extension.

Q: How do I create both a large preview and a small thumbnail from the same uploaded file?

A: Use the built-in multi-variant pipeline:

$result = Upload::file($_FILES['photo'])
    ->allowedExtensions(['jpg', 'png', 'webp'])
    ->resize(800, 600, mode: 'contain')
    ->prefix('preview_')
    ->variant('thumb', function ($v): void {
        $v->resize(150, 150, mode: 'cover')
            ->prefix('thumb_')
            ->toWebp(quality: 80);
    })
    ->saveTo('/uploads/gallery');

15. Automated Testing & Code Quality

Run the automated quality and test suites:

# Run PHPUnit tests
composer test

# Run PHPStan static analysis (Level 8)
composer analyse

# Check code formatting (PER-CS 2.0)
composer cs:check

# Measure test line coverage (enforces >= 90%)
composer test:coverage

# Run standalone examples
for f in examples/*.php; do php "$f"; done

16. License & Credits