# PHP integration

Run these examples on your server. They use the language runtime HTTP client, not a platform SDK. No request is sent until you run a command. Models must be configured and enabled before use.

## Setup

Runtime: **PHP 8.1+ + ext-curl**. [Download complete source](/examples/php.php).

Set your deployment base URL, including `/v1` exactly once, and a server-side API key. `https://xxxrouter.com` is the official service domain; for local testing, replace it with your deployment origin. The service is not yet deployed and integration certification is not complete.

```sh
export XXXROUTER_BASE_URL='https://xxxrouter.com/v1'
export XXXROUTER_API_KEY='YOUR_SERVER_SIDE_KEY'
```

## Run one path

`chat` reads SSE deltas and final usage; `image` receives a synchronous image; `async-image` and `video` handle HTTP 202 and query the returned task. Polling is bounded to approximately five minutes plus any in-flight read; a timeout preserves the task for later reads.

```sh
php php.php chat
# php php.php image
# php php.php async-image
# php php.php video
```

## Idempotency and failure handling

A new run creates a new idempotency key and can incur a new charge. In an application, persist that key with the exact body before submitting. Set `XXXROUTER_REQUEST_ID` only to resume the same logical request with the same model, payload, API key and source. Do not rerun with a new key after a transport failure. These clients perform one generation POST and never retry it automatically.

Record the response `X-Request-ID`. HTTP errors retain the response body for diagnosis. A task with `status=unknown` stops automatic polling and requires later read/reconciliation; it is not a successful or free request. A stream error event or missing `[DONE]` is an interruption. Missing usage is not zero cost. Stopping the connection does not cancel accepted work. Do not log API keys or signed asset URLs.

## Image inputs and result files

For image-to-image or image-to-video, pass a public HTTPS URL in `image` when the model supports reference images. The public inference API takes URLs, not file bytes. Playground can upload temporary input images or videos to private R2 storage; only signed-in, verified users can use it. Generated results are never downloaded, stored or rehosted by XXXRouter. Successful responses return the original generated-media URL unchanged. The original host controls availability, expiry and browser cross-origin behavior. Save results before expiry; querying a completed task returns the saved URL and does not renew it. Request metadata and result URLs may be retained for recovery; generated-media bytes are never retained.

## Complete example

The code is an integration example, not proof that a live model or payment channel is enabled. Do not place it in a browser bundle.

