Downloading remote files from PHP needs more than writing the response stream to a path. A missing object, timeout, redirect loop, or partial transfer can leave a local file that looks available unless the script checks the transfer before replacing the destination.
PHP passes a writable file handle to libcurl through CURLOPT_FILE. curl_exec() reports transport failures, while HTTP responses such as 404 still need a status check with curl_getinfo() before the file is trusted.
Write each response to a temporary .part file first, verify the final HTTP status and optional byte count, then rename it into place. Keep private downloads outside the public web root, and use an expected size or checksum when the provider publishes one for the file.
Related: Save cURL output to a file
Related: Fail on HTTP errors in cURL
Related: Set timeouts for PHP cURL
Tool: HTTP Header Checker
$ mkdir -p webroot downloads
Use a private application directory instead of a public web directory when the downloaded file contains exports, reports, invoices, or other restricted data.
release=2026.06 status=ready
$ php -S 127.0.0.1:8080 -t webroot
Keep this terminal open while testing the downloader, then stop it with Ctrl+C after verification.
<?php if ($argc < 3 || $argc > 4) { fwrite(STDERR, "Usage: php download-file.php <url> <destination> [expected-bytes]\n"); exit(64); } $url = $argv[1]; $destination = $argv[2]; $expectedBytes = null; if (isset($argv[3])) { if (!ctype_digit($argv[3])) { fwrite(STDERR, "Expected bytes must be a whole number.\n"); exit(64); } $expectedBytes = (int) $argv[3]; } $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); if (!in_array($scheme, ['http', 'https'], true)) { fwrite(STDERR, "URL must use http or https.\n"); exit(64); } $directory = dirname($destination); if (!is_dir($directory) && !mkdir($directory, 0700, true)) { fwrite(STDERR, "Could not create destination directory: {$directory}\n"); exit(1); } $temporary = $destination . '.part'; $file = fopen($temporary, 'wb'); if ($file === false) { fwrite(STDERR, "Could not open temporary file: {$temporary}\n"); exit(1); } $curl = curl_init($url); if ($curl === false) { fclose($file); @unlink($temporary); fwrite(STDERR, "Could not initialize cURL.\n"); exit(1); } curl_setopt_array($curl, [ CURLOPT_FILE => $file, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, CURLOPT_USERAGENT => 'ExampleDownloader/1.0', ]); $success = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); $error = curl_error($curl); curl_close($curl); fclose($file); if ($success === false) { @unlink($temporary); fwrite(STDERR, "cURL error: {$error}\n"); exit(1); } if ($status < 200 || $status >= 300) { @unlink($temporary); fwrite(STDERR, "HTTP status: {$status}\n"); fwrite(STDERR, "Download failed; temporary file removed.\n"); exit(1); } $bytes = filesize($temporary); if ($bytes === false) { @unlink($temporary); fwrite(STDERR, "Could not read downloaded file size.\n"); exit(1); } if ($expectedBytes !== null && $bytes !== $expectedBytes) { @unlink($temporary); fwrite(STDERR, "Size mismatch: expected {$expectedBytes} bytes, got {$bytes} bytes.\n"); exit(1); } if (!rename($temporary, $destination)) { @unlink($temporary); fwrite(STDERR, "Could not move temporary file into place.\n"); exit(1); } echo "HTTP status: {$status}\n"; echo "Saved: {$destination}\n"; echo "Bytes: {$bytes}\n";
The script writes to downloads/release.txt.part during the transfer and renames it only after the status and byte-count checks pass.
$ php download-file.php http://127.0.0.1:8080/release.txt downloads/release.txt 29 HTTP status: 200 Saved: downloads/release.txt Bytes: 29
Replace the local http://127.0.0.1:8080/release.txt URL with the real https:// file URL when using the script outside the local test.
$ wc -c downloads/release.txt 29 downloads/release.txt
$ php download-file.php http://127.0.0.1:8080/missing.txt downloads/missing.txt HTTP status: 404 Download failed; temporary file removed.
HTTP errors are checked separately because receiving an HTTP error response is not the same thing as a cURL transport failure.
$ rm -rf webroot