ScreenshotNeo

BlogHow-to

Send Custom HTTP Headers in PHP with Guzzle

Learn the correct way to send, override, inspect, and centralize custom HTTP headers in PHP with Guzzle, with runnable examples and fixes.

By the ScreenshotNeo team29 September 20269 min read

Send Custom HTTP Headers in PHP with Guzzle

Use Guzzle’s headers request option to send custom HTTP headers. The option accepts an associative array whose keys are header names and whose values are strings or arrays of strings.

<?php
require 'vendor/autoload.php';

use GuzzleHttp\Client;

$client = new Client();

$response = $client->request('GET', 'https://api.example.com/items', [
    'headers' => [
        'Accept' => 'application/json',
        'X-Custom-Header' => 'value',
    ],
]);

echo $response->getBody();

This article explains where headers belong, how precedence works, how to update PSR-7 requests, and when middleware is the better choice. It also covers JSON bodies, repeated values, authentication, debugging, retries, security, and practical alternatives.

1. Install Guzzle and make a first request

Install Guzzle with Composer:

composer require guzzlehttp/guzzle

Create a client and pass request options as the third argument to request():

<?php
require 'vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

$client = new Client();

try {
    $response = $client->request('GET', 'https://api.example.com/items', [
        'headers' => [
            'Accept' => 'application/json',
            'X-Request-ID' => bin2hex(random_bytes(8)),
        ],
        'timeout' => 20,
    ]);

    echo $response->getStatusCode() . PHP_EOL;
    echo $response->getBody();
} catch (GuzzleException $e) {
    error_log($e->getMessage());
}

Request-level options keep a token, trace ID, or content negotiation preference limited to one call. Guzzle’s documentation describes request options as the way to customize requests created and transferred by a client: request options documentation.

2. Header values, names, and multiple values

Header names are array keys. Values can be strings or arrays of strings:

Headers travel with the request and can be applied at call, client, or middleware scope.
Headers travel with the request and can be applied at call, client, or middleware scope.
$response = $client->request('GET', 'https://api.example.com/items', [
    'headers' => [
        'Accept' => 'application/json',
        'Cache-Control' => 'no-cache',
        'X-Feature' => ['alpha', 'beta'],
    ],
]);

Use the exact field name and value format required by the remote API. An array representation allows multiple field values, but it does not mean every HTTP header can safely be comma-joined. Follow the API’s definition for that particular field.

Common custom headers

Purpose Example Guidance
Content negotiation Accept: application/json Ask for the response representation your code parses.
Bearer authentication Authorization: Bearer TOKEN Keep the token in an environment variable or secret store.
API key X-API-Key: KEY Use the exact name required by the provider.
Tracing X-Request-ID: abc123 Generate a value per operation when correlating logs.
Conditional requests If-None-Match: "etag" Only send a value obtained from a previous response.

3. Set default headers on a client

Use client defaults for stable headers shared by requests made through that client:

$client = new Client([
    'headers' => [
        'Accept' => 'application/json',
        'X-Client' => 'inventory-service',
    ],
]);

$response = $client->request('GET', 'https://api.example.com/items');

Defaults are applied only when the request does not already contain that specific header. A request-level value can replace a client default. If you build a PSR-7 request separately and it already carries the field, that existing value also prevents the default from being added.

To disable client defaults for one request, pass headers => null:

$response = $client->request('GET', 'https://api.example.com/public', [
    'headers' => null,
]);

Do not put credentials in a broadly reused client when requests may go to unrelated hosts. Create a client for the intended service or scope sensitive headers to the individual request.

4. Send authentication and JSON safely

Bearer tokens and API keys

$token = getenv('EXAMPLE_API_TOKEN');

$response = $client->request('GET', 'https://api.example.com/profile', [
    'headers' => [
        'Authorization' => 'Bearer ' . $token,
        'Accept' => 'application/json',
    ],
]);

Never hard-code a production credential in source control. Validate that the environment variable exists before making the request, and avoid logging the complete headers array.

JSON request bodies

Guzzle’s json option encodes a PHP value and sets JSON-related behavior:

