Skip to content

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

file_exists() returns true for directories, so an empty filename can pass your check. See tested results for is_file(), symlinks, and the Laravel equivalents.

Dasun Kalupahana 3 min read 101 views 0 comments
On this page
  1. The bug in four lines
  2. One detail the tip leaves out
  3. What each function says about the same paths
  4. What happens when the check wrongly passes
  5. The fix
  6. Laravel: same trap, same fix
  7. Two more things worth knowing
  8. References

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:

Pathfile_existsis_fileis_diris_readable
uploads/avatars/ (folder + empty name)truefalsetruetrue
uploads/avatars/ada.png (real file)truetruefalsetrue
uploads/avatars/nope.png (missing)falsefalsefalsefalse
'' (empty string)falsefalsefalsefalse
. and ..truefalsetruetrue
Symlink to a filetruetruefalsetrue
Symlink to a foldertruefalsetruetrue
Broken symlinkfalsefalsefalsefalse

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
}

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.
  • null is 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

Free resources for this topic

Code snippets and fixes for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Free download: Laravel interview questions (PDF)

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.