PHP cURL Requests Stopped Working — The Complete Debugging & Fix Guide

1426 views
curl requests not working in php php curl error fix curl stopped working after server update php curl ssl error curl api request not working php curl connection refused curl requests for database connection curl troubleshooting php php curl not connecting to api curl error 28 / timeout fix

Why “cURL Stopped Working” Is Never One Problem

When a developer says “cURL stopped working,” they almost never mean one thing. cURL requests break for dozens of completely different reasons — SSL certificate validation failures, firewall rule changes, DNS resolution failures, PHP version upgrades, server OS updates, TLS protocol mismatches, timeout configuration, blocked outbound ports, proxy issues, and more.

The original treatment of this problem — “update your CA certificates and check your SSL settings” — solves one specific scenario that happens after a certificate renewal on AWS. But developers hitting this problem in 2025 are dealing with far more varied contexts:

  • cURL broke after a composer update or PHP 8.x upgrade
  • cURL works on local but fails on the production server
  • cURL works for some domains but fails for others
  • cURL was working yesterday and nothing changed
  • cURL fails silently — no error, empty response
  • cURL fails only for HTTPS, not HTTP
  • cURL fails inside Docker but not on bare metal
  • cURL times out for one endpoint but not others
  • cURL fails in WordPress (wp_remote_post) but works in raw PHP

This guide gives you a systematic diagnostic framework — not a list of solutions to try randomly, but a decision tree that gets you from “cURL stopped working” to the exact root cause and the precise fix, every time.

 

cURL API Request Troubleshooting Flow

Part 1 — The Anatomy of a cURL Request and Where It Can Break

Understanding where cURL fails requires understanding the pipeline it traverses:

PHP Script
    │
    ▼
PHP cURL Extension (libcurl)
    │
    ├── DNS Resolution         ← Failure: CURLE_COULDNT_RESOLVE_HOST (6)
    │       │
    │       ▼
    ├── TCP Connection         ← Failure: CURLE_COULDNT_CONNECT (7)
    │   (port 80/443)         ← Failure: CURLE_OPERATION_TIMEDOUT (28)
    │       │
    │       ▼
    ├── TLS Handshake          ← Failure: CURLE_SSL_CONNECT_ERROR (35)
    │   ├── Certificate check  ← Failure: CURLE_PEER_FAILED_VERIFICATION (60)
    │   ├── Protocol version   ← Failure: CURLE_SSL_CIPHER (59)
    │   └── CA bundle          ← Failure: CURLE_SSL_CACERT (60)
    │       │
    │       ▼
    ├── HTTP Request Sent      ← Failure: CURLE_SEND_ERROR (55)
    │       │
    │       ▼
    ├── HTTP Response Received ← Failure: CURLE_RECV_ERROR (56)
    │       │
    │       ▼
    └── Response Returned to PHP

External Factors at Every Stage:
    ├── Firewall (iptables/ufw/Security Groups)
    ├── SELinux / AppArmor policies
    ├── Proxy servers (transparent or explicit)
    ├── DNS server configuration
    └── OS-level SSL policy (FIPS mode, crypto policies)

When cURL fails, curl_errno($ch) gives you the error code and curl_error($ch) gives you the human-readable message. Always capture both — every diagnosis in this guide starts there.

 

Part 2 — The Universal cURL Diagnostic Template

Before doing anything else, wrap every cURL call in this diagnostic template. It tells you the exact error code, message, and response metadata in one pass.

<?php
/**
 * Universal cURL Diagnostic Wrapper
 *
 * Replace your existing cURL call with this function temporarily.
 * It logs everything needed to diagnose any cURL failure.
 *
 * Usage:
 *   $result = curl_diagnose('https://api.example.com/endpoint', [
 *       'method'  => 'POST',
 *       'headers' => ['Content-Type: application/json', 'Authorization: Bearer token'],
 *       'body'    => json_encode(['key' => 'value']),
 *       'timeout' => 30,
 *   ]);
 */
function curl_diagnose(string $url, array $options = []): array {

    $method  = strtoupper($options['method'] ?? 'GET');
    $headers = $options['headers'] ?? [];
    $body    = $options['body']    ?? null;
    $timeout = $options['timeout'] ?? 30;

    $ch = curl_init();

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER         => true,       // Include response headers
        CURLOPT_VERBOSE        => true,       // Enable verbose output
        CURLOPT_SSL_VERIFYPEER => true,       // KEEP THIS TRUE in production
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_TIMEOUT        => $timeout,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_ENCODING       => '',         // Accept all encodings
        CURLOPT_USERAGENT      => 'PHP/' . PHP_VERSION . ' cURL/' . curl_version()['version'],
    ]);

    // Capture verbose output (the TLS handshake trace)
    $verbose_log = fopen('php://temp', 'w+');
    curl_setopt($ch, CURLOPT_STDERR, $verbose_log);

    // Method handling
    if ($method === 'POST') {
        curl_setopt($ch, CURLOPT_POST, true);
        if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    } elseif ($method !== 'GET') {
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }

    $start_time = microtime(true);
    $response   = curl_exec($ch);
    $elapsed_ms = round((microtime(true) - $start_time) * 1000, 2);

    // Collect all diagnostic data
    $errno    = curl_errno($ch);
    $error    = curl_error($ch);
    $info     = curl_getinfo($ch);

    // Read verbose log
    rewind($verbose_log);
    $verbose_output = stream_get_contents($verbose_log);
    fclose($verbose_log);

    curl_close($ch);

    // Separate headers and body
    $response_headers = '';
    $response_body    = '';

    if ($response !== false && isset($info['header_size'])) {
        $response_headers = substr($response, 0, $info['header_size']);
        $response_body    = substr($response, $info['header_size']);
    }

    $diagnostics = [
        // ── Core Result ────────────────────────────────────────────────────
        'success'       => ($errno === 0 && $response !== false),
        'error_code'    => $errno,
        'error_message' => $error,
        'elapsed_ms'    => $elapsed_ms,

        // ── HTTP Info ─────────────────────────────────────────────────────
        'http_code'     => $info['http_code'],
        'redirect_count'=> $info['redirect_count'],
        'final_url'     => $info['url'],

        // ── Connection Info ───────────────────────────────────────────────
        'primary_ip'        => $info['primary_ip'],
        'primary_port'      => $info['primary_port'],
        'connect_time_ms'   => round($info['connect_time'] * 1000, 2),
        'ssl_verify_result' => $info['ssl_verify_result'],
        'ssl_version'       => $info['ssl_version'] ?? 'N/A',

        // ── Timing Breakdown ─────────────────────────────────────────────
        'namelookup_ms'     => round($info['namelookup_time'] * 1000, 2),
        'connect_ms'        => round($info['connect_time'] * 1000, 2),
        'appconnect_ms'     => round($info['appconnect_time'] * 1000, 2),  // TLS done
        'pretransfer_ms'    => round($info['pretransfer_time'] * 1000, 2),
        'starttransfer_ms'  => round($info['starttransfer_time'] * 1000, 2),
        'total_ms'          => round($info['total_time'] * 1000, 2),

        // ── Transfer Stats ────────────────────────────────────────────────
        'size_download'     => $info['size_download'],
        'speed_download'    => $info['speed_download'],

        // ── Certificates ─────────────────────────────────────────────────
        'certinfo'          => $info['certinfo'] ?? [],

        // ── Response Data ─────────────────────────────────────────────────
        'response_headers'  => $response_headers,
        'response_body'     => $response_body,

        // ── Verbose TLS Trace (invaluable for SSL issues) ─────────────────
        'verbose'           => $verbose_output,

        // ── Environment Info ──────────────────────────────────────────────
        'curl_version'      => curl_version(),
        'php_version'       => PHP_VERSION,
    ];

    // Interpret the error for quicker diagnosis
    if ($errno !== 0) {
        $diagnostics['interpretation'] = curl_interpret_error($errno, $error);
    }

    return $diagnostics;
}