$response = $client->request('POST', 'https://api.example.com/items', [
    'json' => [
        'name' => 'Notebook',
        'quantity' => 2,
    ],
    'headers' => [
        'Accept' => 'application/json',
    ],
]);

The json option does not provide a way to customize Content-Type through that option. If an API requires a vendor-specific media type or custom encoding, encode the body yourself and set the header explicitly:

$payload = json_encode(
    ['name' => 'Notebook'],
    JSON_THROW_ON_ERROR
);

$response = $client->request('POST', 'https://api.example.com/items', [
    'body' => $payload,
    'headers' => [
        'Content-Type' => 'application/vnd.example.item+json',
        'Accept' => 'application/json',
    ],
]);

5. Add headers to an existing PSR-7 request

Guzzle uses PSR-7 messages. Header updates are immutable: withHeader() returns a new request. Keep the returned object.

use GuzzleHttp\Psr7\Request;

$request = new Request('GET', 'https://api.example.com/items');
$request = $request->withHeader('Accept', 'application/json');
$request = $request->withAddedHeader('X-Trace', 'first');
$request = $request->withAddedHeader('X-Trace', 'second');

$response = $client->send($request);

Inspect headers with PSR-7 accessors:

if ($request->hasHeader('Accept')) {
    $firstAccept = $request->getHeaderLine('Accept');
    $allAcceptValues = $request->getHeader('Accept');
}

$allHeaders = $request->getHeaders();

See the Guzzle PSR-7 documentation for request construction and inspection.

6. Add a header to every request with middleware

Middleware is appropriate for a cross-cutting rule, such as attaching a correlation ID to every request handled by one client. Guzzle middleware receives a request and returns a modified request before it reaches the handler.

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Psr\Http\Message\RequestInterface;

$stack = HandlerStack::create();
$stack->push(function (callable $handler) {
    return function (RequestInterface $request, array $options) use ($handler) {
        $request = $request->withHeader(
            'X-Request-ID',
            bin2hex(random_bytes(8))
        );

        return $handler($request, $options);
    };
});

$client = new Client(['handler' => $stack]);
$response = $client->request('GET', 'https://api.example.com/items');

When supplying a custom handler, build it with HandlerStack::create() when you need Guzzle’s default middleware. A bare handler can omit middleware-dependent behavior. The official handlers and middleware guide shows the documented pattern.

7. Complete runnable example with error handling

<?php
require 'vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;

$token = getenv('EXAMPLE_API_TOKEN');
if (!$token) {
    throw new RuntimeException('EXAMPLE_API_TOKEN is not configured');
}

$client = new Client([
    'base_uri' => 'https://api.example.com/',
    'timeout' => 30,
    'connect_timeout' => 10,
    'http_errors' => false,
]);

try {
    $response = $client->request('GET', 'items', [
        'headers' => [
            'Authorization' => 'Bearer ' . $token,
            'Accept' => 'application/json',
            'X-Request-ID' => bin2hex(random_bytes(8)),
        ],
    ]);

    $status = $response->getStatusCode();
    $body = (string) $response->getBody();

    if ($status < 200 || $status >= 300) {
        error_log('API returned HTTP ' . $status);
    }

    $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    var_dump($data);
} catch (ConnectException $e) {
    error_log('Connection failed: ' . $e->getMessage());
} catch (RequestException $e) {
    error_log('Request failed: ' . $e->getMessage());
}

Setting http_errors to false lets your code inspect 4xx and 5xx responses directly. If you leave it enabled, Guzzle may throw a request exception for those statuses. Choose one style and handle it consistently.

8. Equivalent requests with cURL, Python, and Node.js

cURL

curl -H 'Accept: application/json' \
     -H 'X-Custom-Header: value' \
     https://api.example.com/items

Python requests

import requests

response = requests.get(
    "https://api.example.com/items",
    headers={
        "Accept": "application/json",
        "X-Custom-Header": "value",
    },
    timeout=20,
)
response.raise_for_status()
print(response.json())

Node.js fetch

