PHP: Multiple image file upload with dynamic file storage
Tags: php, beginners
Published: 2023-03-16 02:45:57

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)
```

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";
```


#### 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 ...
}
```

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](https://www.php.net/manual/en/datetime.format.php).



### 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;
```

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");
```

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');
```


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())
    {
```


### 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;
}
```


### 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.
```

### 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;
}
```


#### 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;
}
```

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;
}
```


#### Validate file size

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


#### 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;
}
```



#### Do a Byte Signature Check according to file type

Use our `checkMagicBytes()` callback function from `config.php`.
[Read more in this post](https://dev.to/yasuie/php-magic-bytes-check-for-image-files-30c2)

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

#### 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]);
```


#### 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
```

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");
    }
}
```


#### 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>
```


And that is it. You can get the whole source code on my [personal repository](https://ysdevblog.de/index.php?repo=3).