PHP Directories

A directory is simply a special kind of entry in the filesystem that holds references to other files and directories. PHP gives you several ways to open a directory, walk through its contents, create new folders, and remove them again — from low-level stream functions like opendir() to convenient array-returning helpers like scandir() and pattern-matching with glob(). Understanding how these tools work, and where they trip people up, is essential for any script that processes uploads, builds file browsers, or manages logs and cache folders.

Overview: How PHP Directories Work

Every directory function in PHP ultimately talks to the operating system’s filesystem API through PHP’s streams layer. When you call opendir(), PHP asks the OS to open a directory stream and hands you back a directory handle — in modern PHP this is an object of the internal Directory class (older code and documentation still call it a “resource”, and it behaves the same way: an opaque handle you must eventually close). That handle keeps track of your current position inside the directory listing, similar to how a file handle tracks your position inside a file.

Calling readdir($handle) repeatedly returns one filename at a time — as a plain string, not a full path — advancing the internal cursor each time, until there is nothing left, at which point it returns false. Crucially, the operating system always includes two special entries in every directory listing: . (the directory itself) and .. (its parent). PHP does not filter these out for you; every low-level listing function will return them, and it is your job to skip them when you don’t want them.

Higher-level functions build on top of this same mechanism. scandir() opens the directory, reads every entry into an array, closes it, and returns the array — by default sorted alphabetically. glob() asks the OS (or PHP’s own emulation of it) to match filenames against a shell-style wildcard pattern such as *.jpg. The SPL classes DirectoryIterator and RecursiveDirectoryIterator wrap the same directory stream in an object-oriented, iterable interface, which is convenient when you want to combine directory traversal with other SPL iterators like filters or recursive walkers.

One detail that surprises many beginners: PHP scripts have a current working directory (CWD), which is usually the directory the web server or CLI process was started from — not the directory that contains your PHP file. Relative paths like 'uploads' are resolved against the CWD, which can change at runtime (for example after calling chdir()) or differ between a cron job, a CLI call, and a web request. The magic constant __DIR__ always resolves to the absolute path of the directory containing the current file, regardless of CWD, which is why it is the recommended way to build reliable paths.

Syntax

The core directory functions and their signatures:

Function Purpose
opendir(string $dir): Directory|false Opens a directory handle for reading, or false on failure.
readdir(?Directory $handle = null): string|false Returns the next filename in the handle, or false when exhausted.
closedir(?Directory $handle = null): void Releases the directory handle and its OS file descriptor.
scandir(string $dir, int $sorting_order = SCANDIR_SORT_ASCENDING): array|false Returns all entries (including . and ..) as a sorted array.
glob(string $pattern, int $flags = 0): array|false Returns filenames matching a shell-style wildcard pattern.
is_dir(string $path): bool Checks whether a path exists and is a directory.
mkdir(string $dir, int $permissions = 0777, bool $recursive = false): bool Creates a directory; set $recursive to true to create missing parent directories too.
rmdir(string $dir): bool Removes an empty directory.
new DirectoryIterator(string $dir) An SPL object that lets you foreach over a directory’s contents.

Examples

Example 1: Basic listing with opendir, readdir, closedir

<?php
$dir = __DIR__ . '/uploads';

if (!is_dir($dir)) {
    mkdir($dir);
}

touch($dir . '/report.pdf');
touch($dir . '/photo.jpg');
touch($dir . '/notes.txt');

if ($handle = opendir($dir)) {
    echo "Contents of $dir:\n";
    while (false !== ($entry = readdir($handle))) {
        if ($entry === '.' || $entry === '..') {
            continue;
        }
        echo "- $entry\n";
    }
    closedir($handle);
}

Output:

Contents of /path/to/uploads:
- notes.txt
- photo.jpg
- report.pdf

This example creates three empty files with touch(), then opens the folder, loops with readdir() until it returns false, and skips the . and .. entries. Note that the raw order returned by readdir() is whatever order the underlying filesystem stores entries in — it is not guaranteed to be alphabetical. If you need a predictable order, sort the results yourself or use scandir(), which sorts by default.

Example 2: scandir() with filtering and type checking