const res = await fetch('https://api.example.com/items', {
  headers: {
    Accept: 'application/json',
    'X-Custom-Header': 'value'
  }
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(await res.json());

9. Or skip the browser setup

If your PHP service needs screenshots rather than a general API response, ScreenshotNeo accepts custom headers and other capture settings through one request. The API can capture a URL as PNG, JPEG, WebP, or PDF; its custom headers, cookies, user agent, and Authorization options are useful for authenticated pages.

A capture service can remove common overlays before producing the final image.
A capture service can remove common overlays before producing the final image.

See the ScreenshotNeo API documentation for all options. A direct call looks like this:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots each month without a card; paid plans start at $5 for 3,000 shots.

Sign up free for ScreenshotNeo.

10. Troubleshooting custom headers

Symptom Likely cause Fix
Server says a header is missing The option was placed outside the request options array, or the header name is wrong. Put headers in the third argument and copy the provider’s exact field name.
Default header does not appear The request already contains that field, or headers => null disabled defaults. Inspect the request and remove the override or set the desired value at request level.
withHeader() appears ineffective PSR-7 messages are immutable and the returned request was discarded. Assign the result back to $request.
401 or 403 response Token is missing, expired, scoped incorrectly, or formatted incorrectly. Check the required scheme, environment variable, host, and token permissions without logging secrets.
415 Unsupported Media Type The body encoding and Content-Type do not match the endpoint. Use json for standard JSON, or encode manually and set the required media type.
Repeated values are rejected The API does not support multiple values for that field. Read the API specification; use one value or its documented delimiter.
Timeout or connection error DNS, TLS, network access, or an overloaded upstream service. Set sensible connect_timeout and timeout values, then add bounded retries only for safe operations.
Custom middleware never runs A custom bare handler replaced the normal stack. Start with HandlerStack::create() and push middleware onto that stack.

11. Reliability, performance, and cost considerations

  • Reuse clients. A client with stable defaults avoids rebuilding configuration for every call and keeps connection handling centralized.
  • Keep timeouts explicit. Use a connection timeout and an overall request timeout so a worker cannot wait indefinitely.
  • Retry selectively. Retry transient connection failures and status codes your API documents as retryable. Do not blindly retry non-idempotent POST operations.
  • Keep headers small. Large cookies or tracing payloads increase request size and can trigger proxy limits.
  • Separate tenants and hosts. Do not share an authenticated default-header client across destinations unless you control the routing.
  • Measure at the edge. Log status, duration, and a redacted request ID. Never log Authorization values or full cookie contents.
  • Use caching deliberately. Conditional headers such as If-None-Match can reduce payload transfer when the server supports them.

For ScreenshotNeo captures, caching has a TTL you choose, bulk capture supports up to 100 URLs per call, and asynchronous jobs can notify your service with signed webhooks. Clean shots are the billable unit; failed loads and cache hits cost nothing.

12. Practical checklist

  • Put one-off fields in the request’s headers option.
  • Put stable fields in a dedicated client’s defaults.
  • Use middleware for a rule that applies to every request.
  • Reassign the return value of PSR-7 withHeader().
  • Keep credentials outside source code and logs.
  • Use the endpoint’s exact content type and authentication format.
  • Set connection and total timeouts.
  • Inspect status and response headers separately from request headers.

13. FAQ

Can I send more than one value for a Guzzle header?

Yes. Supply an array of strings. Whether the remote server accepts multiple values depends on that header’s specification.

Should I use client defaults or request-level headers?

Use defaults for stable headers shared by one client. Use request-level headers for tokens, trace IDs, or values that vary per call.

How do I inspect the final outgoing headers?

Inspect a PSR-7 request with getHeaders(), getHeader(), or getHeaderLine(). Avoid printing secrets in production logs.

Does the Guzzle json option let me choose any Content-Type?

No. Encode the body yourself and set Content-Type in headers when a custom media type is required.

Can ScreenshotNeo use authenticated pages?

Yes. Its capture options include custom headers, cookies, user agent, and Authorization, alongside controls for waiting, blocking resources, and selecting an element.