DEV Community

Yassine Elo
Yassine Elo

Posted on

PHP: Multiple image file upload with dynamic file storage

I wrote a simple but wholesome file upload script in PHP, for beginners.
You can upload multiple image files at once, they will be saved dynamically inside automatically created subfolders based on the date.
The file sources will be logged in a mysql table and for every saved image, also a thumbnail sized version will be created using PHP's extension IMAGICK.

Let's break it down, we will use these two files:

  1. config.php - MySQL connection, file storage, file limitations and validation function
  2. index.php - File upload form, file saving process and gallery of uploaded images

Remember: Obviously this is not for public usage, you need some kind of user authentication to run this script. For beginners: If you are not sure how to code a user authentication in PHP,
there are lots of tutorials and I can recommend this one: https://alexwebdevelop.com/user-authentication/

config.php - MySQL connection, file storage, file limitations and validation function

MySQL file log table

For every uploaded and successfully saved image file, insert a new record, that makes it easier to manage the files later.

The file log table:

CREATE TABLE IF NOT EXISTS myfiles (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    src_original VARCHAR(100) NOT NULL,
    src_thumb VARCHAR(100) DEFAULT NULL,
    uploaded_at INT UNSIGNED DEFAULT NULL,
    file_ext VARCHAR(5),
    media_type VARCHAR(50),
    PRIMARY KEY (id)
);
-- myfiles (id, src_original, src_thumb, uploaded_at, file_ext, media_type)
Enter fullscreen mode Exit fullscreen mode

The file source is stored as a relative path, for example: "uploads/2023/02/08/image.jpg"

File storage

Relative file paths, define file base

All files sources will be stored inside our "uploads" directory. And all file paths will begin with "uploads":

The example from before: "uploads/2023/02/08/image.jpg" this will be stored in mysql as the file source.
That can result in an URL like: mywebsite.com/images/uploads/2023/03/image.jpg

The directory "image" inside the web root folder is therefore our file base. This file base must be defined, at the beginning of our config.php file.

$fileBase = "images";
Enter fullscreen mode Exit fullscreen mode

Dynamic file storage

Now we don't want every uploaded file to be stored in the same folder, thats why we need a function that checks on demand, if today's subfolder exists and if not, tries to create it.

We just use PHP's date() function to create subfolder names.

// Dynamic uploads storage:
// Based on year, month and day
// e.g. "uploads/2023/04/14"

$fileBase = "images";

// Create/get current file storage path
// Returns string on success, or FALSE if directory doesnt exist and cant be created

function getCurrentFileStorage():string|false {
    if(!is_dir($GLOBALS["fileBase"])) return FALSE;

    // Our globally defined file base
    $base = $GLOBALS["fileBase"] .'/';
    // Our uploades folder
    $fs = 'uploads';

    // We need to return the relative file path, keep it separated
    if(!is_dir($base . $fs)) {
        if(!mkdir($base . $fs)) return FALSE;
    }

    // Year based file storage
    $fs .= date("/Y");
    if(!is_dir($base . $fs)) {
        if(!mkdir($base . $fs)) return FALSE;
    }

    // Month based file storage
    $fs .= date("/m");
    if(!is_dir($base . $fs)) {
        if(!mkdir($base . $fs)) return FALSE;
    }

    // Day based file storage
    $fs .= date("/d");
    if(!is_dir($base . $fs)) {
        if(!mkdir($base . $fs)) return FALSE;
    }

    return $fs; // return relative file path
}


// Call the function on upload submit,
// so that subfolders are created only when they are needed

if(isset($_FILES["myFile"]["name"]))
{
    if(!$fileStorage = getCurrentFileStorage()) {
        die("File storage not available");
    }

    // Upload process ...
}
Enter fullscreen mode Exit fullscreen mode

Of course this function can be changed to save files based on year only, or on weeks etc.
The possibilities are bound by PHP's date() function parameters.

File limitations

Now we want to provide security and protect ourselves from potential malicious files, for example if we let users upload image files on our website, too.
What we do is not 100% secure for the only reason that there is no such thing as 100% security. The data of an image file can be manipulated to bypass certain validation steps.
For example to execute harmful code disguised as a jpg file.

A simple way to raise security is using restrictions, that means before saving an uploaded file, we will check each file if it passes the limitations and will not allow anything else.

The file limitations are specified in our config file as such:

$fileMaxSize = 1024 * 1024 * 10; // 10 MB