<?php
$dir = __DIR__ . '/uploads';

if (!is_dir($dir)) {
    mkdir($dir);
}
if (!is_dir($dir . '/archive')) {
    mkdir($dir . '/archive', 0777, true);
}
touch($dir . '/report.pdf');
touch($dir . '/photo.jpg');

$entries = scandir($dir);
$entries = array_diff($entries, ['.', '..']);
sort($entries);

foreach ($entries as $entry) {
    $path = $dir . DIRECTORY_SEPARATOR . $entry;
    $type = is_dir($path) ? 'DIR' : 'FILE';
    echo "[$type] $entry\n";
}

Output:

[DIR] archive
[FILE] photo.jpg
[FILE] report.pdf

scandir() returns every entry, dot entries included, as an array. array_diff() strips out . and .., and sort() guarantees alphabetical order. Combining each entry with is_dir() lets you tell folders and files apart — something readdir() alone cannot do, since it only gives you names.

Example 3: glob() patterns and DirectoryIterator

<?php
$dir = __DIR__ . '/uploads';

if (!is_dir($dir)) {
    mkdir($dir);
}
touch($dir . '/report.pdf');
touch($dir . '/photo.jpg');

echo "Using glob():\n";
foreach (glob($dir . '/*.{pdf,jpg}', GLOB_BRACE) as $file) {
    echo basename($file) . ' - ' . filesize($file) . " bytes\n";
}

echo "\nUsing DirectoryIterator:\n";
$iterator = new DirectoryIterator($dir);
foreach ($iterator as $fileInfo) {
    if ($fileInfo->isDot()) {
        continue;
    }
    $kind = $fileInfo->isDir() ? 'directory' : 'file';
    echo $fileInfo->getFilename() . " is a $kind\n";
}

Output:

Using glob():
report.pdf - 0 bytes
photo.jpg - 0 bytes

Using DirectoryIterator:
report.pdf is a file
photo.jpg is a file

glob() matches filenames directly against a wildcard pattern without you having to loop and check extensions manually; the GLOB_BRACE flag lets you group alternatives like {pdf,jpg} in one pattern (note: GLOB_BRACE depends on the system’s C library and isn’t available on every platform — for portable code, call glob() once per extension instead). DirectoryIterator gives the same information in an object-oriented style: isDot() replaces manual ./.. checks, and isDir()/getFilename() replace separate calls to is_dir() and string handling.

How It Works Step by Step

  • Open: opendir() (or the constructor of DirectoryIterator) asks the OS to open a directory stream and PHP wraps the resulting file descriptor in a handle.
  • Read: each call to readdir() asks the OS for the next raw directory entry and advances an internal cursor; the OS is free to return entries in whatever order it stores them internally (often related to inode creation order, not name order).
  • Filter: your PHP code is responsible for skipping . and .., and for deciding what counts as a “real” entry (hidden files starting with . are not skipped automatically — only the two dot entries are special).
  • Classify: functions like is_dir(), is_file(), and filesize() perform a separate stat() system call per path, so calling them in a loop over many entries has a real (if usually small) performance cost.
  • Close: closedir() releases the underlying OS file descriptor. PHP will eventually close a leaked handle when the request ends and the resource is garbage collected, but explicitly closing it is good practice, especially in long-running scripts (CLI daemons, workers) that open many directories over their lifetime.

Common Mistakes

1. Forgetting to skip the dot entries

<?php
$dir = __DIR__ . '/uploads';
if ($handle = opendir($dir)) {
    while (false !== ($entry = readdir($handle))) {
        echo $entry . "\n";
    }
    closedir($handle);
}

Output:

.
..
notes.txt
photo.jpg
report.pdf

Every raw directory listing includes . and ... Forgetting to check for them is one of the most common directory bugs — it silently pollutes file lists, counts, and loops. Always filter them out explicitly:

<?php
$dir = __DIR__ . '/uploads';
if ($handle = opendir($dir)) {
    while (false !== ($entry = readdir($handle))) {
        if ($entry === '.' || $entry === '..') {
            continue;
        }
        echo $entry . "\n";
    }
    closedir($handle);
}

