Contents
- 1 The quiet power of the filesystem
- 2 Seeing files like PHP sees them
- 3 The holy trinity: reading, writing, deleting
- 4 Working with directories: more than mkdir
- 5 Uploads, temp files, and user data
- 6 Permissions, ownership, and those weird production-only bugs
- 7 Streams: the deeper layer under everything
- 8 Building habits instead of one-off fixes
- 9 What this looks like in real work
The quiet power of the filesystem
Most of the time, when people talk about PHP, they talk about frameworks.
Laravel. Symfony. API platforms. Event buses. Queues. They talk about “architecture” and “clean code” and “DDD in PHP.”
But if you strip all of that away—if you turn off Composer, forget about containers, and just sit in front of a blinking cursor—you’re left with a much simpler question:
How does this PHP code touch the real world?
It writes to disk.
It reads from disk.
It moves files around that someone, somewhere, cares about.
User uploads. Logs. Reports. Backups. Tiny cache files created at 3:17 AM by a cron job like a small, tireless librarian.
Filesystem code is where your PHP application stops being an idea and becomes something you can literally see on the server.
And yet… most of us learned PHP file system functions the same way:
- Copy–paste from StackOverflow.
- “This works in dev, ship it.”
- Deal with the first bug when the production server has different permissions.
If you’ve ever pushed a release on Friday and then watched your log directory silently fill the disk all weekend, you know how much this stuff matters.
So, friends, let’s slow down for a moment.
Let’s talk about PHP file system functions not as dry documentation, but as tools we use in real life: late at night, under pressure, trying not to break that fragile shared hosting environment or that overworked Kubernetes node.
We’ll walk through:
- The core functions you should really understand
- The traps that bite even experienced developers
- How to write safer, more predictable file handling code
- A few patterns you can bring into your next project
And we’ll keep it grounded in the kind of scenarios you face at work. On projects you actually ship. For clients or companies who don’t care how elegant the abstraction is, as long as the invoice PDF is where it should be.
Seeing files like PHP sees them
When you run file_put_contents('report.txt', 'Hello'); PHP doesn’t think “ah, a report.”
It thinks:
- What is the current working directory?
- Do I have permissions here?
- Is
report.txta file that already exists? - What error reporting level is set?
If you don’t control those things, you don’t really control your I/O.
A simple mental reset helps:
- Stop thinking in terms of “files in the project.”
- Start thinking in terms of paths and contexts.
Absolute vs relative paths (and future headaches)
At some point, most of us wrote something like:
file_put_contents('logs/debug.log', $message);
It works from public/index.php, fails from a CLI command, behaves differently under a queue worker, and then someone moves the entry script and all your paths break.
So, one of the simplest upgrades you can make as a PHP developer is this:
Always anchor paths to something stable.
$basePath = __DIR__; // if you're in a config/bootstrap file
$logDir = $basePath . '/logs';
$logFile = $logDir . '/debug.log';
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
file_put_contents($logFile, $message . PHP_EOL, FILE_APPEND | LOCK_EX);
Suddenly:
- Your paths are predictable.
- Your logs directory gets created if missing.
- You’re not relying on wherever PHP “happens” to be running from.
Frameworks like Laravel or Symfony wrap this nicely (storage_path(), kernel.project_dir), but underneath, it’s the same idea.
Know where you are.
Build paths intentionally.
The holy trinity: reading, writing, deleting
Most file operations boil down to three core actions: read, write, delete. The rest is flavor.
Reading files: file_get_contents and friends
If you’ve worked with PHP for longer than an afternoon, you’ve seen file_get_contents():
$content = file_get_contents($path);
It’s deceptively simple. But there’s nuance:
- It returns
falseon failure, not an empty string. - It respects
php.inilimits likememory_limit. - It can fetch remote URLs if
allow_url_fopenis enabled (which can be useful or terrifying, depending on your threat model).
Safer usage:
if (!is_readable($path)) {
// log, throw, or handle – but don't just ignore it
throw new RuntimeException("File not readable: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("Failed to read: {$path}");
}
Rule of thumb:
If the file size is predictable and small (config files, templates, JSON blobs), file_get_contents() is perfect.
If you’re dealing with large files (video uploads, exports), don’t pull everything into memory—stream it.
Streaming example with fopen:
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException("Cannot open file: {$path}");
}
while (!feof($handle)) {
$chunk = fread($handle, 8192);
if ($chunk === false) {
fclose($handle);
throw new RuntimeException("Error reading from file: {$path}");
}
// process or echo $chunk
}
fclose($handle);
Not glamorous, but this is the code that keeps servers alive.
Writing files: file_put_contents and atomic thinking
file_put_contents() is the go-to for most write operations:
file_put_contents($path, $data);
It’s easy, but let’s improve it:
- Use flags:
FILE_APPENDto add,LOCK_EXto avoid race conditions. - Handle directories.
- Think atomic writes: write to a temp file, then rename.
A safer pattern:
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$tempPath = $path . '.' . uniqid('', true) . '.tmp';
if (file_put_contents($tempPath, $data, LOCK_EX) === false) {
throw new RuntimeException("Failed to write temp file: {$tempPath}");
}
if (!rename($tempPath, $path)) {
@unlink($tempPath);
throw new RuntimeException("Failed to move temp file into place: {$path}");
}
That rename is key. On most file systems, rename is atomic. Which means:
- No one ever sees a “half-written” file.
- Readers either see the old version or the new version, nothing in between.
When you’re building things like JSON caches, config dumps, or generated reports that other processes read, this pattern is gold.
Deleting files: unlink and the quiet fear
There is something emotionally different about unlink($path);.
Reading is safe. Writing feels constructive.
Deleting feels… irreversible.
So, treat deletes with a bit of ceremony:
if (is_file($path)) {
if (!unlink($path)) {
throw new RuntimeException("Failed to delete file: {$path}");
}
}
To avoid regrets in production:
- Consider a “trash” approach: move to a
deleted/directory instead of hard delete. - Add logging when you delete user data.
- Be very careful with variables in paths. A single missing slash can turn a
rm -rfscenario into reality.
In multi-user systems and job platforms, file deletion errors can easily turn into privacy issues or stale data in user dashboards. Those bugs rarely look fancy in a PR, but they absolutely show up in support tickets.
Working with directories: more than mkdir
Directories are where your “system design” hits the OS. Even a simple PHP job board or CV storage system quietly needs a strategy:
- Where do uploaded CVs go?
- How do you separate environments?
- How do you clean old files?
Creating directories reliably: mkdir
The classic:
mkdir($path, 0775, true);
Notes that are worth remembering:
- The
trueenables recursive creation (/var/www/app/storage/logsin one call). - The
0775is the mode; the actual permission might be affected byumask. - Calling
mkdiron an existing directory will emit a warning unless you silence it or check first.
A more defensive version:
if (!is_dir($path)) {
if (!mkdir($path, 0775, true) && !is_dir($path)) {
throw new RuntimeException("Cannot create directory: {$path}");
}
}
That double check might look redundant, but it handles the case where another process creates the directory between your first check and the mkdir call.
Listing files: scandir, glob, DirectoryIterator
Sometimes you just need to see what’s inside a directory. Simple example:
$files = array_diff(scandir($dir), ['.', '..']);
For pattern matching:
$files = glob($dir . '/*.log');
For more control and better readability in complex code, SPL iteration is your friend:
$files = [];
$iterator = new DirectoryIterator($dir);
foreach ($iterator as $file) {
if ($file->isDot() || !$file->isFile()) {
continue;
}
$files[] = [
'path' => $file->getPathname(),
'size' => $file->getSize(),
'mtime' => $file->getMTime(),
];
}
The moment you start building periodic cleanup jobs, exports, or media processing scripts, DirectoryIterator starts to feel much nicer than raw arrays.
And crucially: this is the kind of code that’s easier for future you—or another developer on your team—to reason about when they’re debugging a production issue on a sleepy Tuesday night.
Uploads, temp files, and user data
Let’s talk about something very real: file uploads.
CVs for a PHP job platform. Profile pictures. Portfolio archives. Log dumps your users send to support.
They all hit your application through $_FILES.
The safe way: is_uploaded_file and move_uploaded_file
When a user uploads a file, PHP drops it into a temporary directory and exposes metadata via $_FILES. That data is not trusted.
Proper handling:
$uploaded = $_FILES['resume'] ?? null;
if (!$uploaded || $uploaded['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed.');
}
if (!is_uploaded_file($uploaded['tmp_name'])) {
throw new RuntimeException('Potential file upload attack detected.');
}
$ext = pathinfo($uploaded['name'], PATHINFO_EXTENSION);
$ext = strtolower($ext);
$allowed = ['pdf', 'doc', 'docx'];
if (!in_array($ext, $allowed, true)) {
throw new RuntimeException('Unsupported file type.');
}
$targetDir = $basePath . '/storage/resumes';
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$targetPath = $targetDir . '/' . $newName;
if (!move_uploaded_file($uploaded['tmp_name'], $targetPath)) {
throw new RuntimeException('Failed to move uploaded file.');
}
Why this matters:
is_uploaded_file()ensures the file actually came from PHP’s upload mechanism.move_uploaded_file()is designed for safely moving uploaded files.- Random filenames avoid conflicts and leaking original file names or user IDs.
And one more thing people often forget: never trust MIME types from the client. If content type really matters, inspect the file contents server-side (e.g., finfo_file).
Temporary files: small helpers with big impact
When you generate reports, transcode media, or build zip archives, you often want a temporary “scratch” file.
PHP gives you sys_get_temp_dir() and tempnam():
$tmpDir = sys_get_temp_dir();
$tmpFile = tempnam($tmpDir, 'php_job_');
file_put_contents($tmpFile, $data);
// Use the file...
unlink($tmpFile);
A few use cases:
- Staging generated PDFs before sending them to S3
- Preparing CSVs for download
- Running external processes (like
wkhtmltopdf) that expect a file path
This is the boring plumbing most users never see, but it quietly determines whether your job board feels reliable or “flaky.”
Permissions, ownership, and those weird production-only bugs
You know that situation where everything works fine on your local machine, but fails on the staging server with a vague warning:
failed to open stream: Permission denied
PHP file system functions are deeply entangled with the OS:
- Which user is running PHP (FPM pool, Apache user, CLI user)?
- What are the dir/file permissions?
- What about SELinux or other security layers?
Ignoring this is how you end up SSH-ing into a server at 1 AM, manually running chmod -R 777 storage/ and hoping nobody ever sees the audit logs.
Understand the user behind PHP
A quietly powerful mental shift:
Always ask yourself: which user is executing this PHP code?
- On Apache with mod_php: probably
www-dataorapache. - On PHP-FPM: a specific pool user.
- On CLI: your shell user (unless you
sudoor run under a different account).
If you:
- Upload files as one user
- Run the web server as another
- Run cron jobs as a third
…you’ll eventually hit strange permission mismatches.
I’ve seen teams “fix” this by loosening permissions everywhere. It works until it doesn’t.
Better:
- Choose a single unix user for all app-related tasks, if possible.
- Make sure that user owns the storage directories.
- Set reasonable permissions from deployment scripts or container images.
is_readable, is_writable – don’t assume
Before you operate on files, check whether you can:
if (!is_writable($logDir)) {
// fall back to /tmp, or throw clearly
}
Yes, checks and error handling add noise.
But the alternative is silent failure and broken features.
If you’re building hiring platforms, job systems, or anything involving payment receipts or contracts, silent file errors will slowly erode trust in your product.
Streams: the deeper layer under everything
Under the hood, PHP’s filesystem is powered by the stream layer. For most day-to-day work, you don’t have to think about it explicitly. But sometimes it’s exactly what you need.
Streams are why you can do things like:
file_get_contents('php://input');
file_put_contents('php://stdout', "Hello\n");
$fp = fopen('php://memory', 'r+');
A few practical use cases you might actually encounter:
- Logging to
php://stderrin CLI scripts or containers - Reading raw HTTP request bodies from
php://input - Using in-memory streams (
php://memory,php://temp) to avoid temporary files on disk when working with small payloads
Example: logging from a CLI command in a container environment:
$log = fopen('php://stderr', 'w');
fwrite($log, "[" . date('c') . "] Job started\n");
fclose($log);
This way, you don’t care about log file paths at all—the container or hosting platform captures stdout/stderr.
When you start working in more complex environments—queues, workers, serverless functions—knowing that “files” in PHP are sometimes just streams is quietly empowering.
Building habits instead of one-off fixes
If you’ve been working with PHP for a while, you’ve probably noticed a pattern:
The bugs that hurt the most rarely come from framework code.
They come from the “little” parts:
- A log file that wasn’t rotated
- An upload directory that slowly filled up
- A temporary report that was never deleted
- A
renamethat failed silently on Windows paths
So, let’s turn PHP filesystem functions from random snippets into habits.
Habit 1: always check the result
Every filesystem function can fail: file_get_contents, fopen, mkdir, rename, unlink.
Instead of assuming success, make it a default pattern:
$result = file_put_contents($path, $data);
if ($result === false) {
throw new RuntimeException("Failed to write file: {$path}");
}
Or, if you prefer, centralize this in small helpers.
Habit 2: centralize paths
Instead of scattering '../storage/logs/app.log' across your codebase:
- Define a base path once.
- Build everything from it.
For example, for a job platform:
class Paths
{
public function __construct(private string $basePath)
{
}
public function resumes(): string
{
return $this->basePath . '/storage/resumes';
}
public function logs(): string
{
return $this->basePath . '/storage/logs';
}
public function temp(): string
{
return $this->basePath . '/storage/temp';
}
}
Now your code reads like:
$logFile = $paths->logs() . '/worker.log';
And when your infrastructure changes (new directory layout, different disks, mounted volumes), there’s one place to adjust.
Habit 3: think about lifecycle
Every file your system creates should have an answer to a quiet question:
When and how will this file disappear?
- Logs: rotation, max size, or retention policy
- Uploads: deletion when user closes account
- Temp files: cleanup job or auto-expiry
- Exports: time-limited download links
Filesystem growth is like technical debt in physical form. It doesn’t show up in unit tests, but it absolutely shows up in disk monitor alerts.
A simple daily cron job to delete old temp files might save you from a 3 AM incident.
What this looks like in real work
Imagine you’re working on a PHP platform like Find PHP:
- Developers upload CVs and portfolios.
- Companies download structured reports.
- The system generates logs, exports, and caches.
Underneath all the UI and API and Docker and buzzwords, there’s a quiet layer:
- Directories created with
mkdir - Files written with
file_put_contents - Uploads handled with
move_uploaded_file - Cleanup done with
unlinkandglob
It’s not glamorous, but this is where reliability happens.
And this is where good PHP developers quietly distinguish themselves:
- The developer who doesn’t just “save the file,” but thinks about atomic writes.
- The one who doesn’t just “delete the folder,” but thinks about permissions and logs the action.
- The one who anticipates that there will be more files in six months than there are today, and designs the directory structure like they’re planning a library, not a junk drawer.
These aren’t tricks you learn from memorizing APIs.
They come from living with systems long enough to see what breaks.
So the next time you write:
file_put_contents('some/path/file.txt', $data);
you might pause, take a breath, and ask:
- What user is writing this?
- What happens if it fails?
- Who will read this file later?
- How long should it live?
If you build that habit, you’re not just using PHP filesystem functions—you’re designing how your application touches reality.
And that’s the kind of quiet skill that doesn’t always show up in job descriptions, but it absolutely shows up in the systems people trust.