Programster's Blog

Tutorials focusing on Linux, programming, and open-source

Handling File Uploads With PHP

PHP

Example Form

Below is an example form for uploading a file.

<form enctype="multipart/form-data" action="/" method="POST">
    <input type="hidden" name="MAX_FILE_SIZE" value="2097152" />
    <input name="file_input_name" type="file" /><br /><br />
    <input type="submit" value="Send File" />
</form>

Encoding Type

When creating a form for a file upload, the data encoding type, enctype, must be specified as multipart/form-data.

MAX_FILE_SIZE

Using the a hidden input with MAX_FILE_SIZE preceding the form input for the file can prevent users from trying to upload a file too large that the server will reject it. It expects a value in bytes. The user could manipulate this, but that won't stop the server rejecting a file that it considers too large. It just provides convenience for the user in preventing them from wasting time.

Unfortunately, the user will never see a warning, and this only takes effect after the user has been uploading up-to that limit, but it is still better than nothing as it prevents the user having to wait until they have uploaded the entire file before it errors out.

The maximum size that your server can take will be the smaller of the two configuration variables post_max_size, and upload_max_filesize. Unfortunately, when you use ini_get() on these, it will return a string like 2M instead of the number of bytes, which is what the input field expects. Hence we need to run a conversion function if we wish to automatically set the value, like below:

<?php
function convertToBytes($val) : int
{
    $val = trim($val);
    $last = strtolower($val[strlen($val)-1]);

    switch ($last) 
    {
        case 'g': $val *= (1024 * 1024 * 1024); break;
        case 'm': $val *= (1024 * 1024); break;
        case 'k': $val *= 1024; break;
    }

    return $val;
}

$sizes = [
    convertToBytes(ini_get('post_max_size')),
    convertToBytes(ini_get('upload_max_filesize')),
];

$maxFileSizeInBytes = min($sizes); 

// ... other code and HTML here.
?>

<input type="hidden" name="MAX_FILE_SIZE" value="<?= $maxFileSizeInBytes; ?>" />

$_FILES Output

If you want to see an example of what a var_dump of the $_FILES variable looks like after uploading a file here is an example:

array(1) {
  ["file_input_name"]=>
  array(5) {
    ["name"]=>
    string(17) "Selection_003.png"
    ["type"]=>
    string(9) "image/png"
    ["tmp_name"]=>
    string(14) "/tmp/phpJMlS2W"
    ["error"]=>
    int(0)
    ["size"]=>
    int(39826)
  }
}

The above example output assumes the file input field was named file_input_name. It will match whatever you named your input field for the file.

Handling Errors

When something goes wrong, you will get an error code in the "error" field and your $_FILES array will look something like:

array(1) {
  ["file_input_name"]=>
  array(5) {
    ["name"]=>
    string(17) "Selection_009.png"
    ["type"]=>
    string(0) ""
    ["tmp_name"]=>
    string(0) ""
    ["error"]=>
    int(2)
    ["size"]=>
    int(0)
  }
}

The above example output assumes the file input field was named file_input_name. It will match whatever you named your input field for the file.

These error codes can be translated into what they mean here. Alternatively, you could use the UploadException class shown below.

UploadException

Here is a useful Exception to use when dealing with file uploads. This will allow you to automatically get the error message you need.

class UploadException extends Exception
{
    public function __construct($code) 
    {
        $message = $this->codeToMessage($code);
        parent::__construct($message, $code);
    }


    private function codeToMessage($code)
    {
        switch ($code)
        {
            case UPLOAD_ERR_INI_SIZE: $message = "The uploaded file exceeds the upload_max_filesize directive in php.ini"; break;
            case UPLOAD_ERR_FORM_SIZE: $message = "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form"; break;
            case UPLOAD_ERR_PARTIAL: $message = "The uploaded file was only partially uploaded"; break;
            case UPLOAD_ERR_NO_FILE: $message = "No file was uploaded"; break;
            case UPLOAD_ERR_NO_TMP_DIR: $message = "Missing a temporary folder"; break;
            case UPLOAD_ERR_CANT_WRITE: $message = "Failed to write file to disk"; break;
            case UPLOAD_ERR_EXTENSION: $message = "File upload stopped by extension"; break;
            default: $message = "Unknown upload error"; break;
        }

        return $message;
    }
}

Usage:

if ($_FILES['file_input_name']['error'] === UPLOAD_ERR_OK) 
{
    //uploading successfully done
} 
else
{
    throw new UploadException($_FILES['file_input_name']['error']);
}

The above example output assumes the file input field was named file_input_name. It will match whatever you named your input field for the file.

The code snippets above was grabbed from the user contributed notes here.

Determining File Type

You will have noticed that you have a type in the $_FILES array to indicate the file type. In our example this was image/png. Unfortunately, you shouldn't trust it, as it takes whatever is sent from the browser, so a user could "lie" and say it's a different type to what it actually is.

Most file formats have a header that helps identify what kind of file it is. For example, GIF files always begin with GIF87. You can use finfo_open, which will use this header to to verify the MIME type of a file. The method uses "the magic number database", which is just a list of all headers that allows finfo() to identify the files.

Example

$finfo = new finfo(FILEINFO_MIME);

if (!$finfo) 
{
    throw new Exception("Opening fileinfo database failed");
}

$filepath = $_FILES['file_input_name']['tmp_name'];
echo $finfo->file($filename); // "image/gif; charset=binary" for Gif, "video/mp4; charset=binary" for an mp4

Don't Specify Magic Database Location

When I was initially studying this, I saw a lot of posts telling me that I needed to specify the path to the magic number database, and I found mine at /etc/magic.mime, but if I specified this path I would always get a result of application/octet-stream. It looks like the magic database is now built into PHP, and not specifying the path now works.

FILEINFO_EXTENSION

I thought it would be easier to use FILEINFO_EXTENSION instead of FILEINFO_MIME, in order to get the appropriate extension for the file instead of comparing all the possible mime types. Unfortunately, this would always return a value of ??? to indicate that it didn't know.

A Package To Make Life Easier

I made a package which encapsulates all the logic above. You can use it with:

composer require programster/upload-file-manager

References

Last updated: 13th April 2020
First published: 13th April 2020