---
title: 'File Uploads'
source: 'https://academia.sh/en/courses/php-fundamentals/file-uploads'
course: 'PHP Fundamentals'
language: en
updated: '2026-10-21T05:03:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures through which channel moving a file that was not uploaded is heard'
    - 'Observes the $_FILES structure and the error codes by sending files to the built-in server with curl'
    - 'Shows that when post_max_size is exceeded, not even a field carrying an error code is left'
---

# File Uploads

How a file upload reports trouble through return values and error codes: move_uploaded_file on a file that was not uploaded, exceeding a server limit, and a request emptied by exceeding post_max_size.

In the Form Handling lesson an invalid value came back from the `filter_var` function as `false`, and the request went on with a 200. A file upload uses the same channel, but the request has two parts: the file itself and the status information PHP attaches to it. The question widens as well: when an upload fails, where is that information, and what happens if it never arrives?

M04 covered the HTTP side of an upload, the structure of the multipart body, and M16/K01 covered how a server handles a request; here what is measured is what PHP extracts from that request. An uploaded file is placed in the `$_FILES` superglobal; each file field has the keys `name` (the name the client gave), `size` (in bytes), `error` (a status code) and `tmp_name`, which holds the path of the temporary file. Because the temporary path depends on the environment, it is not printed in any output of this lesson.

## A Non-Uploaded File Is Heard Through the Return

The only way to move a file to a permanent location is the `move_uploaded_file` function, and it works only for a file that PHP itself received in that request. The first measurement runs on the command line; no upload has taken place there, so the function should fail even when applied to an ordinary file that exists.

```php
<?php
// channel_upload.php — which channel is heard when a file that was not uploaded is moved
$warnings = [];
set_error_handler(function (int $level) use (&$warnings): bool {
    $warnings[] = $level === E_WARNING ? 'E_WARNING' : 'E_' . $level;
    return true;
});

file_put_contents('source.txt', "hello\n");

try {
    $result = move_uploaded_file('source.txt', 'target.txt');
    $channel = $warnings ? 'warning' : ($result === false ? 'return' : 'silent');
} catch (Throwable $error) {
    $channel = 'halts';
}
printf("%-24s%-10s%s\n", 'non-uploaded file', $channel, 'result ' . var_export($result, true));
printf("%-24s%s\n", 'target created', file_exists('target.txt') ? 'yes' : 'no');
```

```
non-uploaded file       return    result false
target created          no
```

The function returns `false`; no warning, no exception. The file was not moved to the target. This is the **return** channel, and it is the channel measured in this lesson: execution goes on as if nothing happened unless the caller asks about the `false` value. **RR15.** The measurement shows that the function working only on files PHP received as part of a request is a deliberate security feature: even if the caller supplies a file name by hand, the function does not move it. Why this feature exists and how it is classified is the subject of M24 and M25.

## The Structure of an Uploaded File

A real upload is measured with the built-in server and `curl -F`. The `-F` flag sends the request with a multipart body, and the `@` prefix makes the field's value be read from a file.

```php
<?php
// upload.php — reports the uploaded file and the field counts
$names = [
    UPLOAD_ERR_OK => 'UPLOAD_ERR_OK',
    UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE',
    UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
];

echo 'files=', count($_FILES), ' post=', count($_POST), "\n";
foreach ($_FILES as $field => $file) {
    echo $field, ' name=', $file['name'], ' size=', $file['size'];
    echo ' error=', $names[$file['error']] ?? $file['error'], "\n";
    if ($file['error'] === UPLOAD_ERR_OK) {
        $moved = move_uploaded_file($file['tmp_name'], 'stored.txt');
        echo 'moved=', $moved ? 'true' : 'false', ' size=', filesize('stored.txt'), "\n";
    }
}
```

```bash
# server.sh — uploads a small file to the built-in server
printf 'hello, world.\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
curl -s -F 'file=@report.txt' -F 'title=first' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
```

```
files=1 post=1
file name=report.txt size=14 error=UPLOAD_ERR_OK
moved=true size=14
status=200
```

`$_FILES` saw one file field, and `$_POST` saw the text field (`title`). The field name, the file name the client gave, the size in bytes and the `UPLOAD_ERR_OK` error code arrived; this time `move_uploaded_file` returned `true`, because the file was part of this request. This is the only case in which the return value is `true`: the function accepts a file that came by the right path at the start of execution. **RR16.** `tmp_name` is not in this output; its value changes on every run and the lesson does not print it.