/**
 * Interpret a cURL error number into a human-readable diagnosis and action.
 */
function curl_interpret_error(int $errno, string $error): array {
    $interpretations = [
        // ── DNS ────────────────────────────────────────────────────────────
        CURLE_COULDNT_RESOLVE_PROXY => [
            'category' => 'DNS',
            'cause'    => 'Cannot resolve the proxy hostname.',
            'action'   => 'Check CURLOPT_PROXY setting or remove proxy config.',
        ],
        CURLE_COULDNT_RESOLVE_HOST => [
            'category' => 'DNS',
            'cause'    => 'Cannot resolve the target hostname to an IP address.',
            'action'   => 'Run: nslookup {hostname}. Check DNS server, /etc/resolv.conf, network access.',
        ],

        // ── Connection ────────────────────────────────────────────────────
        CURLE_COULDNT_CONNECT => [
            'category' => 'Connection',
            'cause'    => 'TCP connection refused or port not reachable.',
            'action'   => 'Run: curl -v {url} from shell. Check firewall (iptables, Security Groups), target port open, service running.',
        ],
        CURLE_OPERATION_TIMEDOUT => [
            'category' => 'Timeout',
            'cause'    => 'Request exceeded CURLOPT_TIMEOUT or CURLOPT_CONNECTTIMEOUT.',
            'action'   => 'Increase timeout, check network latency, check if target server is overloaded. Run: traceroute {hostname}.',
        ],

        // ── SSL/TLS ───────────────────────────────────────────────────────
        CURLE_SSL_CONNECT_ERROR => [
            'category' => 'SSL/TLS',
            'cause'    => 'TLS handshake failed. Protocol/cipher mismatch.',
            'action'   => 'Run: openssl s_client -connect {host}:443. Check TLS version support. Update libssl/openssl.',
        ],
        CURLE_PEER_FAILED_VERIFICATION => [
            'category' => 'SSL/TLS',
            'cause'    => 'SSL certificate chain cannot be verified against CA bundle.',
            'action'   => 'Update CA certificates. Check CURLOPT_CAINFO path. Verify intermediate certs are in chain.',
        ],
        CURLE_SSL_CIPHER => [
            'category' => 'SSL/TLS',
            'cause'    => 'No shared cipher between client and server.',
            'action'   => 'Server requires ciphers not supported by your OpenSSL. Update OpenSSL or set CURLOPT_SSL_CIPHER_LIST.',
        ],
        CURLE_SSL_CERTPROBLEM => [
            'category' => 'SSL/TLS',
            'cause'    => 'Problem with local SSL certificate (client certificate auth).',
            'action'   => 'Check CURLOPT_SSLCERT and CURLOPT_SSLKEY paths and permissions.',
        ],
        CURLE_SSL_CACERT_BADFILE => [
            'category' => 'SSL/TLS',
            'cause'    => 'CA bundle file cannot be read or is invalid format.',
            'action'   => 'Verify CURLOPT_CAINFO path exists and is readable. Regenerate CA bundle.',
        ],

        // ── HTTP ──────────────────────────────────────────────────────────
        CURLE_HTTP_RETURNED_ERROR => [
            'category' => 'HTTP',
            'cause'    => 'Server returned HTTP 4xx or 5xx (only when CURLOPT_FAILONERROR is set).',
            'action'   => 'Check HTTP status code. Review API credentials and request format.',
        ],
        CURLE_TOO_MANY_REDIRECTS => [
            'category' => 'HTTP',
            'cause'    => 'Exceeded CURLOPT_MAXREDIRS redirect limit.',
            'action'   => 'Increase CURLOPT_MAXREDIRS or fix redirect loop at the target server.',
        ],

        // ── Transfer ─────────────────────────────────────────────────────
        CURLE_SEND_ERROR => [
            'category' => 'Transfer',
            'cause'    => 'Failed to send network data after connection established.',
            'action'   => 'Usually intermittent. Check network stability. May indicate server-side connection reset.',
        ],
        CURLE_RECV_ERROR => [
            'category' => 'Transfer',
            'cause'    => 'Failed to receive network data.',
            'action'   => 'Server closed connection mid-response. Check server logs. Increase CURLOPT_TIMEOUT.',
        ],

        // ── Auth ─────────────────────────────────────────────────────────
        CURLE_LOGIN_DENIED => [
            'category' => 'Authentication',
            'cause'    => 'Remote server denied login (FTP/SSH).',
            'action'   => 'Verify CURLOPT_USERPWD credentials.',
        ],
    ];

    return $interpretations[$errno] ?? [
        'category' => 'Unknown',
        'cause'    => "cURL error #{$errno}: {$error}",
        'action'   => 'Check https://curl.se/libcurl/c/libcurl-errors.html for error code documentation.',
    ];
}

How to Use the Diagnostic Tool

<?php
// Quick diagnosis — run this from a test script on your server
$result = curl_diagnose('https://api.target.com/v1/endpoint', [
    'method'  => 'POST',
    'headers' => ['Content-Type: application/json'],
    'body'    => json_encode(['test' => true]),
    'timeout' => 30,
]);

if (!$result['success']) {
    echo "=== FAILURE DIAGNOSIS ===\n";
    echo "Error Code:    " . $result['error_code'] . "\n";
    echo "Error Message: " . $result['error_message'] . "\n";
    echo "Category:      " . ($result['interpretation']['category'] ?? 'N/A') . "\n";
    echo "Cause:         " . ($result['interpretation']['cause']    ?? 'N/A') . "\n";
    echo "Action:        " . ($result['interpretation']['action']   ?? 'N/A') . "\n\n";
    echo "=== TIMING ===\n";
    echo "DNS Lookup:    " . $result['namelookup_ms']  . "ms\n";
    echo "TCP Connect:   " . $result['connect_ms']     . "ms\n";
    echo "TLS Complete:  " . $result['appconnect_ms']  . "ms\n";
    echo "Total:         " . $result['total_ms']       . "ms\n\n";
    echo "=== VERBOSE OUTPUT ===\n";
    echo $result['verbose'];
} else {
    echo "=== SUCCESS ===\n";
    echo "HTTP Status:   " . $result['http_code']   . "\n";
    echo "Total Time:    " . $result['total_ms']    . "ms\n";
    echo "Response Size: " . $result['size_download'] . " bytes\n";
    echo "IP Connected:  " . $result['primary_ip']  . "\n";
    echo "SSL Version:   " . $result['ssl_version'] . "\n";
}

 

Part 3 — The Complete cURL Error Code Reference

Every cURL error has a number. Here is the complete reference with causes and fixes for the errors PHP developers encounter most:

Category 1: DNS Errors (Codes 5, 6)

Code Constant Message Common Cause
5 CURLE_COULDNT_RESOLVE_PROXY Couldn’t resolve proxy Bad proxy hostname in config
6 CURLE_COULDNT_RESOLVE_HOST Couldn’t resolve host DNS failure, typo in URL, offline

Diagnosing error 6:

# Can you resolve the hostname from the server?
nslookup api.example.com
dig api.example.com

# Check which DNS server your server uses
cat /etc/resolv.conf

# Test DNS resolution directly
host api.example.com 8.8.8.8

# Is it only failing for external hosts?
nslookup google.com       # External
nslookup internal.db.local # Internal

Fixes for DNS error 6:

# Fix 1: Flush DNS cache
sudo systemctl restart systemd-resolved   # Ubuntu 18.04+
sudo /etc/init.d/nscd restart             # Ubuntu 16.04

# Fix 2: Add a reliable DNS server
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
echo "nameserver 8.8.4.4" >> /etc/resolv.conf

# Fix 3: Add to /etc/hosts if DNS is unreliable
echo "104.21.45.23 api.example.com" >> /etc/hosts

# Fix 4: In Docker — DNS often fails
# Add to docker-compose.yml:
# dns:
#   - 8.8.8.8
#   - 8.8.4.4

