PHP gives you two levels of file handling: one-line helpers like file_get_contents() for whole files, and the fopen family for reading a line at a time. This guide covers both, plus listing directories, checking permissions properly, and the one security mistake — letting user input reach a file path — that turns a file feature into a way to read your configuration files.
Reading and writing whole files#
<?php
// Read the whole file into a string
$text = file_get_contents('notes.txt');
// Read into an array of lines, without the newlines
$lines = file('notes.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// Write, replacing anything already there
file_put_contents('notes.txt', "First line\n");
// Append instead of replacing
file_put_contents('notes.txt', "Second line\n", FILE_APPEND);
These four cover the majority of everyday file work. They all return false on failure, which is the part people skip:
<?php
$text = file_get_contents('notes.txt');
if ($text === false) {
// could not read - missing file, wrong permissions, bad path
exit('Could not read the file.');
}
Use === rather than ==. An empty file returns an empty string, and "" == false is true in PHP, so a loose comparison would report a perfectly readable empty file as an error.
Reading a line at a time#
file_get_contents() loads everything into memory. For a large log file that is a problem, so read it in pieces:
<?php
$handle = fopen('big.log', 'r');
if ($handle === false) {
exit('Could not open the file.');
}
while (($line = fgets($handle)) !== false) {
if (str_contains($line, 'ERROR')) {
echo $line;
}
}
fclose($handle);
Memory use stays flat no matter how big the file is. The comparison against false matters here too: a line containing just "0" is falsy in PHP, and a plain while ($line = fgets(...)) would stop early on it.
The mode string in fopen is worth memorising:
| Mode | Means | If the file is missing |
|---|---|---|
r |
read only | fails |
w |
write, truncates to empty first | creates it |
a |
append at the end | creates it |
x |
write, but only if new | creates it |
r+ |
read and write | fails |
Checking before you act#
<?php
$path = 'data/report.csv';
if (!file_exists($path)) {
exit('No such file.');
}
if (!is_readable($path)) {
exit('The file exists but PHP cannot read it.');
}
echo filesize($path), " bytes\n";
echo date('Y-m-d H:i', filemtime($path)), "\n";
echo is_dir($path) ? "directory\n" : "file\n";
Separating “does not exist” from “cannot be read” saves a great deal of time, because the two have completely different fixes: one is a wrong path, the other is file ownership on the server.
Listing a directory#
<?php
// Everything, including . and ..
$items = scandir('uploads');
// Just the CSV files, no dot entries
$csvFiles = glob('uploads/*.csv');
foreach ($csvFiles as $file) {
echo basename($file), ' - ', filesize($file), " bytes\n";
}
glob() is usually the better choice: it filters by pattern and skips . and .. for you. For walking subdirectories, the iterator classes handle recursion:
<?php
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator('uploads', RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'jpg') {
echo $file->getPathname(), "\n";
}
}
Creating, copying, moving and deleting#
<?php
mkdir('uploads/2026', 0755, true); // true = create parent folders too
copy('a.txt', 'backup/a.txt');
rename('old.txt', 'new.txt'); // also moves between folders
unlink('temp.txt'); // delete a file
rmdir('empty-folder'); // only works if empty
Each returns a boolean. Ignoring that return value is how “the upload silently did nothing” bugs happen.
The security part#
This is the code you must never write:
<?php
// DANGEROUS - do not use
$file = $_GET['file'];
echo file_get_contents('documents/' . $file);
A visitor requesting ?file=../../wp-config.php walks straight out of your documents folder and reads your database credentials. This is called path traversal and it is one of the most commonly exploited web vulnerabilities there is.
The fix is to resolve the path and confirm it is still inside the folder you intended:
<?php
$base = realpath('documents');
$requested = realpath($base . '/' . basename($_GET['file'] ?? ''));
if ($requested === false || !str_starts_with($requested, $base . DIRECTORY_SEPARATOR)) {
http_response_code(404);
exit('Not found.');
}
echo file_get_contents($requested);
Two defences stacked: basename() strips any directory part from the input, and realpath() resolves what is left so the str_starts_with check cannot be fooled by symlinks or extra dot segments.
Handling uploads#
<?php
if ($_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
exit('Upload failed.');
}
$tmp = $_FILES['photo']['tmp_name'];
// Trust the file contents, never the name the browser sent
$mime = mime_content_type($tmp);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!isset($allowed[$mime])) {
exit('Only JPEG and PNG images are accepted.');
}
$name = bin2hex(random_bytes(8)) . '.' . $allowed[$mime];
if (!move_uploaded_file($tmp, 'uploads/' . $name)) {
exit('Could not save the file.');
}
Generating your own filename removes an entire class of problem at once: no traversal, no collisions, no .php file smuggled in under an image extension.
Questions people ask#
Why does my script get “Permission denied”?
PHP runs as the web server user, not as you. That user needs write access to the folder. Check ownership on the server rather than setting permissions to 777, which makes the folder writable by anything running on the machine.
What is the difference between unlink and rmdir?
unlink() deletes a file, rmdir() removes a directory and only if it is already empty. To delete a folder with contents you have to walk it and remove the files first.
Should I use file_get_contents or fopen?
file_get_contents() when the file comfortably fits in memory and you want it all. fopen when the file is large, when you are streaming, or when you need to write repeatedly without reopening.
How do I write files safely if two requests might run at once?
Use flock() on the handle, or write to a temporary file and rename() it into place. Rename is atomic on the same filesystem, so readers never see a half-written file.
Where to go next#
- Handling errors and exceptions in PHP — turning those false returns into proper errors.
- Working with forms in PHP — where uploaded files come from.
- PHP security best practices — the wider version of the traversal section.