```php
<?php
// PHP 8.1+ with ext-curl. Run: php php.php chat
// Server-side only. No redirect following or automatic generation retries.
declare(strict_types=1);
$base = rtrim(getenv('XXXROUTER_BASE_URL') ?: '', '/'); // Include /v1.
$key = getenv('XXXROUTER_API_KEY') ?: '';
if (!$base || !$key) throw new RuntimeException('Set XXXROUTER_BASE_URL and XXXROUTER_API_KEY');

function request(string $path, ?array $payload = null, ?string $idem = null): array {
    global $base, $key;
    $headers = ['Authorization: Bearer ' . $key];
    $contentType = ''; $requestId = ''; $raw = ''; $buffer = ''; $data = [];
    $done = false; $streamError = null;
    $consume = function () use (&$data, &$done, &$streamError): void {
        $value = implode("\n", $data); $data = [];
        if ($value === '[DONE]') { $done = true; return; }
        if ($value === '') return;
        try {
            $chunk = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
            if (isset($chunk['error'])) { $streamError = json_encode($chunk['error']); return; }
            foreach ($chunk['choices'] ?? [] as $choice) echo $choice['delta']['content'] ?? '';
            if (isset($chunk['usage'])) echo "\nusage: " . json_encode($chunk['usage']) . "\n";
        } catch (JsonException $e) { $streamError = $e->getMessage(); }
    };
    $curl = curl_init($base . '/' . $path);
    $options = [
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => $payload !== null ? 300 : 30,
        CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$contentType, &$requestId): int {
            if (stripos($line, 'Content-Type:') === 0) $contentType = trim(substr($line, 13));
            if (stripos($line, 'X-Request-ID:') === 0) $requestId = trim(substr($line, 13));
            return strlen($line);
        },
        CURLOPT_WRITEFUNCTION => function ($handle, string $bytes) use (&$contentType, &$raw, &$buffer, &$data, &$done, &$streamError, $consume): int {
            if (!str_contains($contentType, 'text/event-stream')) { $raw .= $bytes; return strlen($bytes); }
            $buffer .= $bytes;
            while (($end = strpos($buffer, "\n")) !== false) {
                $line = rtrim(substr($buffer, 0, $end), "\r"); $buffer = substr($buffer, $end + 1);
                if ($line === '') $consume();
                elseif (str_starts_with($line, 'data:')) $data[] = ltrim(substr($line, 5), ' ');
            }
            return strlen($bytes);
        },
    ];
    if ($payload !== null) {
        $headers[] = 'Content-Type: application/json';
        $headers[] = 'Idempotency-Key: ' . $idem;
        $options[CURLOPT_POST] = true;
        $options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR);
    }
    $options[CURLOPT_HTTPHEADER] = $headers;
    curl_setopt_array($curl, $options);
    try {
        $ok = curl_exec($curl);
        $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
        fwrite(STDERR, 'request_id=' . $requestId . "\n");
        if ($ok === false) throw new RuntimeException('Transport error; retain IDs, do not resubmit: ' . curl_error($curl));
        if ($status < 200 || $status >= 300) throw new RuntimeException('HTTP ' . $status . ': ' . $raw);
        if (str_contains($contentType, 'text/event-stream')) {
            if (str_starts_with($buffer, 'data:')) $data[] = ltrim(substr($buffer, 5), ' ');
            $consume();
            if ($streamError !== null) throw new RuntimeException($streamError);
            if (!$done) throw new RuntimeException('Stream interrupted; retain request ID and idempotency key.');
            return ['stream_complete' => true];
        }
        return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    } finally { curl_close($curl); }
}
$cases = [
    'chat' => ['chat/completions', ['model'=>'deepseek-v4-flash','messages'=>[['role'=>'user','content'=>'Explain HTTP in one sentence.']],'max_tokens'=>256,'stream'=>true,'stream_options'=>['include_usage'=>true]]],
    'image' => ['images/generations', ['model'=>'seedream-4.5','prompt'=>'A ceramic cup in natural light','size'=>'2048x2048','n'=>1]],
    'async-image' => ['images/generations', ['model'=>'nano-banana-pro','prompt'=>'A ceramic cup in natural light','size'=>'1024x1024','n'=>1]],
    'video' => ['videos/generations', ['model'=>'kling-3.0','prompt'=>'A slow camera move across a mountain landscape','duration'=>5,'aspect_ratio'=>'16:9']],
];
$mode = $argv[1] ?? 'chat';
if (!isset($cases[$mode])) throw new InvalidArgumentException('Choose chat, image, async-image or video');
[$path, $payload] = $cases[$mode];
$idem = getenv('XXXROUTER_REQUEST_ID') ?: bin2hex(random_bytes(16));
fwrite(STDERR, 'idempotency_key=' . $idem . "\n"); // Persist alongside exact payload before submission.
$result = request($path, $payload, $idem);
$deadline = microtime(true) + 300;
while (isset($result['task_id']) && !in_array($result['status'], ['succeeded', 'failed', 'unknown'], true)) {
    if (microtime(true) >= $deadline) throw new RuntimeException('Query task ' . $result['task_id'] . ' later; do not resubmit.');
    sleep(2);
    $result = request('tasks/' . rawurlencode($result['task_id']));
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n";
if (($result['status'] ?? '') === 'unknown') throw new RuntimeException('Outcome unconfirmed; retain IDs and wait for reconciliation.');
if (($result['status'] ?? '') === 'failed') throw new RuntimeException('Generation failed; inspect error and billing.');
```

## Related reference

[chat](/docs?page=chat&lang=en) · [images](/docs?page=images&lang=en) · [videos](/docs?page=videos&lang=en) · [tasks](/docs?page=tasks&lang=en) · [uploads](/docs?page=uploads&lang=en) · [errors](/docs?page=errors&lang=en)

[Runtime documentation](https://www.php.net/manual/en/function.curl-exec.php) · 2026-10-26
