DEV Community

Cover image for PHP file_exists() vs is_file(): Directory Bug Explained
devTalk
devTalk

Posted on Originally published at dev-talk.com

PHP file_exists() vs is_file(): Directory Bug Explained

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
}
Enter fullscreen mode Exit fullscreen mode

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) returned false, which can break code that expects an array.
  • readfile($dir) returned -1 and 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) and is_file(null) both returned false and 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)