## The `$_FILES` Structure and a Field with No File Selected

So far only three keys were read. The whole structure of a file field, with its key names, can be seen with a single script; the `tmp_name` value is not printed, only the key name is listed. The same script also measures the case where the form has a file field but the user selected no file: in this case the browser sends the field with an empty file name.

```php
<?php
// structure.php — all keys and error codes of the $_FILES fields
foreach ($_FILES as $field => $file) {
    $keys = array_keys($file);
    sort($keys);
    echo $field, ' keys=', implode(',', $keys), "\n";
    echo $field, ' error=', json_encode($file['error']), ' size=', json_encode($file['size']), "\n";
}
echo 'file_present=', isset($_FILES['file']) ? 'yes' : 'no', "\n";
```

```bash
# structure.sh — a filled field and a field with no file selected
printf 'welcome\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/structure.php && break; sleep 0.1; done
echo '--- filled field'
curl -s -F 'file=@report.txt' http://127.0.0.1:8083/structure.php
echo '--- no file selected'
curl -s -F 'file=@/dev/null;filename=' http://127.0.0.1:8083/structure.php
kill $pid
```

```
--- filled field
file keys=error,full_path,name,size,tmp_name,type
file error=0 size=8
file_present=yes
--- no file selected
file keys=error,full_path,name,size,tmp_name,type
file error=4 size=0
file_present=yes
```

The filled field gives `error` 0 and `size` 8; in the measured PHP 8.5 release the key list contains `full_path`, `name`, `size`, `tmp_name`, `type` and `error`. **RR17.** The `full_path` key has existed since PHP 8.1; the lesson shows the key list only for the measured release. In the field with no file selected, `error` is 4, the value of `UPLOAD_ERR_NO_FILE`, and `size` is 0. This is the case where the `$_FILES['file']` key exists but no upload took place: `isset` reports it as `yes`, so the existence of the key does not show that the upload succeeded. Success is recognized by `error` being `UPLOAD_ERR_OK`.

## A Multiple-File Array Is Per Key, Not Per Field

When the field name is given as `file[]`, PHP does not create a separate record for each file. Instead it holds the array of files under each key (`name`, `size`, `error`); that is, the structure looks not like one record per file but like one array per key.

```php
<?php
// multiple.php — the structure of a file[] field in $_FILES
$file = $_FILES['file'];
echo 'name_type=', get_debug_type($file['name']), ' count=', count($file['name']), "\n";
foreach ($file['name'] as $index => $name) {
    echo $index, ' ', $name, ' size=', $file['size'][$index], ' error=', $file['error'][$index], "\n";
}
```

```bash
# multiple.sh — uploads two files under a single field name
printf 'one\n' > a.txt
printf 'two two\n' > b.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/multiple.php && break; sleep 0.1; done
curl -s -F 'file[]=@a.txt' -F 'file[]=@b.txt' -w 'status=%{http_code}\n' http://127.0.0.1:8083/multiple.php
kill $pid
```

```
name_type=array count=2
0 a.txt size=4 error=0
1 b.txt size=8 error=0
status=200
```

`name` becomes an `array`, and the information about each file is read with the same index number from three separate arrays. A `$file['name']` read written for a single file tries to use an array as a string in a multiple upload; this is the same mistake as the `strlen` and array measurement in the Superglobals lesson. **RR18.** Because the client can change the structure by making the field name `file[]`, the structure is still the client's choice for code that expects a single file.

## A Missing Target Path Makes the Move Raise a Warning

The move function can return `false` for two separate reasons: the file is not part of this request, or the target cannot be written. The first is silent. The second gives the same return value together with a warning. The script below receives a real upload and tries to move the file to a directory that does not exist; the text of the warning is not printed, only the level names.

```php
<?php
// target.php — an attempt to move the uploaded file to a directory that does not exist
$levels = [];
set_error_handler(function (int $level) use (&$levels): bool {
    $levels[] = $level === E_WARNING ? 'E_WARNING' : 'E_' . $level;
    return true;
});

$result = move_uploaded_file($_FILES['file']['tmp_name'], 'missing_dir/target.txt');
echo 'result=', var_export($result, true), ' warnings=', implode(',', $levels), "\n";
```

```bash
# target.sh — sends the file, the move goes to the missing directory
printf 'welcome\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/target.php && break; sleep 0.1; done
curl -s -F 'file=@report.txt' -w 'status=%{http_code}\n' http://127.0.0.1:8083/target.php
kill $pid
```