In PHP — add fallback to IP:

<?php
// If DNS is flaky, bypass it by connecting to IP directly
$ch = curl_init('https://104.21.45.23/api/endpoint');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Host: api.example.com',  // Tell the server which virtual host you want
    'Content-Type: application/json',
]);
// Note: This bypasses DNS but you must still verify the SSL cert
// Set CURLOPT_RESOLVE for a cleaner approach:
curl_setopt($ch, CURLOPT_RESOLVE, [
    'api.example.com:443:104.21.45.23'  // Format: host:port:ip
]);

Category 2: Connection Errors (Codes 7, 28, 47)

Code Constant Message Common Cause
7 CURLE_COULDNT_CONNECT Connection refused Port blocked, service down
28 CURLE_OPERATION_TIMEDOUT Timeout Slow network, server overloaded
47 CURLE_TOO_MANY_REDIRECTS Max redirects hit Redirect loop

Diagnosing error 7 — Connection refused:

# Can your server reach the target port at all?
telnet api.example.com 443
nc -zv api.example.com 443

# Trace the network path
traceroute api.example.com
mtr api.example.com

# Check if YOUR server's outbound is blocked
curl -v https://api.example.com     # From server shell
curl -v http://api.example.com:80   # Try HTTP
curl -v https://api.example.com:443 # Explicit HTTPS

# Check iptables outbound rules
sudo iptables -L OUTPUT -n -v

# Check AWS/GCP/Azure Security Group
# Look for outbound rules blocking port 443 to 0.0.0.0/0

Error 7 fixes:

# Fix 1: Allow outbound HTTPS in iptables
sudo iptables -I OUTPUT -p tcp --dport 443 -j ACCEPT
sudo iptables -I OUTPUT -p tcp --dport 80  -j ACCEPT
sudo netfilter-persistent save

# Fix 2: Allow in UFW (Ubuntu)
sudo ufw allow out 443/tcp
sudo ufw allow out 80/tcp

# Fix 3: AWS Security Groups
# Go to EC2 → Security Groups → Outbound Rules
# Add: Type=HTTPS, Port=443, Destination=0.0.0.0/0

# Fix 4: Check if Docker container has network access
docker exec -it your_container curl -v https://google.com
# If that works but your endpoint doesn't, it's target-specific

Diagnosing and fixing error 28 — Timeout:

# Measure actual response time from shell
time curl -v https://api.example.com/slow-endpoint

# Run multiple times to check consistency
for i in {1..5}; do time curl -s -o /dev/null https://api.example.com; done
<?php
// Fix: Proper timeout configuration
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 10,    // Time to establish connection (seconds)
    CURLOPT_TIMEOUT        => 60,    // Time for entire request (seconds)
    // For long-running but active connections:
    CURLOPT_LOW_SPEED_LIMIT => 1,    // Min bytes/second
    CURLOPT_LOW_SPEED_TIME  => 30,   // Seconds at low speed before timeout
]);

// For very slow APIs — use millisecond precision
curl_setopt($ch, CURLOPT_TIMEOUT_MS,        60000);  // 60 seconds
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 10000);  // 10 seconds

Category 3: SSL/TLS Errors (Codes 35, 51, 58, 59, 60, 77, 83)

This is the largest category — SSL errors are the most common cause of cURL breaking after server updates.

Code Constant Message Common Cause
35 CURLE_SSL_CONNECT_ERROR SSL connect error TLS handshake failed
51 CURLE_PEER_FAILED_VERIFICATION SSL: certificate name mismatch CN/SAN doesn’t match host
58 CURLE_SSL_CERTPROBLEM Local cert problem Client cert misconfigured
59 CURLE_SSL_CIPHER No cipher match OpenSSL too old for server
60 CURLE_SSL_CACERT SSL cert verification failed CA bundle can’t verify chain
77 CURLE_SSL_CACERT_BADFILE CA bundle read error File missing/unreadable
83 CURLE_SSL_ISSUER_ERROR Issuer check failed Pinned issuer mismatch

Fixing Error 60 — “SSL certificate problem: unable to get local issuer certificate”

This is the most common SSL cURL error and the one the original article focuses on. Here is the complete, multi-environment fix:

# ── Step 1: Confirm the error is actually error 60 ──────────────────────────
php -r "
\$ch = curl_init('https://your-target.com');
curl_setopt(\$ch, CURLOPT_RETURNTRANSFER, true);
curl_exec(\$ch);
echo 'errno: ' . curl_errno(\$ch) . PHP_EOL;
echo 'error: ' . curl_error(\$ch) . PHP_EOL;
curl_close(\$ch);
"

# ── Step 2: Confirm the SSL chain is actually broken ─────────────────────────
openssl s_client -connect your-target.com:443 -servername your-target.com 2>/dev/null | \
    openssl x509 -noout -text | grep -E "(Subject|Issuer|Not After)"

# ── Step 3: Check current CA bundle ──────────────────────────────────────────
php -r "echo curl_version()['ssl_version_number']; print_r(curl_version());"

Fix on Ubuntu/Debian:

sudo apt-get update
sudo apt-get install -y ca-certificates
sudo update-ca-certificates --fresh
# Verify CA bundle updated
ls -la /etc/ssl/certs/ca-certificates.crt

Fix on CentOS/RHEL/AlmaLinux/Rocky Linux:

sudo yum update -y ca-certificates
# OR on newer systems:
sudo dnf update -y ca-certificates
sudo update-ca-trust force-enable
sudo update-ca-trust extract
# Verify
trust list | grep "mozilla"

Fix on Amazon Linux 2:

sudo yum update -y ca-certificates
sudo update-ca-trust

Fix on Alpine Linux (Docker containers):

apk update
apk add ca-certificates
update-ca-certificates

Fix in PHP — explicitly point to CA bundle:

<?php
/**
 * Find the correct CA bundle path for the current environment.
 * Different Linux distributions, PHP versions, and hosting environments
 * store the CA bundle in different locations.
 */
function find_ca_bundle(): string {
    $candidates = [
        // Ubuntu/Debian
        '/etc/ssl/certs/ca-certificates.crt',
        // CentOS/RHEL/Amazon Linux
        '/etc/ssl/certs/ca-bundle.crt',
        '/etc/pki/tls/certs/ca-bundle.crt',
        // Alpine Linux (Docker)
        '/etc/ssl/certs/ca-cert-Amazon_Root_CA_1.pem',
        '/etc/ssl/cert.pem',
        // macOS (Homebrew)
        '/usr/local/etc/openssl/cert.pem',
        '/opt/homebrew/etc/openssl@3/cert.pem',
        // Windows (XAMPP)
        'C:/xampp/php/extras/ssl/cacert.pem',
        // cPanel / shared hosting
        '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
        // Plesk
        '/usr/share/ca-certificates/mozilla/DigiCert_Global_Root_CA.crt',
        // What PHP's cURL extension was compiled with
        curl_version()['cainfo'] ?? '',
    ];

    foreach ($candidates as $path) {
        if ($path && file_exists($path) && is_readable($path)) {
            return $path;
        }
    }

    // Last resort: download Mozilla's bundle
    // (use this only as a one-time bootstrap fix)
    return download_mozilla_ca_bundle();
}

function download_mozilla_ca_bundle(): string {
    $path = sys_get_temp_dir() . '/cacert.pem';
    if (!file_exists($path)) {
        $content = file_get_contents('https://curl.se/ca/cacert.pem');
        if ($content) file_put_contents($path, $content);
    }
    return $path;
}

// Usage:
$ch = curl_init('https://api.example.com');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_SSL_VERIFYPEER => true,  // ALWAYS keep true in production
    CURLOPT_SSL_VERIFYHOST => 2,
    CURLOPT_CAINFO         => find_ca_bundle(),
]);

Fixing Error 35 — TLS Handshake Failed (Protocol/Cipher Mismatch)

