Here is a bug that passes every test until a user without a profile picture shows up. You build an image path from a folder and a filename, check file_exists(), and serve the image. For users with no filename saved, the filename is empty, so the path points at the folder itself. The folder exists, so the check passes, and your code tries to serve a directory as if it were an image. The fix is one word: is_file(). I reproduced the bug on PHP 8.3.6 so you can see exactly what happens next.
The bug in four lines
$dir = __DIR__ . '/uploads/avatars/';
$avatar = ''; // this user never uploaded one
if (file_exists($dir . $avatar)) {
// we get here, even though there is no image
}
This is not a PHP quirk, it is what the function is documented to do. The PHP manual describes file_exists() as checking "whether a file or directory exists." The name says file, but the behavior is "anything at this path."
One detail the tip leaves out
An empty string by itself does not pass: file_exists('') returned false in my test. The trouble starts when the empty value is joined to a directory path. 'uploads/avatars/' . '' is just 'uploads/avatars/', and that is a real folder. The same happens when the filename is null: (string) null is an empty string, so the join gives the same result. Real code hits this with things like public_path('images/' . $user->avatar) when avatar is empty.
What each function says about the same paths
I ran four checks on a set of paths. This is the real output:
| Path | file_exists | is_file | is_dir | is_readable |
|---|---|---|---|---|
uploads/avatars/ (folder + empty name) |
true | false | true | true |
uploads/avatars/ada.png (real file) |
true | true | false | true |
uploads/avatars/nope.png (missing) |
false | false | false | false |
'' (empty string) |
false | false | false | false |
. and ..
|
true | false | true | true |
| Symlink to a file | true | true | false | true |
| Symlink to a folder | true | false | true | true |
| Broken symlink | false | false | false | false |
Two things to notice. First, is_readable() is not a fix: it is also true for the folder. Second, is_file() follows symlinks, as the manual says, so a symlink to a real file still counts as a file. A broken symlink is false for everything.
What happens when the check wrongly passes
The bug is not just a wrong boolean. I took the passing directory path and ran the usual next steps:
-
file_get_contents($dir)returned an empty string and emitted a warning: "Read of 12288 bytes failed with errno=21 Is a directory". Your page gets an empty image with a 200 status. -
getimagesize($dir)returnedfalse, which can break code that expects an array. -
readfile($dir)returned-1and printed nothing. -
unlink($dir)failed with "Is a directory". That is safe here, but a "delete the old file" step that quietly does nothing is its own bug.
In a download or image endpoint, this means broken images, empty responses, or log files full of warnings, all from a check that looked correct.
The fix
$path = $dir . $avatar;
if ($avatar !== '' && is_file($path)) {
// safe: a real file with a name
} else {
// show the default avatar
}
is_file() returns true only for a regular file, so the folder case now correctly gives false. I added the explicit empty-name check as well, because it states the intent and avoids a pointless disk lookup, but is_file() alone already fixes the bug. Use is_dir() when you actually mean a folder, and keep file_exists() for the rare case where either is acceptable.
Laravel: same trap, same fix
Laravel's helpers map straight onto these functions. In the framework source, File::exists() calls file_exists() and File::isFile() calls is_file(), so the same rule applies:
use Illuminate\Support\Facades\File;
File::exists(public_path('images/' . $user->avatar)); // true for the folder when avatar is empty
File::isFile(public_path('images/' . $user->avatar)); // false
The storage layer has a similar split. In the source, Storage::exists() is documented as "Determine if a file or directory exists," and there are separate fileExists() and directoryExists() methods. I read this in the source and did not run it, because that needs the full Flysystem setup. If your path may be empty, use fileExists() for files.
Two more things worth knowing
-
Results are cached. The manual notes that both functions cache their results within a request. If your script creates or deletes a file and checks again, call
clearstatcache()first. -
nullis deprecated. On PHP 8.3,file_exists(null)andis_file(null)both returnedfalseand raised a deprecation notice: "Passing null to parameter #1 ($filename) of type string is deprecated". Make sure your filename variable is a string.
References
Originally published on DEV Talk.
Top comments (0)