// How many uploaded files will be saved at once?
//If the user uploads 17 files, only the first 15 will be saved.
$fileMaxUploads = 15;

// Size in pixel of the thumbnail image that will be created using IMAGICK
$thumbnailSize = 150;
Enter fullscreen mode Exit fullscreen mode

Now we define a whitelist array, with all supported file extensions as array keys pointing to their respective mime/media type.

// whitelist = array ("extension" => "media type")
$mediaWhiteList = array("jpg" => "image/jpeg",
"jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"png" => "image/png",
"webp" => "image/webp");
Enter fullscreen mode Exit fullscreen mode

If the uploaded file does not match the criteria above, it will not be saved. The validation happens next.

index.php - File upload form, file saving process and gallery of uploaded images

At the start we include our config file and define a little function to collect error messages.
Since you're uploading multiple files, you can get different responses, for clean code we collect all error messages in one variable and display them later.
If one uploaded file doesn't pass validation, add one error message and continue with the next file.

require_once "config.php";

// UPLOAD PROCESS RESPONSE
// Feedback messages appended in this string, using the function below
$uploadResponse = "";

function addUploadResponse($class, $text):void {
    $GLOBALS["uploadResponse"] .= "<p class=\"$class\">$text</p>\r\n";
    return;
}

// Example, attach file name to error message:
addUploadResponse('error', $_FILES["fileUpload"]["name"] . ' file type not supported');
Enter fullscreen mode Exit fullscreen mode

And then, if an submit action happened, we start our file saving process.
Let's break it down step by step:

if(isset($_FILES["fileUpload"]["name"]))
{
    // On upload submit, check if file storage is available

    if($fileStorage = getCurrentFileStorage())
    {
Enter fullscreen mode Exit fullscreen mode

Count uploaded files

Don't save all submitted files! Control the amount with $fileMaxUploads from config.php
Any files after max. value will be omitted!

$numFiles = count($_FILES["fileUpload"]["tmp_name"]);

if($numFiles > $fileMaxUploads) {
    addUploadResponse("info", "You can only upload $fileMaxUploads files at once");
    $numFiles = $fileMaxUploads;
}
Enter fullscreen mode Exit fullscreen mode

Start the loop

Validate each file of $_FILES["fileUpload"]. If a validation step fails, add an error message and continue with the next file.
Our files in this array can be iterated by the 3rd array index.
More info: https://www.php.net/manual/en/features.file-upload.multiple.php

// $_FILES["fileUpload"]["tmp_name"][0] => First uploaded file
// $_FILES["fileUpload"]["name"][0]     => Original name of first uploaded file
// $_FILES["fileUpload"]["size"][0]     => Size of first uploaded file
// etc.
Enter fullscreen mode Exit fullscreen mode

Inside the loop:

Check for PHP upload errors

more info: https://www.php.net/manual/en/features.file-upload.errors.php

if($_FILES["fileUpload"]["error"][$i] != 0) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " Error: File can not be saved");
    continue;
}
Enter fullscreen mode Exit fullscreen mode

Validate file extension

Get the file extension from the original file name with our function getFileExtension() from config.php.
It extracts the file extension from the original file name and returns it as a lowercase string, unless the extension is not whitelisted, in which case it returns FALSE.

function getFileExtension($name):string|false
{
    $arr = explode('.', strval($name)); // split file name by dots
    $ext = array_pop($arr); // last array element has to be the file extension
    $ext = mb_strtolower(strval($ext));
    // Return file extension string if whitelisted
    if(array_key_exists($ext, $GLOBALS["mediaWhiteList"])) return $ext;
    return FALSE;
}
Enter fullscreen mode Exit fullscreen mode

And the validation step inside the loop:

if(!$ext = getFileExtension($_FILES["fileUpload"]["name"][$i])) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - file type not supported");
    continue;
}
Enter fullscreen mode Exit fullscreen mode

Validate file size

if($_FILES["fileUpload"]["size"][$i] > $fileMaxSize) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - file too big");
    continue;
}
Enter fullscreen mode Exit fullscreen mode

Validate content type

Check if the uploaded file has the content type it should have according to our whitelist.

if($mediaWhiteList[$ext] != mime_content_type($_FILES["fileUpload"]["tmp_name"][$i])) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - invalid media type");
    continue;
}
Enter fullscreen mode Exit fullscreen mode

Do a Byte Signature Check according to file type

Use our checkMagicBytes() callback function from config.php.
Read more in this post