This happens when:

  • Your server’s OpenSSL is too old and doesn’t support TLS 1.3 required by the target
  • The target server dropped TLS 1.0/1.1 support and your server only supports those
  • FIPS mode is enabled (blocks many standard ciphers)
# ── Diagnose: What TLS versions does the target accept? ─────────────────────
# Test TLS 1.2
openssl s_client -connect api.example.com:443 -tls1_2 2>&1 | tail -5
# Test TLS 1.3
openssl s_client -connect api.example.com:443 -tls1_3 2>&1 | tail -5

# ── Diagnose: What does your server's OpenSSL support? ───────────────────────
openssl version -a
openssl ciphers -v | head -20

# ── Diagnose: Check if FIPS mode is active ───────────────────────────────────
cat /proc/sys/crypto/fips_enabled   # 1 = FIPS enabled
<?php
// Fix in PHP — Force specific TLS version
$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER  => true,
    CURLOPT_SSL_VERIFYPEER  => true,
    CURLOPT_SSL_VERIFYHOST  => 2,
    // Force TLS 1.2 or higher (defined as of PHP 7.0 / libcurl 7.34)
    CURLOPT_SSLVERSION      => CURL_SSLVERSION_TLSv1_2,
    // OR TLS 1.3 specifically:
    // CURLOPT_SSLVERSION   => CURL_SSLVERSION_TLSv1_3,
    // OR let cURL negotiate (usually best):
    // CURLOPT_SSLVERSION   => CURL_SSLVERSION_DEFAULT,

    // Set explicit ciphers if needed (rarely required)
    // CURLOPT_SSL_CIPHER_LIST => 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256',
]);
# Fix at OS level — Update OpenSSL
sudo apt-get install -y openssl libssl-dev       # Ubuntu/Debian
sudo yum install -y openssl openssl-devel         # CentOS/RHEL

# Fix: Disable FIPS mode if it's causing issues (requires server restart)
sudo fips-mode-setup --disable

Fixing Error 51 — Certificate Name Mismatch

# Diagnose: What name is the certificate issued to?
openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null | \
    openssl x509 -noout -subject -ext subjectAltName
<?php
// Fix: Explicitly set SNI (Server Name Indication) — critical for shared hosting
$ch = curl_init('https://api.example.com/endpoint');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
    // Force SNI — sends the hostname in TLS ClientHello
    // This is usually automatic but can fail on some server configs
    CURLOPT_URL => 'https://api.example.com/endpoint',  // Must match cert CN/SAN
]);

Category 4: HTTP Errors (Not cURL Errors)

These are not cURL failures — the request succeeded but the server returned an HTTP error code. Many developers confuse this with cURL failure.

<?php
// WRONG — doesn't check for HTTP errors
$response = curl_exec($ch);
if ($response === false) {
    echo "Failed!"; // Only catches cURL errors, misses HTTP 4xx/5xx
}

// CORRECT — check both cURL errors AND HTTP status
$response  = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errno     = curl_errno($ch);
$error     = curl_error($ch);

if ($errno !== 0) {
    // cURL-level failure (network, SSL, DNS, timeout)
    echo "cURL Error #{$errno}: {$error}";
} elseif ($http_code >= 400) {
    // HTTP-level failure (auth, not found, server error, rate limit)
    echo "HTTP Error {$http_code}";
    echo "Response: " . substr($response, 0, 500);
} else {
    // Success
    $data = json_decode($response, true);
}

Common HTTP errors misread as cURL problems:

HTTP Code Common Cause Debug Action
400 Bad request body / missing required field Log $response — server returns error details
401 Wrong/expired API key Check Authorization header, regenerate token
403 IP blocked / wrong permissions Check API dashboard for IP allowlist
404 Wrong endpoint URL Verify URL against API docs, check trailing slashes
429 Rate limit exceeded Add sleep/retry logic, check rate limit headers
500 Remote server bug Log $response, contact API support
502 Upstream server error Retry after delay
503 Server overloaded / maintenance Retry with exponential backoff

 

Part 4 — Environment-Specific Fixes

Docker Containers

