guzzle/guzzle · error · GuzzleHttp\Exception\InvalidArgumentException

progress client option must be callable

Error message

progress client option must be callable

What it means

Thrown when the 'progress' client/request option is set to a non-null value that is not callable (does not satisfy is_callable). Guzzle forwards progress to a cURL transfer callback; a non-callable value cannot be invoked and is rejected up front.

Source

Thrown at src/Handler/CurlFactory.php:2680

                }
                $sslKey = $options['ssl_key'][0];
            }

            $sslKey = $sslKey ?? $options['ssl_key'];

            if (!\is_string($sslKey)) {
                throw new InvalidArgumentException('Invalid ssl_key request option');
            }

            if (self::shouldValidateSslKeyFile($sslKeyType) && !\file_exists($sslKey)) {
                throw new InvalidArgumentException(\sprintf('SSL private key not found: %s', Psr7\DiagnosticValue::escape($sslKey)));
            }
            $conf[\CURLOPT_SSLKEY] = $sslKey;
        }

        $progress = $options['progress'] ?? null;
        if ($progress !== null && !\is_callable($progress)) {
            throw new InvalidArgumentException('progress client option must be callable');
        }

        // The streaming read callback (set by applyBody) aborts the upload on a
        // body read failure by returning CURL_READFUNC_ABORT, but PHP ignores
        // that integer return before 8.1.17/8.2.4. Install a progress callback
        // so older PHP still has a cross-version abort path; the failure is
        // classified from the stored request-body exception regardless of errno
        // (a truncated request may reach the server first on those versions).
        $abortsOnBodyReadFailure = isset($conf[\CURLOPT_READFUNCTION]);

        if ($progress !== null || $abortsOnBodyReadFailure) {
            /** @var (callable(int, int, int, int): mixed)|null $progress */
            $conf[\CURLOPT_NOPROGRESS] = false;
            $progressCallback = static function ($resource, $downloadSize, $downloaded, $uploadSize, $uploaded) use ($easy, $progress): int {
                // Abort the transfer when the request body read failed (the
                // cross-version abort path, since older PHP ignores the read
                // callback's return). progressAborted is left unset so the
                // failure is classified from the stored request-body exception.

View on GitHub (pinned to 9b200fc580)

Solutions

  1. Pass a valid callable: a closure, a function name string that exists, or a valid [$object, 'method'] array.
  2. Remove the 'progress' option if you no longer need it.
  3. If the callable target is an object method, verify the method exists and is public.

Example fix

// before
['progress' => [$logger, 'onProgress']]  // method renamed to 'progress'
// after
['progress' => [$logger, 'progress']]
Defensive patterns

Strategy: type-guard

Validate before calling

if (array_key_exists('progress', $options) && $options['progress'] !== null && !is_callable($options['progress'])) {
    throw new InvalidArgumentException('progress client option must be callable');
}

Type guard

function isProgressCallable($progress): bool {
    return $progress === null || is_callable($progress);
}

Prevention

When it happens

Trigger: ['progress' => 'someFunc'] where the function does not exist; ['progress' => [$obj, 'method']] on a non-existent or non-public method; ['progress' => 5]; passing a class name instead of an instance.

Common situations: Refactoring renaming a method without updating the progress option; passing a static method string without the class context; serialization/unserialization leaving the target object detached.

Related errors


AI-assisted analysis of guzzle/guzzle@9b200fc580 (2026-08-04). Data as JSON: /data/errors/28874754ddd1c7a5.json. Report an issue: GitHub.