if(!checkMagicBytes($ext, $_FILES["fileUpload"]["tmp_name"][$i])) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - invalid file type");
    continue;
}
Enter fullscreen mode Exit fullscreen mode

File is valid now

Create the source path to be stored and a random filename, then move the uploaded file and prepare the info to be saved later in mysql.

// Random file name:
$filename = bin2hex(random_bytes(4)) . '.' . $ext;

// Source path:
$srcOriginal = $fileStorage .'/'. $filename;

if(!move_uploaded_file($_FILES["fileUpload"]["tmp_name"][$i], $fileBase .'/'. $srcOriginal)) {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - failed to save file");
    continue;
}

// Info to be saved in mysql later
$logImages[$i] = array("srcOriginal" => $srcOriginal,
"srcThumb" => NULL,
"ext" => $ext,
"type" => $mediaWhiteList[$ext]);
Enter fullscreen mode Exit fullscreen mode

Create a thumbnail sized version with IMAGICK

The PHP extension "Imagick" is available on most hosting providers and servers these days and can be installed if not.
The Imagick class provides super easy functions to crop images into a square by using
Imagick::cropThumbnailImage(150, 150) to create a thumbnail with 150px. However in the comments below the PHP manual,
you can find information on how to crop GIF images. The contributed comments in the manual are GOAT sometimes.

try {
    $objIMG = new Imagick(realpath($fileBase .'/'. $srcOriginal)); // New Imagick object made of uploaded file
    $objIMG->setImageFormat($ext);

    // Special case for cropping gifs - https://www.php.net/manual/en/imagick.coalesceimages.php

    if($ext == "gif") {
        $objIMG = $objIMG->coalesceImages();
        foreach ($objIMG as $frame) {
            $frame->cropThumbnailImage($thumbnailSize, $thumbnailSize);
            $frame->setImagePage($thumbnailSize, $thumbnailSize, 0, 0);
        }
        $objIMG = $objIMG->deconstructImages();
    }
    else {
        $objIMG->cropThumbnailImage($thumbnailSize, $thumbnailSize);
    }
}
catch (ImagickException $e) {
    addUploadResponse("info", $_FILES["fileUpload"]["name"][$i] . " - failed to create thumbnail image<br>" . $e->getMessage());
    continue;
}


// SAVE NEW THUMBNAIL IMAGE NOW:
$srcThumb = $fileStorage . "/th" . $filename;

if(file_put_contents($fileBase .'/'. $srcThumb, $objIMG)) {
    // Update thumbnail source in image log:
    $logImages[$i]["srcThumb"] = $srcThumb;
}
else {
    addUploadResponse("error", $_FILES["fileUpload"]["name"][$i] . " - failed to save thumbnail image");
}

$objIMG->clear(); // free memory usage
Enter fullscreen mode Exit fullscreen mode

And that's our file loop.

Save files to MySQL

At the end of our loop, if images have been saved successfully the $logImages array is set and contains the file info to be stored.

if(isset($logImages)) {
    $savedFiles = 0;
    $time = time();
    $stmt = $mysqli->prepare("INSERT INTO myFiles (src_original, src_thumb, uploaded_at, file_ext, media_type) VALUES (?,?,?,?,?);");

    foreach($logImages as $log) {
        $stmt->bind_param("ssiss", $log["srcOriginal"], $log["srcThumb"], $time, $log["ext"], $log["type"]);
        $stmt->execute();
        if($mysqli->affected_rows === 1) {
            $savedFiles++; // Count successfully saved files
        }
    }
    $stmt->close();

    if($savedFiles > 0) {
        addUploadResponse("success", $savedFiles . " Files uploaded!");
    }
    else {
        addUploadResponse("error", "No files saved");
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally our HTML upload form

Remember to use brackets [] in the name attribute of the input file element,
this way PHP will provide the multiple files as an array inside $_FILES
Also the keyword multiple

<form class="gridForm" action="index.php" method="post" enctype="multipart/form-data">
    <h4>upload files from your device</h4>
    <?php echo $uploadResponse; ?>
    <input type="file" id="inputFile" name="fileUpload[]" multiple>
    <input type="submit" id="inputSubmit" name="uploadSubmit" value="upload now">
    <p><a href="index.php">refresh</a></p>
</form>
Enter fullscreen mode Exit fullscreen mode

And that is it. You can get the whole source code on my personal repository.

Top comments (0)