The Situation
You make an HTTPS request to an external API โ Stripe, PayPal, some webhook endpoint โ and PHP throws this:
cURL error 60: SSL certificate problem: unable to get local issuer certificate
Postman works. The browser works. PHP refuses. Every time. This almost always happens on Windows dev machines (XAMPP, Laragon, WAMP) or freshly provisioned Linux servers where the CA bundle is missing or points nowhere useful.
Why It Happens
cURL verifies a server's SSL certificate against a list of trusted Certificate Authorities (CAs). On Linux, that bundle lives at /etc/ssl/certs/ca-certificates.crt (Debian/Ubuntu) or /etc/pki/tls/certs/ca-bundle.crt (CentOS/RHEL). Two things go wrong: either PHP's bundled cURL can't find the file at all, or the system CA bundle is so outdated it doesn't include the issuer for the cert you're hitting.
Windows is a different animal. cURL doesn't read the Windows certificate store by default โ it needs a separate cacert.pem pointed to explicitly in php.ini. Out of the box, XAMPP ships with no cainfo setting configured, so every HTTPS call to an external API fails with error 60.
Quick Fix (Dev Only โ Don't Ship This)
Need to unblock yourself right now? Disable SSL verification. Never do this in production.
$ch = curl_init('https://api.example.com/endpoint');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // dev only
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // dev only
$response = curl_exec($ch);
curl_close($ch);
With Guzzle:
$client = new \GuzzleHttp\Client(['verify' => false]); // dev only
$response = $client->get('https://api.example.com/endpoint');
Got a response? Good. That confirms the SSL cert check is the culprit. Now fix it properly.
Permanent Fix โ Point cURL at a Valid CA Bundle
Step 1: Download the Mozilla CA Bundle
The curl project maintains an official cacert.pem sourced directly from Mozilla. It's updated several times a year and covers all major CAs:
# Linux
wget -O /etc/ssl/certs/cacert.pem https://curl.se/ca/cacert.pem
# Or using curl itself
curl -o /etc/ssl/certs/cacert.pem https://curl.se/ca/cacert.pem
On Windows, download it manually and drop it somewhere stable โ C:\php\extras\cacert.pem works well. Avoid paths with spaces.
Step 2: Tell PHP to Use It
Edit php.ini (find the right file with php --ini or check phpinfo() in a browser):
[curl]
curl.cainfo = /etc/ssl/certs/cacert.pem
[openssl]
openssl.cafile = /etc/ssl/certs/cacert.pem
On Windows with XAMPP, use Windows-style paths with quotes:
[curl]
curl.cainfo = "C:\php\extras\cacert.pem"
[openssl]
openssl.cafile = "C:\php\extras\cacert.pem"
Restart your server after saving:
# Linux systemd
sudo systemctl restart php8.2-fpm
sudo systemctl restart apache2
# XAMPP on Windows โ restart via the control panel
Step 3: Set It Per-Request (Shared Hosting Fallback)
No access to php.ini? Pass the CA bundle path directly in the cURL call:
$ch = curl_init('https://api.example.com/endpoint');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/cacert.pem');
$response = curl_exec($ch);
if ($response === false) {
echo curl_error($ch);
}
curl_close($ch);
Same idea with Guzzle:
$client = new \GuzzleHttp\Client([
'verify' => '/etc/ssl/certs/cacert.pem'
]);
$response = $client->get('https://api.example.com/endpoint');
Linux: Update the System CA Store Instead
Debian and Ubuntu make this even simpler โ just reinstall the CA certificates package and regenerate the bundle:
sudo apt-get update
sudo apt-get install --reinstall ca-certificates
sudo update-ca-certificates
CentOS/RHEL equivalent:
sudo yum reinstall ca-certificates
sudo update-ca-trust
After this, PHP's cURL (if compiled to look at the system bundle) picks up the changes automatically. No php.ini edits needed.
Verify the Fix Worked
Test from the CLI before touching your app code:
# Test with curl directly
curl -v https://api.example.com/endpoint
# Test PHP's cURL specifically
php -r "
\$ch = curl_init('https://api.example.com/endpoint');
curl_setopt(\$ch, CURLOPT_RETURNTRANSFER, true);
\$out = curl_exec(\$ch);
\$err = curl_error(\$ch);
curl_close(\$ch);
echo \$err ? 'ERROR: ' . \$err : 'OK โ got ' . strlen(\$out) . ' bytes';
"
You want to see OK โ got N bytes. Still failing? Run php --ini and confirm it's loading the right php.ini. Then open phpinfo() in a browser and search for curl.cainfo โ the path shown there is what PHP is actually using, not necessarily what you edited.
Self-Signed or Internal CA Certificates
Calling an internal API behind a self-signed cert? Don't disable verification โ that trades one problem for a worse one. Append your internal CA cert to the bundle instead:
# Append your internal CA cert to the cacert.pem bundle
cat /path/to/your-internal-ca.crt >> /etc/ssl/certs/cacert.pem
Or point cURL at it directly per request:
curl_setopt($ch, CURLOPT_CAINFO, '/path/to/your-internal-ca.crt');
Quick Reference
- Windows dev machine (XAMPP/Laragon): Download
cacert.pem, setcurl.cainfoinphp.ini - Linux prod server: Run
update-ca-certificatesor setcurl.cainfoinphp.ini - Self-signed cert: Append your CA to the bundle โ never disable verification
- No access to
php.ini: UseCURLOPT_CAINFOper request, or'verify' => '/path/to/cacert.pem'in Guzzle - Never in production:
CURLOPT_SSL_VERIFYPEER = false