Output:

notes.txt
photo.jpg
report.pdf

2. Using a relative path instead of __DIR__

<?php
chdir(sys_get_temp_dir());
$files = scandir('uploads');
foreach ($files as $file) {
    echo $file . "\n";
}

Output:

Warning: scandir(uploads): Failed to open directory: No such file or directory
Fatal error: Uncaught TypeError: foreach() argument must be of type array|object, bool given

The relative path 'uploads' is resolved against the process’s current working directory, which is not guaranteed to be the folder your script lives in — here it has just been changed by chdir(), but the same thing happens whenever a script is invoked from a different location (a cron job, an include from another file, a different CLI invocation directory). Since scandir() returns false on failure, passing that straight into foreach throws a TypeError in modern PHP. Anchor the path to the script’s own location with __DIR__, which is a compile-time constant unaffected by chdir():

<?php
chdir(sys_get_temp_dir());
$dir = __DIR__ . '/uploads';
if (!is_dir($dir)) {
    mkdir($dir);
}
touch($dir . '/notes.txt');

$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
    echo $file . "\n";
}

Output:

notes.txt

3. Calling mkdir() on a nested path without the recursive flag

<?php
chdir(sys_get_temp_dir());
$dir = __DIR__ . '/cache/thumbnails/small';
$result = mkdir($dir);
var_dump($result);

Output:

Warning: mkdir(): No such file or directory
bool(false)

By default mkdir() can only create the final directory in a path — if cache or cache/thumbnails don’t already exist, it fails and emits a warning instead of creating the whole chain. Pass true as the third argument to create every missing parent directory automatically:

<?php
chdir(sys_get_temp_dir());
$dir = __DIR__ . '/cache/thumbnails/small';
$result = mkdir($dir, 0777, true);
var_dump($result);

Output:

bool(true)

Best Practices

  • Always build directory paths from __DIR__ (or an absolute application-root constant) rather than relative strings, so behavior doesn’t depend on the current working directory.
  • Always skip . and .. when iterating with opendir()/readdir() or scandir(); use DirectoryIterator::isDot() as a shortcut when working with iterators.
  • Prefer scandir() or glob() over manual opendir()/readdir() loops for simple listing tasks — less code, and sorted output by default.
  • Always pass true as the third argument to mkdir() when the target path may have missing parent directories.
  • Check return values: mkdir(), rmdir(), and opendir() all return false on failure rather than throwing — silently ignoring that return value hides real errors.
  • Remember rmdir() only removes empty directories; to delete a directory tree, recursively delete its contents first (or use a library helper), never assume it “just works” on populated folders.
  • Close directory handles with closedir() once you’re done, especially in loops or long-running processes, to avoid exhausting file descriptors.
  • Set directory permissions deliberately with the second argument to mkdir() (e.g. 0755) rather than relying on the default 0777, which is overly permissive on shared or production systems.

Practice Exercises

  1. Write a script that creates a directory called logs next to itself (using __DIR__) if it doesn’t already exist, then creates three empty files inside it named app.log, error.log, and access.log. Finally, list only the files (not directories) it finds there.
  2. Using glob(), write a script that finds every .log file inside a directory and prints each filename together with its size in bytes, sorted from largest to smallest.
  3. Write a function countEntries(string $dir): array that returns an associative array like ['files' => 3, 'dirs' => 1] for a given directory, using scandir() and is_dir(), correctly excluding . and ...

Summary

  • Directory functions in PHP (opendir(), readdir(), scandir(), glob()) all read from the same underlying OS directory stream, just with different levels of convenience.
  • Every raw listing includes the special . and .. entries — you must filter them out yourself unless you use a helper (like DirectoryIterator::isDot()) that does it for you.
  • readdir() order is filesystem-dependent and not sorted; scandir() sorts by default.
  • Use __DIR__ instead of relative paths so your script’s directory logic doesn’t depend on the current working directory.
  • mkdir() needs true as its third argument to create nested directories that don’t yet exist; rmdir() only removes empty directories.
  • glob() and DirectoryIterator offer more expressive, higher-level alternatives to manual opendir()/readdir() loops for most real-world tasks.