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.

Steps to download a file with PHP cURL:

  1. Create local directories for the repeatable test target and the downloaded file.
    $ 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.

  2. Create the sample file that the local test server will publish.
    webroot/release.txt
    release=2026.06
    status=ready
  3. Start PHP's built-in server for the sample file in a second terminal.
    $ 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.

  4. Create the PHP cURL downloader script.
    download-file.php
    <?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.

  5. Download the sample file and check the expected byte count.
    $ 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.

  6. Confirm that the saved file has the expected size before another process consumes it.
    $ wc -c downloads/release.txt
    29 downloads/release.txt
  7. Verify that a missing remote file is rejected instead of being saved as a successful download.
    $ 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.

  8. Remove the local test webroot after the downloader is adapted to the real endpoint.
    $ rm -rf webroot