Docker containers have three unique cURL failure modes that don`t exist on bare metal:

Failure Mode 1: DNS not resolving external hosts

# docker-compose.yml — add explicit DNS
version: '3.9'
services:
  app:
    image: php:8.2-fpm
    dns:
      - 8.8.8.8
      - 1.1.1.1
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

Failure Mode 2: No CA certificates in minimal images

# Dockerfile — Alpine base (php:8.2-fpm-alpine)
FROM php:8.2-fpm-alpine

# Alpine has no CA certs by default
RUN apk add --no-cache ca-certificates curl

# Enable cURL extension with required deps
RUN apk add --no-cache \
    libcurl \
    curl-dev \
    && docker-php-ext-install curl

# Copy a fresh CA bundle (alternative to apk)
# COPY cacert.pem /etc/ssl/certs/ca-certificates.crt
# Dockerfile — Debian base (php:8.2-fpm)
FROM php:8.2-fpm

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        libcurl4-openssl-dev \
    && docker-php-ext-install curl \
    && update-ca-certificates \
    && rm -rf /var/lib/apt/lists/*

Failure Mode 3: Container can’t reach services on host machine

<?php
// Access host machine from inside Docker container
// On Linux (Docker host networking):
$host_url = 'http://host.docker.internal:3000/api';  // Works on Docker Desktop
// OR use the gateway IP:
$host_url = 'http://172.17.0.1:3000/api';             // Linux Docker default

$ch = curl_init($host_url);

AWS EC2 Instances

After installing a new SSL certificate (the original article’s scenario):

# ── Complete AWS SSL + cURL fix procedure ────────────────────────────────────

# 1. Update system CA certificates
sudo apt-get update && sudo apt-get install -y ca-certificates   # Ubuntu
sudo update-ca-certificates --fresh

# 2. Restart PHP-FPM to pick up new certs
sudo systemctl restart php8.2-fpm    # Adjust version

# 3. Restart web server
sudo systemctl restart nginx         # OR apache2

# 4. Verify PHP sees the new CA bundle
php -r "var_dump(curl_version()['cainfo']);"

# 5. Test from PHP CLI
php -r "
\$ch = curl_init('https://your-domain.com');
curl_setopt(\$ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt(\$ch, CURLOPT_SSL_VERIFYPEER, true);
\$r = curl_exec(\$ch);
echo curl_errno(\$ch) ? 'ERROR: ' . curl_error(\$ch) : 'SUCCESS: HTTP ' . curl_getinfo(\$ch, CURLINFO_HTTP_CODE);
curl_close(\$ch);
"

AWS Security Group misconfiguration:

# Check outbound rules (cURL makes OUTBOUND connections)
# AWS Console: EC2 → Security Groups → Your SG → Outbound Rules

# Required outbound rules:
# Type: HTTPS    Protocol: TCP    Port: 443    Destination: 0.0.0.0/0
# Type: HTTP     Protocol: TCP    Port: 80     Destination: 0.0.0.0/0
# Type: DNS      Protocol: UDP    Port: 53     Destination: 0.0.0.0/0

# If you use a NAT Gateway, also check the NACL on the subnet
# Network ACLs (NACLs) are stateless — need both inbound AND outbound rules

AWS instance metadata and IMDSv2:

<?php
// If your PHP app uses AWS IMDS (instance metadata) via cURL:
// IMDSv2 requires a PUT request first for the token
$ch = curl_init('http://169.254.169.254/latest/api/token');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_HTTPHEADER    => [
        'X-aws-ec2-metadata-token-ttl-seconds: 21600'
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 5,
]);
$token = curl_exec($ch);
curl_close($ch);

// Now use the token to get metadata
$ch2 = curl_init('http://169.254.169.254/latest/meta-data/instance-id');
curl_setopt_array($ch2, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ["X-aws-ec2-metadata-token: {$token}"],
    CURLOPT_TIMEOUT        => 5,
]);
$instance_id = curl_exec($ch2);
curl_close($ch2);

Shared Hosting (cPanel / Plesk)

<?php
/**
 * Shared hosting often restricts outbound connections.
 * Common fixes:
 */

// Fix 1: Use PHP's stream context instead of cURL (often unrestricted)
$context = stream_context_create([
    'http' => [
        'method'  => 'POST',
        'header'  => "Content-Type: application/json\r\nAuthorization: Bearer {$token}\r\n",
        'content' => json_encode($data),
        'timeout' => 30,
    ],
    'ssl' => [
        'verify_peer'       => true,
        'verify_peer_name'  => true,
        'cafile'            => '/etc/ssl/certs/ca-certificates.crt',
    ],
]);

$response = file_get_contents('https://api.example.com/endpoint', false, $context);

// Fix 2: Use WordPress HTTP API (in WordPress context)
$response = wp_remote_post('https://api.example.com/endpoint', [
    'timeout' => 30,
    'headers' => ['Content-Type' => 'application/json'],
    'body'    => json_encode($data),
]);

// Fix 3: Check if specific ports are blocked
// Many shared hosts block ALL outbound except port 80 and 443
// For custom ports (e.g., 8080, 9000), contact your host

WordPress-Specific cURL Issues

WordPress wraps cURL in wp_remote_* functions via the WP HTTP API. Different failure modes apply:

<?php
/**
 * WordPress cURL Debugging
 * wp_remote_post() / wp_remote_get() failures
 */

// ── Debug WP HTTP API calls ───────────────────────────────────────────────
add_filter('http_request_args', function(array $args, string $url): array {
    // Log all HTTP requests WordPress makes
    error_log('[WP HTTP] ' . $args['method'] . ' ' . $url);
    return $args;
}, 10, 2);

add_filter('http_response', function($response, array $args, string $url) {
    if (is_wp_error($response)) {
        error_log('[WP HTTP Error] ' . $url . ': ' . $response->get_error_message());
    } else {
        error_log('[WP HTTP] ' . $url . ' → ' . wp_remote_retrieve_response_code($response));
    }
    return $response;
}, 10, 3);

// ── Check what HTTP transport WordPress is using ──────────────────────────
add_action('init', function(): void {
    $transport = new WP_HTTP();
    // WordPress tries: WP_HTTP_Requests_Response, then streams, then fopen
    error_log('WP HTTP Transports: ' . print_r(WP_Http::_get_first_available_transport([]), true));
});

// ── Force cURL transport in WordPress ─────────────────────────────────────
add_filter('use_curl_transport', '__return_true');

// ── Increase WordPress HTTP timeout ───────────────────────────────────────
add_filter('http_request_timeout', fn() => 60);

// ── Disable SSL verification (TEMP DEBUG ONLY — never in production) ──────
add_filter('https_ssl_verify', '__return_false');  // Only for debugging!
add_filter('https_local_ssl_verify', '__return_false');

Common WordPress-specific cause: Loopback requests fail

<?php
/**
 * WordPress makes "loopback" requests to itself for some features.
 * These can fail if your server can't connect to its own hostname.
 */

// Test the loopback:
$response = wp_remote_get(home_url('/wp-json/'));
if (is_wp_error($response)) {
    error_log('Loopback failed: ' . $response->get_error_message());
    // Fix: Add your own domain to /etc/hosts
    // Or set DISABLE_WP_CRON = true and use real cron
}

// In Site Health (WP 5.1+), check:
// Tools → Site Health → Tests → "Can perform loopback requests"

 

Part 5 — SELinux and AppArmor Blocking cURL

This is the most overlooked cause of cURL failure on Linux. Security-hardened servers running SELinux (common on CentOS/RHEL) or AppArmor (Ubuntu) can block outbound connections entirely.

# ── Check if SELinux is blocking cURL ────────────────────────────────────────
sestatus                          # Is SELinux enabled?
getenforce                        # Enforcing / Permissive / Disabled

# Check SELinux audit logs for cURL blocks
sudo ausearch -m avc -ts recent | grep curl
sudo grep denied /var/log/audit/audit.log | grep curl

# ── Fix: Allow httpd (Apache/PHP-FPM) to make network connections ─────────
sudo setsebool -P httpd_can_network_connect 1
sudo setsebool -P httpd_can_network_connect_db 1  # For DB connections
sudo setsebool -P httpd_can_sendmail 1             # For SMTP via cURL

# Verify the booleans are set:
getsebool httpd_can_network_connect

# ── Check if AppArmor is blocking cURL ────────────────────────────────────
sudo aa-status                    # List active profiles
sudo journalctl -xe | grep apparmor | tail -20
sudo cat /var/log/syslog | grep apparmor | grep DENIED

# ── Fix AppArmor: Add PHP-FPM to allowed connections ─────────────────────
# Edit profile: /etc/apparmor.d/usr.sbin.php-fpm8.2
sudo nano /etc/apparmor.d/usr.sbin.php-fpm8.2

# Add inside the profile:
# network tcp,
# network udp,

# Reload AppArmor profile:
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.php-fpm8.2

 

Part 6 — PHP Configuration That Breaks cURL

Sometimes cURL fails not because of the network but because of PHP settings:

<?php
/**
 * PHP Configuration Checker for cURL
 * Run this as a standalone PHP script on your server
 */
function check_php_curl_config(): void {
    echo "=== PHP cURL Configuration Check ===\n\n";

    // Check if cURL extension is loaded
    echo "cURL loaded: "    . (extension_loaded('curl') ? 'YES' : 'NO — run: apt-get install php-curl') . "\n";
    echo "PHP version: "    . PHP_VERSION . "\n\n";

    if (!extension_loaded('curl')) return;

    $cv = curl_version();
    echo "cURL version:     " . $cv['version']       . "\n";
    echo "SSL version:      " . $cv['ssl_version']    . "\n";
    echo "libz version:     " . $cv['libz_version']   . "\n";
    echo "Protocols:        " . implode(', ', $cv['protocols']) . "\n";
    echo "CA bundle path:   " . ($cv['cainfo'] ?? 'NOT SET') . "\n";
    echo "CA bundle exists: " . (isset($cv['cainfo']) && file_exists($cv['cainfo']) ? 'YES' : 'NO') . "\n\n";

    // Check php.ini settings
    echo "=== PHP.ini Settings ===\n";
    $settings = [
        'curl.cainfo'         => 'Path to CA bundle. Empty = use system default.',
        'openssl.cafile'      => 'Alternative CA bundle for OpenSSL stream wrapper.',
        'allow_url_fopen'     => 'Needed for file_get_contents() HTTP. Not needed for cURL.',
        'disable_functions'   => 'Check if curl_init/exec/close are listed here.',
        'max_execution_time'  => 'PHP max runtime — must be > cURL timeout.',
        'default_socket_timeout' => 'Default socket timeout for stream wrappers.',
    ];

    foreach ($settings as $key => $desc) {
        $val = ini_get($key);
        echo "{$key}: " . ($val ?: '(empty)') . "\n";
        echo "  → {$desc}\n";
    }

    echo "\n=== Disabled Functions Check ===\n";
    $disabled = array_map('trim', explode(',', ini_get('disable_functions')));
    $curl_functions = ['curl_init', 'curl_exec', 'curl_setopt', 'curl_close', 'curl_error', 'curl_errno', 'curl_getinfo'];
    foreach ($curl_functions as $func) {
        $blocked = in_array($func, $disabled, true);
        echo "{$func}: " . ($blocked ? 'BLOCKED — in disable_functions' : 'OK') . "\n";
    }
}

check_php_curl_config();

Common PHP.ini fixes:

; In php.ini or /etc/php/8.2/fpm/conf.d/20-curl.ini

; Point to CA bundle explicitly
curl.cainfo = /etc/ssl/certs/ca-certificates.crt
openssl.cafile = /etc/ssl/certs/ca-certificates.crt

; Increase execution time if cURL times out at PHP level
max_execution_time = 120

; Remove curl_exec from disabled_functions (shared hosting issue)
; disable_functions = (remove curl_* entries)

 

Part 7 — Building a Production-Grade cURL Wrapper

Once you understand every failure mode, build a cURL wrapper that handles them all — with retries, proper error handling, timeouts, and logging.

<?php
/**
 * Production-Grade PHP cURL HTTP Client
 *
 * Features:
 * - Automatic retry with exponential backoff
 * - Comprehensive error handling for all error categories
 * - Proper SSL configuration
 * - Request/response logging
 * - Connection pooling support
 * - JSON auto-decode
 * - Rate limit awareness (429 handling)
 */
class HttpClient {

    private array  $default_options;
    private ?string $ca_bundle;
    private bool   $debug;
    private array  $request_log = [];

    public function __construct(array $config = []) {
        $this->debug     = $config['debug']    ?? false;
        $this->ca_bundle = $config['ca_bundle'] ?? $this->detect_ca_bundle();

        $this->default_options = [
            CURLOPT_RETURNTRANSFER  => true,
            CURLOPT_HEADER          => true,
            CURLOPT_FOLLOWLOCATION  => true,
            CURLOPT_MAXREDIRS       => 5,
            CURLOPT_SSL_VERIFYPEER  => true,
            CURLOPT_SSL_VERIFYHOST  => 2,
            CURLOPT_CONNECTTIMEOUT  => $config['connect_timeout'] ?? 10,
            CURLOPT_TIMEOUT         => $config['timeout']         ?? 30,
            CURLOPT_ENCODING        => '',
            CURLOPT_USERAGENT       => $config['user_agent'] ?? 'PHP-HttpClient/1.0',
        ];

        if ($this->ca_bundle) {
            $this->default_options[CURLOPT_CAINFO] = $this->ca_bundle;
        }
    }

    /**
     * Make a GET request.
     */
    public function get(string $url, array $headers = [], array $options = []): HttpResponse {
        return $this->request('GET', $url, null, $headers, $options);
    }

    /**
     * Make a POST request.
     */
    public function post(string $url, $body = null, array $headers = [], array $options = []): HttpResponse {
        return $this->request('POST', $url, $body, $headers, $options);
    }

    /**
     * Make a PUT request.
     */
    public function put(string $url, $body = null, array $headers = []): HttpResponse {
        return $this->request('PUT', $url, $body, $headers);
    }

    /**
     * Make a DELETE request.
     */
    public function delete(string $url, array $headers = []): HttpResponse {
        return $this->request('DELETE', $url, null, $headers);
    }

    /**
     * Core request method with retry logic.
     *
     * @param string $method    HTTP method
     * @param string $url       Target URL
     * @param mixed  $body      Request body (string, array, or null)
     * @param array  $headers   HTTP headers ['Key: Value', ...]
     * @param array  $options   Additional cURL options
     * @param int    $attempt   Current attempt number (internal, for retry)
     */
    public function request(
        string $method,
        string $url,
        $body       = null,
        array  $headers = [],
        array  $options = [],
        int    $attempt = 1
    ): HttpResponse {

        $max_retries = $options['max_retries'] ?? 3;
        $ch          = curl_init();

        // Merge options: defaults < instance < per-request
        $curl_opts = $this->default_options;

        // Method
        $curl_opts[CURLOPT_URL]           = $url;
        $curl_opts[CURLOPT_CUSTOMREQUEST] = $method;

        // Body handling
        if ($body !== null) {
            if (is_array($body)) {
                // JSON encode arrays automatically
                $body                          = json_encode($body);
                $headers[]                     = 'Content-Type: application/json';
                $curl_opts[CURLOPT_POSTFIELDS] = $body;
            } else {
                $curl_opts[CURLOPT_POSTFIELDS] = $body;
            }
        } elseif ($method === 'POST') {
            $curl_opts[CURLOPT_POSTFIELDS] = '';
        }

        // Headers
        if (!empty($headers)) {
            $curl_opts[CURLOPT_HTTPHEADER] = $headers;
        }

        // Apply per-request cURL options (allows overriding anything)
        foreach ($options as $key => $value) {
            if (is_int($key)) {
                $curl_opts[$key] = $value;
            }
        }

        curl_setopt_array($ch, $curl_opts);

        $start    = microtime(true);
        $response = curl_exec($ch);
        $elapsed  = round((microtime(true) - $start) * 1000);

        $errno    = curl_errno($ch);
        $error    = curl_error($ch);
        $info     = curl_getinfo($ch);
        curl_close($ch);

        $this->log("{$method} {$url} [{$elapsed}ms]" . ($errno ? " ERROR #{$errno}: {$error}" : " HTTP {$info['http_code']}"));

        // ── cURL-level failure ────────────────────────────────────────────
        if ($errno !== 0) {
            return $this->handle_curl_error($errno, $error, $method, $url, $body, $headers, $options, $attempt, $max_retries);
        }

        // ── Parse response ─────────────────────────────────────────────────
        $header_size     = $info['header_size'];
        $response_body   = substr($response, $header_size);
        $response_headers = $this->parse_headers(substr($response, 0, $header_size));
        $http_code        = $info['http_code'];

        // ── HTTP-level failures with retry logic ──────────────────────────
        if ($http_code === 429 && $attempt <= $max_retries) {
            // Rate limited — honour Retry-After header
            $retry_after = (int)($response_headers['retry-after'] ?? 60);
            $this->log("Rate limited (429). Waiting {$retry_after}s before retry #{$attempt}.");
            sleep($retry_after);
            return $this->request($method, $url, $body, $headers, $options, $attempt + 1);
        }

        if (in_array($http_code, [500, 502, 503, 504], true) && $attempt <= $max_retries) {
            // Server error — exponential backoff
            $delay = min(pow(2, $attempt - 1), 30); // 1s, 2s, 4s, max 30s
            $this->log("Server error ({$http_code}). Retry #{$attempt} in {$delay}s.");
            sleep($delay);
            return $this->request($method, $url, $body, $headers, $options, $attempt + 1);
        }

        return new HttpResponse($http_code, $response_headers, $response_body, $info);
    }

    /**
     * Handle cURL errors with intelligent retry logic.
     */
    private function handle_curl_error(
        int $errno, string $error,
        string $method, string $url, $body, array $headers, array $options,
        int $attempt, int $max_retries
    ): HttpResponse {

        // Retryable errors (transient network issues)
        $retryable = [
            CURLE_OPERATION_TIMEDOUT,  // 28 — timeout
            CURLE_SEND_ERROR,           // 55 — send error
            CURLE_RECV_ERROR,           // 56 — receive error
            CURLE_COULDNT_CONNECT,      // 7 — connection refused (may be transient)
        ];

        if (in_array($errno, $retryable, true) && $attempt <= $max_retries) {
            $delay = min(pow(2, $attempt - 1), 30);
            $this->log("cURL error #{$errno} (retryable). Retry #{$attempt} in {$delay}s.");
            sleep($delay);
            return $this->request($method, $url, $body, $headers, $options, $attempt + 1);
        }

        // Non-retryable — return error response immediately
        return HttpResponse::from_curl_error($errno, $error);
    }

    /**
     * Detect the best available CA bundle path for current environment.
     */
    private function detect_ca_bundle(): ?string {
        // First: use what cURL was compiled with
        $curl_v = curl_version();
        if (!empty($curl_v['cainfo']) && file_exists($curl_v['cainfo'])) {
            return $curl_v['cainfo'];
        }

        // Check common locations
        $paths = [
            '/etc/ssl/certs/ca-certificates.crt',    // Ubuntu/Debian
            '/etc/ssl/certs/ca-bundle.crt',           // CentOS/RHEL
            '/etc/pki/tls/certs/ca-bundle.crt',       // Amazon Linux
            '/etc/ssl/cert.pem',                       // Alpine/macOS
            '/usr/local/etc/openssl/cert.pem',         // macOS Homebrew
        ];

        foreach ($paths as $path) {
            if (file_exists($path) && is_readable($path)) {
                return $path;
            }
        }

        return null;
    }

    private function parse_headers(string $raw): array {
        $headers = [];
        foreach (explode("\r\n", $raw) as $line) {
            if (str_contains($line, ':')) {
                [$key, $value] = explode(':', $line, 2);
                $headers[strtolower(trim($key))] = trim($value);
            }
        }
        return $headers;
    }

    private function log(string $message): void {
        $entry = '[' . date('Y-m-d H:i:s') . '] ' . $message;
        $this->request_log[] = $entry;
        if ($this->debug) error_log('[HttpClient] ' . $message);
    }

    public function get_log(): array { return $this->request_log; }
}

/**
 * HTTP Response value object.
 */
class HttpResponse {

    public readonly int    $status;
    public readonly array  $headers;
    public readonly string $body;
    public readonly array  $info;
    public readonly bool   $ok;
    public readonly ?int   $curl_errno;
    public readonly ?string $curl_error;

    public function __construct(int $status, array $headers, string $body, array $info = []) {
        $this->status     = $status;
        $this->headers    = $headers;
        $this->body       = $body;
        $this->info       = $info;
        $this->ok         = $status >= 200 && $status < 300;
        $this->curl_errno = null;
        $this->curl_error = null;
    }

    public static function from_curl_error(int $errno, string $error): self {
        $instance             = new self(0, [], '');
        // Using reflection or a named constructor to set readonly props
        // In PHP 8.1+ with readonly, use a subclass or constructor params
        return new class($errno, $error) extends HttpResponse {
            public function __construct(
                public readonly int    $curl_errno,
                public readonly string $curl_error
            ) {
                parent::__construct(0, [], '');
            }
            public bool $ok = false;
        };
    }

    public function json(): mixed {
        return json_decode($this->body, true);
    }

    public function header(string $name): ?string {
        return $this->headers[strtolower($name)] ?? null;
    }

    public function is_json(): bool {
        $ct = $this->header('content-type') ?? '';
        return str_contains($ct, 'application/json');
    }
}

 

Usage:

<?php
$client = new HttpClient([
    'timeout'         => 30,
    'connect_timeout' => 10,
    'debug'           => false,
]);

// JSON POST
$response = $client->post(
    'https://api.example.com/users',
    ['name' => 'John', 'email' => 'john@example.com'],
    ['Authorization: Bearer ' . $api_key]
);

if ($response->ok) {
    $user = $response->json();
    echo "Created user: " . $user['id'];
} elseif ($response->curl_errno) {
    echo "Network error #{$response->curl_errno}: {$response->curl_error}";
} else {
    echo "HTTP {$response->status}: " . $response->body;
}

 

Part 8 — cURL Multi-Handle for Parallel Requests

When cURL requests stop working at scale, the culprit is often sequential requests — 50 API calls one after another taking 30 seconds total. Use curl_multi_* for parallel execution:

<?php
/**
 * Parallel cURL requests using curl_multi_exec().
 * Sends multiple requests simultaneously — massively reduces total time.
 *
 * Example: 10 API calls × 500ms each = 5 seconds sequential
 *                                      = ~500ms parallel
 */
function curl_multi_request(array $requests, array $default_opts = []): array {

    $multi    = curl_multi_init();
    $channels = [];
    $results  = [];

    // ── Initialize all handles ─────────────────────────────────────────────
    foreach ($requests as $id => $req) {
        $ch = curl_init();

        $opts = array_merge([
            CURLOPT_URL            => $req['url'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_TIMEOUT        => $req['timeout'] ?? 30,
            CURLOPT_CONNECTTIMEOUT => 10,
        ], $default_opts, $req['curl_opts'] ?? []);

        if (isset($req['method']) && $req['method'] !== 'GET') {
            $opts[CURLOPT_CUSTOMREQUEST] = $req['method'];
        }

        if (isset($req['body'])) {
            $opts[CURLOPT_POSTFIELDS] = is_array($req['body'])
                ? json_encode($req['body'])
                : $req['body'];
        }

        if (isset($req['headers'])) {
            $opts[CURLOPT_HTTPHEADER] = $req['headers'];
        }

        curl_setopt_array($ch, $opts);
        curl_multi_add_handle($multi, $ch);
        $channels[$id] = $ch;
    }

    // ── Execute all requests in parallel ──────────────────────────────────
    $running = null;
    do {
        $status = curl_multi_exec($multi, $running);
        if ($running) {
            // Wait for activity (non-blocking select)
            curl_multi_select($multi, 0.01);
        }
    } while ($running > 0 && $status === CURLM_OK);

    // ── Collect results ────────────────────────────────────────────────────
    foreach ($channels as $id => $ch) {
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        $info  = curl_getinfo($ch);

        $results[$id] = [
            'success'      => $errno === 0,
            'body'         => curl_multi_getcontent($ch),
            'http_code'    => $info['http_code'],
            'errno'        => $errno,
            'error'        => $error,
            'total_ms'     => round($info['total_time'] * 1000),
            'info'         => $info,
        ];

        curl_multi_remove_handle($multi, $ch);
        curl_close($ch);
    }

    curl_multi_close($multi);
    return $results;
}

// ── Usage ──────────────────────────────────────────────────────────────────
$requests = [
    'user_profile' => [
        'url'     => 'https://api.example.com/users/123',
        'headers' => ['Authorization: Bearer ' . $token],
    ],
    'user_orders' => [
        'url'     => 'https://api.example.com/orders?user=123',
        'headers' => ['Authorization: Bearer ' . $token],
    ],
    'inventory' => [
        'url'     => 'https://inventory.internal.com/api/stock',
        'timeout' => 5,  // Shorter timeout for internal service
    ],
];

$results = curl_multi_request($requests);

foreach ($results as $id => $result) {
    if ($result['success'] && $result['http_code'] === 200) {
        $data  = json_decode($result['body'], true);
        echo "{$id}: OK ({$result['total_ms']}ms)\n";
    } else {
        echo "{$id}: FAILED — " . ($result['error'] ?: "HTTP {$result['http_code']}") . "\n";
    }
}

 

Part 9 — Complete Diagnostic Checklist

When cURL stops working, run through this checklist in order. Each level rules out an entire category of failure:

Level 1: Can the server make ANY outbound connection?

# Test the most basic possible connection
curl -v http://httpbin.org/get
curl -v https://httpbin.org/get
ping google.com
  • ✅ Both work → Skip to Level 3
  • ❌ HTTP works, HTTPS fails → SSL issue → Level 4
  • ❌ Both fail → Network/firewall issue → Level 2

Level 2: Network and Firewall Diagnosis

# Check iptables
sudo iptables -L OUTPUT -n -v | grep -E "(DROP|REJECT)"
sudo ufw status verbose

# Check SELinux
getenforce
sudo ausearch -m avc -ts recent

# Check if specific port is reachable
nc -zv api.example.com 443
telnet api.example.com 443

# Trace the network path
traceroute api.example.com
mtr --report api.example.com

Level 3: DNS Resolution

# Can the server resolve the target hostname?
nslookup api.example.com
dig api.example.com +short
host api.example.com

# What's the DNS server?
cat /etc/resolv.conf

Level 4: SSL/TLS Investigation

# Full TLS handshake trace
openssl s_client -connect api.example.com:443 -servername api.example.com

# Check certificate chain
openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null | \
    openssl x509 -noout -subject -issuer -dates

# Test specific TLS versions
openssl s_client -connect api.example.com:443 -tls1_2
openssl s_client -connect api.example.com:443 -tls1_3

# Verify CA bundle
cat /etc/ssl/certs/ca-certificates.crt | openssl x509 -noout -subject 2>/dev/null | head -5

Level 5: PHP and cURL Configuration

# Check PHP cURL version and SSL support
php -r "print_r(curl_version());"

# Check if cURL extension is loaded
php -m | grep curl

# Test cURL directly from PHP CLI
php -r "
\$ch = curl_init('https://api.example.com');
curl_setopt(\$ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt(\$ch, CURLOPT_VERBOSE, true);
\$r = curl_exec(\$ch);
echo 'Error ' . curl_errno(\$ch) . ': ' . curl_error(\$ch) . PHP_EOL;
echo 'HTTP: ' . curl_getinfo(\$ch, CURLINFO_HTTP_CODE) . PHP_EOL;
curl_close(\$ch);
"

Level 6: Application-Level Debugging

<?php
// Enable full verbose output in your PHP script
$ch = curl_init($url);
$verbose = fopen('php://stderr', 'w+');
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_STDERR, $verbose);
// ... rest of your options
curl_exec($ch);
// Read verbose output
rewind($verbose);
echo stream_get_contents($verbose);

 

Part 10 — Complete Error Code Quick-Reference Table

Code Constant One-Line Cause Emergency Fix
1 CURLE_UNSUPPORTED_PROTOCOL Protocol not supported (e.g., ftp://) Check URL scheme
2 CURLE_FAILED_INIT cURL init failed Check if extension loaded
3 CURLE_URL_MALFORMAT Malformed URL Validate URL with filter_var()
5 CURLE_COULDNT_RESOLVE_PROXY Bad proxy hostname Remove/fix CURLOPT_PROXY
6 CURLE_COULDNT_RESOLVE_HOST DNS failure Check DNS, /etc/resolv.conf
7 CURLE_COULDNT_CONNECT Port blocked/closed Check firewall, Security Groups
9 CURLE_REMOTE_ACCESS_DENIED Access denied by server Check auth credentials
16 CURLE_HTTP2 HTTP/2 framing error Force HTTP/1.1 with CURLOPT_HTTP_VERSION
22 CURLE_HTTP_RETURNED_ERROR HTTP 4xx/5xx (failonerror) Check API credentials
23 CURLE_WRITE_ERROR Error writing to file/pipe Check CURLOPT_FILE target
26 CURLE_READ_ERROR Error reading upload file Check file path + permissions
28 CURLE_OPERATION_TIMEDOUT Request timeout Increase CURLOPT_TIMEOUT
33 CURLE_RANGE_ERROR Range request not supported Remove CURLOPT_RANGE
35 CURLE_SSL_CONNECT_ERROR TLS handshake failed Update OpenSSL, check TLS version
42 CURLE_ABORTED_BY_CALLBACK CURLOPT_PROGRESSFUNCTION returned non-zero Fix progress callback
47 CURLE_TOO_MANY_REDIRECTS Too many redirects Increase CURLOPT_MAXREDIRS or fix loop
51 CURLE_PEER_FAILED_VERIFICATION Cert hostname mismatch Fix SSL cert CN/SAN
52 CURLE_GOT_NOTHING Empty response from server Server closed connection — check server logs
55 CURLE_SEND_ERROR Data send failure Retry — usually transient
56 CURLE_RECV_ERROR Data receive failure Retry — server reset connection
58 CURLE_SSL_CERTPROBLEM Local client cert problem Fix CURLOPT_SSLCERT/CURLOPT_SSLKEY
59 CURLE_SSL_CIPHER No cipher match Update OpenSSL or set cipher list
60 CURLE_SSL_CACERT CA cert verification failed Update CA bundle, set CURLOPT_CAINFO
61 CURLE_BAD_CONTENT_ENCODING Unrecognised Content-Encoding Set CURLOPT_ENCODING = ”
67 CURLE_LOGIN_DENIED FTP/SSH login denied Check credentials
77 CURLE_SSL_CACERT_BADFILE CA bundle file unreadable Fix path/permissions for CURLOPT_CAINFO
83 CURLE_SSL_ISSUER_ERROR Pinned issuer mismatch Update CURLOPT_PINNEDPUBLICKEY

 

From Guesswork to Systematic Diagnosis

The original approach — “update your CA certificates” — solves one specific scenario. What this guide gives you is a system for solving any cURL failure, not just the one you’re looking at right now.

The key insight: cURL errors are highly specific and highly informative. Every failure tells you exactly where in the connection pipeline it broke — DNS, TCP, TLS, HTTP, or data transfer. The combination of curl_errno() + curl_error() + curl_getinfo() + CURLOPT_VERBOSE gives you more diagnostic information than almost any other protocol-level debugging tool.

The diagnostic framework from this guide — check the error code, identify the category (DNS/Connection/SSL/HTTP/Transfer), run the appropriate shell diagnostic, apply the targeted fix — will resolve every cURL failure you encounter, regardless of:

  • Which PHP version you’re running
  • Which Linux distribution or cloud provider you’re on
  • Whether you’re in Docker, bare metal, shared hosting, or serverless
  • Whether it’s SSL, DNS, firewall, timeout, or configuration
  • Whether it happened after an OS update, SSL renewal, PHP upgrade, or “nothing changed”

With the production-grade HttpClient wrapper from Part 7, you also get automatic retry logic, rate limit handling, exponential backoff, and proper error categorisation baked into every HTTP call your application makes — so when cURL issues do occur in production, your application degrades gracefully instead of silently failing.

Frequently Asked Questions

+

Why did my PHP cURL request suddenly stop working?

There are several possible reasons, including server configuration changes, SSL certificate issues, firewall restrictions, DNS problems, API authentication failures, or the remote server being unavailable. Reviewing cURL error messages is the best place to start.
+

How can I debug a failed cURL request in PHP?

Enable verbose output and inspect the cURL error using curl_error() and curl_errno(). These functions help identify SSL, connection, timeout, or authentication issues.
+

How do I check the cURL error message in PHP?

Use the following functions after executing your cURL request: curl_error($ch) – Returns the error message. curl_errno($ch) – Returns the error code. These functions provide valuable information for troubleshooting.
+

Why am I getting an SSL certificate verification error in cURL?

This usually happens when the server's SSL certificate is invalid, expired, or the CA certificate bundle is missing. Updating your CA bundle or server configuration typically resolves the issue.
+

How can I test whether the API endpoint is reachable?

You can use tools like Postman, your browser (for GET requests), or command-line utilities such as curl to verify that the API endpoint is responding correctly.
+

Can firewall settings block PHP cURL request

Yes. Hosting firewalls, cloud security groups, or outbound firewall rules can block requests to external APIs. Verify that outbound HTTPS (port 443) and HTTP (port 80) traffic is allowed.
+

Why does my cURL request work on localhost but not on the live server?

Differences in PHP versions, cURL extensions, SSL certificates, firewall rules, DNS resolution, or hosting restrictions can cause this behavior. Compare both environments to identify the difference.
+

How do I verify that the PHP cURL extension is installed?

Create a PHP file containing phpinfo(); and search for the cURL section. If it is missing, install or enable the PHP cURL extension and restart your web server.
+

What are common cURL timeout issues?

Timeouts can occur due to slow API responses, network latency, or blocked connections. Configure appropriate timeout values using CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT for reliable communication.
+

How can I improve the reliability of PHP cURL API requests?

Use proper error handling, set reasonable timeout values, validate SSL certificates, log request failures, implement retry mechanisms, and monitor API availability to build more reliable integrations.
Previous Article

Contact Form 7 Inquiries to Email + WhatsApp Automatically — The Complete Production Guide

Next Article

How to Build a Production-Grade SEO-Friendly FAQ System in WordPress — The Complete Developer's Guide

Write a Comment

Leave a Comment

Your email address will not be published. Required fields are marked *

Subscribe to our Newsletter

Subscribe to our email newsletter to get the latest posts delivered right to your email.
Pure inspiration, zero spam ✨