Last reviewed: November 7, 2026 (Asia/Shanghai); sources fetched November 6 UTC.
Direct answer
A PHP service can call CometAPI with the cURL extension and no provider SDK. Keep the endpoint and model in trusted server configuration, encode one narrow request with json_encode, send it with curl_setopt_array, enforce connection and total time limits, and cap the response before decoding it. Check HTTP status and media type before accepting JSON, then validate the completion envelope, assistant role, finish reason, and text field as an application contract.
The CometAPI chat reference
documents /v1/chat/completions, bearer authentication, request fields, and completion responses. PHP’s cURL constants reference
documents CURLOPT_WRITEFUNCTION, connection timeout, and transfer timeout options. PHP’s curl_exec reference
explains that a write callback receives the body and makes successful curl_exec return true. PHP’s curl_close reference
documents its PHP 8 no-op behavior and PHP 8.5 deprecation. PHP’s json_decode reference
documents decoding errors and flags. These sources support a transparent, dependency-light adapter.
Who this is for
Use this pattern in a PHP-FPM application, queue worker, CLI utility, or integration test that needs a small auditable HTTP boundary. The key belongs in an environment variable or secret manager. A browser should send a prompt to your application, never an API key, endpoint, or arbitrary model ID. The limits below are local safety choices, not account quotas or token counts.
Key takeaways
- Keep the cURL connection policy inside one small client boundary, and keep credentials out of logs and serialized objects.
- Set
CURLOPT_CONNECTTIMEOUTandCURLOPT_TIMEOUT; a response can stall after headers arrive. - Use
CURLOPT_WRITEFUNCTIONto count bytes before appending them, and reject an oversized body beforejson_decode. - Check status and
Content-Typebefore parsing so an HTML error page is never mistaken for a completion. - Use
JSON_THROW_ON_ERRORand validate fields your product consumes. - Keep retries outside this adapter; a timeout can leave the remote outcome uncertain.
- Add a separate incremental reader for server-sent events; this class sends
stream: false.
For another language’s standard-library boundary, see Build a CometAPI Go Client with net/http . For a managed transport, see Build a CometAPI Java Client with HttpClient .
Implement the client
Use PHP 8.1 or later with the cURL extension enabled, then create CometClient.php:
<?php
declare(strict_types=1);
final class CometApiException extends RuntimeException {
public function __construct(string $message, public readonly ?int $httpStatus=null, ?Throwable $previous=null) { parent::__construct($message, 0, $previous); }
}
final class CometClient {
private const ENDPOINT='https://api.cometapi.com/v1/chat/completions';
private const MAX_RESPONSE_BYTES=1048576;
public function __construct(private readonly string $apiKey, private readonly string $model) {
if ($this->apiKey==='' || $this->model==='' || strpbrk($this->apiKey, "\r\n")!==false) throw new CometApiException('invalid_configuration');
}
public function complete(string $prompt): string {
if (trim($prompt)==='' || strlen($prompt)>8000) throw new CometApiException('invalid_prompt');
try { $payload=json_encode(['model'=>$this->model,'messages'=>[['role'=>'user','content'=>$prompt]],'stream'=>false], JSON_THROW_ON_ERROR); }
catch (JsonException $e) { throw new CometApiException('request_encoding_failed', null, $e); }
$h=curl_init(self::ENDPOINT); if ($h===false) throw new CometApiException('curl_init_failed');
$body=''; $tooLarge=false;
try {
$configured=curl_setopt_array($h,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$payload,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$this->apiKey,'Content-Type: application/json','Accept: application/json'],CURLOPT_RETURNTRANSFER=>false,CURLOPT_FOLLOWLOCATION=>false,CURLOPT_CONNECTTIMEOUT=>5,CURLOPT_TIMEOUT=>30,CURLOPT_WRITEFUNCTION=>static function($handle,string $chunk) use (&$body,&$tooLarge): int { $length=strlen($chunk); if ($length>self::MAX_RESPONSE_BYTES-strlen($body)) { $tooLarge=true; return 0; } $body.=$chunk; return $length; }]);
if (!$configured) throw new CometApiException('curl_options_failed');
$ok=curl_exec($h); if ($ok===false) { if ($tooLarge) throw new CometApiException('response_too_large'); throw new CometApiException('transport_failed'); }
$status=curl_getinfo($h,CURLINFO_RESPONSE_CODE); $type=strtolower(trim(explode(';',(string)curl_getinfo($h,CURLINFO_CONTENT_TYPE),2)[0]));
if ($status!==200) throw new CometApiException('http_failure',$status);
if ($type!=='application/json') throw new CometApiException('unexpected_media_type');
try { $data=json_decode($body,true,32,JSON_THROW_ON_ERROR); } catch(JsonException $e) { throw new CometApiException('response_json_invalid',null,$e); }
return self::acceptedText($data);
} finally { unset($h); }
}
private static function acceptedText(mixed $data): string {
$choice=is_array($data)?($data['choices'][0]??null):null; $message=is_array($choice)?($choice['message']??null):null; $text=is_array($message)?($message['content']??null):null; $object=is_array($data)?($data['object']??null):null;
if ($object!=='chat.completion' || !is_array($data['choices']??null) || count($data['choices'])!==1 || !is_array($message) || ($message['role']??null)!=='assistant' || ($choice['finish_reason']??null)!=='stop' || (array_key_exists('refusal',$message)&&$message['refusal']!==null) || (array_key_exists('tool_calls',$message)&&!empty($message['tool_calls'])) || !is_string($text) || trim($text)==='') throw new CometApiException('unaccepted_completion');
return $text;
}
}
The request uses a fixed HTTPS endpoint and trusted model. Redirect following is disabled, so a redirect is returned as a failure rather than forwarding a bearer header to another host. With CURLOPT_RETURNTRANSFER disabled and CURLOPT_WRITEFUNCTION set, PHP passes each body chunk to the callback and curl_exec returns true on success. The callback appends a chunk only while the cumulative body stays within the one-megabyte ceiling. Returning zero aborts an oversized transfer; its flag distinguishes that expected abort from another transport failure. The current chunk has already been allocated by the transport stack, but the application-owned response string never grows past the cap.
A true result from curl_exec means the callback completed, not that the model accepted the request. cURL reports transfer success separately from HTTP status. The adapter therefore reads CURLINFO_RESPONSE_CODE and CURLINFO_CONTENT_TYPE, rejects non-200 and non-JSON responses, and only then calls json_decode. Upstream bodies never enter exception messages.
The local CurlHandle is released with unset($h) in finally on success and failure. PHP 8 uses object lifetime management for this handle. The PHP cleanup reference
says curl_close has no effect since PHP 8.0 and is deprecated since PHP 8.5; omitting that call avoids a deprecation during cleanup. The static write callback does not retain the client object.
JSON_THROW_ON_ERROR converts malformed input into a controlled category. The depth limit prevents unexpectedly deep JSON from being accepted by this adapter. The acceptance function requires one chat.completion choice, an assistant message, a stop finish reason, non-empty string content, no refusal, and no non-empty tool calls. If the product wants truncated text, refusals, or tools, return a typed result instead of weakening this text-only contract.
Call it with a bounded workflow
Keep configuration outside the class source:
<?php
declare(strict_types=1);
require __DIR__.'/CometClient.php';
try {
$client=new CometClient(getenv('COMETAPI_KEY')?:'',getenv('COMETAPI_MODEL')?:'');
echo $client->complete('Explain idempotency in one sentence.'), PHP_EOL;
} catch (CometApiException $e) { error_log($e->getMessage().($e->httpStatus===null?'':':'.$e->httpStatus)); exit(1); }
In a web request, construct a shared service during startup and derive a request deadline if your framework exposes one. The class’s 30-second cURL timeout is a defensive ceiling. A worker should also enforce a job deadline and record an operation ID, model ID, status class, elapsed time, and acceptance category rather than the prompt or body.
Do not add an immediate retry in the catch block. A timeout or reset may happen after the provider began processing. If a retry is allowed, make it an outer policy with a total deadline, attempt cap, idempotency decision, and backoff. A second POST is a new generation unless the application can reconcile the first operation.
Contract details to verify
| Boundary | Check | Evidence |
|---|---|---|
| Request | One POST to /v1/chat/completions, JSON body, bearer header, stream: false | Redacted test-server assertion |
| Transport | Five-second connect limit, 30-second transfer limit, redirects disabled | Delayed and redirect fixtures |
| Success | HTTP 200 JSON with one completed assistant choice | Accepted-envelope fixture |
| Failure | Non-200, wrong media type, malformed JSON, refusal, tools, truncation | One fixture per category |
| Size | Over-limit body rejected before decoding | Boundary-size fixtures |
| Secrets | Logs exclude authorization, prompt, body, exception causes | Redaction review |
Use a local test server or cURL mock. Assert method, path, headers, and decoded request fields without printing the bearer value. Return a 302 fixture to prove redirects are not followed, a 429 with an arbitrary body to prove the body is not logged, and a delayed response to verify timeout handling. Test one byte over the response limit. The write callback enforces the cap during transfer; test that it rejects a crossing chunk before appending it and reports the overflow separately from a network error.
Failure modes
A 200 response is treated as success. HTTP status is only the transport result. Keep media-type, JSON, envelope, and finish-state checks.
A redirect sends credentials elsewhere. Keep CURLOPT_FOLLOWLOCATION false and review proxies or wrappers that rewrite destinations.
Raw cURL errors are logged. Map them to fixed categories and attach a safe request ID separately.
The response limit is applied after an unbounded read. Keep the byte counter inside CURLOPT_WRITEFUNCTION; checking after curl_exec has accumulated a full response defeats the memory boundary.
A tool call becomes assistant text. The documented finish reason and message fields distinguish tool calls from completed text. Require authorization before execution.
A timeout is retried automatically. Preserve uncertainty about remote execution. Decide replay safety and total budget before retries.
Sources checked
These six primary public references were fetched successfully on November 6, 2026 UTC:
- CometAPI Create a chat completion — endpoint, bearer authentication, request fields, response envelope, finish reasons, and streaming distinction.
- PHP cURL constants
—
CURLOPT_WRITEFUNCTION, connection timeout, and transfer timeout options. - PHP
curl_exec— callback output and boolean success behavior. - PHP
curl_close— PHP 8 no-op behavior and PHP 8.5 deprecation. - PHP
json_decode— decoding, depth, andJSON_THROW_ON_ERRORbehavior. - Guzzle quickstart — comparison point for a higher-level PHP HTTP client.
The endpoint, cURL callback behavior, timeout options, handle lifetime, and JSON behavior come from maintained references. Timeout values, the one-megabyte ceiling, no-retry policy, and accepted-text rules are local recommendations. Verify them against your PHP version, cURL build, model, and operational budget.
FAQ
Do I need Guzzle?
No. cURL is enough for this small non-streaming boundary. Guzzle helps when middleware, PSR-7 messages, dependency injection, or shared policies justify another layer. Keep the same status, timeout, size, and response checks either way.
Can this parse streaming output?
No. A stream is an event sequence rather than one JSON document. Add a separate reader with incremental parsing, a total deadline, a byte budget, and explicit terminal-event handling.
Does the response ceiling limit billing?
No. It limits bytes retained by this PHP process. A timeout or early close does not prove remote generation stopped or that no usage was recorded. Reconcile usage with the provider’s reporting contract.
Why reject finish_reason: length?
This example promises complete text. If partial text is useful, expose the finish state instead of silently labeling it complete.
Is a test-server pass enough?
No. Local tests prove request construction and classification. One authorized staging call is still needed to check credentials, permissions, model, TLS path, and current endpoint behavior.
Reader next step
Create the class, run the synthetic matrix, and inspect a redacted request for method, path, content type, model, and stream: false. Then make one authorized staging call with a harmless prompt. Record model ID, status class, response-shape verdict, elapsed time, and redaction result. If the live response differs from the current chat reference, pause and update the contract before changing the parser.
Compare the CometAPI Go client walkthrough when your team supports multiple runtimes. Keep the endpoint, model check, source date, and fixtures beside the PHP code so maintainers can identify assumptions that need fresh review.