```
result=false warnings=E_WARNING,E_WARNING
status=200
```

The function returns `false` and in addition reports two `E_WARNING`s: one says the target could not be opened, the other that the move failed. The same return value had come without a warning for the file that was not uploaded in the first section; here the channel shifts to **warning**. **RR19.** This difference is invisible to code that looks at the return value: both are `false`. Only code that has an error handler or a log file can tell why the move failed; the status code is again 200.

## When a Server Limit Is Exceeded

PHP limits uploads with two ini values: `upload_max_filesize` is the upper bound for a single file, and `post_max_size` is the upper bound for the whole request. The lesson trusts no default setting; the limit is passed with `-d`. In the first measurement the single-file limit is kept small and a large file is sent. In the second measurement the file field is not sent at all.

```bash
# limit.sh — exceeding the file limit and not sending the file field
head -c 3000 /dev/zero | tr '\0' 'a' > large.txt
php -S 127.0.0.1:8083 -d display_errors=0 -d upload_max_filesize=1K >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
echo '--- 3000 bytes, limit 1K'
curl -s -F 'file=@large.txt' -F 'title=second' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
echo '--- no file field'
curl -s -F 'title=third' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
```

```
--- 3000 bytes, limit 1K
files=1 post=1
file name=large.txt size=0 error=UPLOAD_ERR_INI_SIZE
status=200
--- no file field
files=0 post=1
status=200
```

When the limit is exceeded, the field is still in `$_FILES`: its name is kept, `size` becomes 0 and `error` becomes `UPLOAD_ERR_INI_SIZE`. This time the signal arrives in the error code; if the code does not read it, execution goes on as if the file had never come. The request again gives 200. When the file field is not sent at all, `$_FILES` is empty and `$_POST` is filled: code that reads `$_FILES['file']` directly gets both a warning and `null`, which is the missing key of the Superglobals lesson.

When storing a file, one should not put the name the client gave directly into a path, should not infer the type from the extension alone, and should not place the permanent location under the document root; the reasoning behind these principles is covered in M16/K01, and their classification and defense procedures in M24 and M25. In this lesson `stored.txt` was used as a fixed name only to make the measurement repeatable.

## The Bounding Measurement

In the previous section there was a field that came back with an error code. If the limit is pulled down further, even that is gone. When `post_max_size` is exceeded, PHP does not parse the body of the request at all: neither the file nor the text field is left in hand.

```bash
# ceiling.sh — what is left when post_max_size is exceeded
head -c 5000 /dev/zero | tr '\0' 'a' > large.txt
php -S 127.0.0.1:8083 -d display_errors=0 -d post_max_size=1K >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
curl -s -F 'file=@large.txt' -F 'title=fourth' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
```

```
files=0 post=0
status=200
```

Both `$_FILES` and `$_POST` are empty, and the status code is 200. There is not even a field carrying an error code: the request cannot be told apart from a request that sent no fields at all. **RR20.** It was measured with `display_errors=0`; with the setting on, a warning PHP produces at startup may fall into the body, and the lesson does not print that text. This is the lower bound of the channel in the previous section: the return channel works when the value the channel would speak about has arrived. When the value never arrives, there is no error code left to read. The only way for the code to notice this is to test the absence of the fields it expects as a separate condition.

## Summary

- `$_FILES` carries `name`, `size`, `error` and `tmp_name` for each file field; `move_uploaded_file` moves only a file that this request received.
- The channel measured in this lesson is **return**: for a file that was not uploaded, `move_uploaded_file` returns `false` without a warning.
- When `upload_max_filesize` is exceeded, the field stays in `$_FILES` with `size` 0 and `error` `UPLOAD_ERR_INI_SIZE`; the request still gives 200.
- If the file field is not sent, `$_FILES` stays empty and `$_POST` stays filled.
- When `post_max_size` is exceeded, both `$_POST` and `$_FILES` are emptied; not even a field carrying an error code is left, and the request looks like an empty request without a signal.

## Next Step

In the upload lesson the code gave a script such as `upload.php` a file it had written itself and got a response. A script pulling another file into itself is a separate operation and brings a new form of the same question: what happens if the file to be pulled in cannot be found? The Including Files lesson measures through which channel the inclusion of a missing file is heard and how that channel changes with the kind of include.
