Back to blog

APIs

PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application

Practical tutorial for calling LLM APIs from PHP - streaming responses, function calling, context management, and cost optimization.

  • PHP
  • OpenAI
  • ChatGPT
  • Claude
  • APIs
  • AI

Reader map

Key points in PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application

Syntax first, runtime behavior second, migration cleanup last.

Read
14 min
Waypoints
5
Track
APIs
  1. 01
    Start here

    One application interface: AiClient.

  2. 02
    Waypoint

    One implementation for OpenAI's Responses API.

  3. 03
    Waypoint

    One implementation for Anthropic's Messages API.

  4. 04
    Waypoint

    One tool executor with an allowlist.

  5. 05
    Migration check

    One place to handle retries, timeouts, request IDs, token usage, and provider errors.

SEO Metadata

SEO Title Options

  1. PHP and AI: Integrating ChatGPT & Claude APIs Into Your
  2. PHP APIs: Practical 2026 Guide
  3. APIs Playbook: PHP APIs

Meta Description Options

  1. Learn PHP APIs with a practical APIs framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Practical tutorial for calling LLM APIs from PHP - streaming responses, function calling, context management, and cost optimization.

URL Slug

php-ai-integrating-chatgpt-claude-apis-php-application

Focus Keyword

PHP APIs

Additional LSI Keywords

  • APIs
  • PHP
  • OpenAI
  • ChatGPT
  • Claude
  • AI
  • PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy

Table of Contents

Article overview

PHP APIs is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP APIs should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP APIs expert guide for APIs]

What PHP APIs means

PHP APIs means applying apis knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For apis topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP APIs implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP APIs as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP APIs common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP APIs with input, decision boundary, implementation, tests, and production feedback. Alt: PHP APIs concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application. Alt: PHP APIs mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP APIs comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP APIs.]

Internal linking opportunities

Original Technical Deep Dive

Version note

This article is dated March 25, 2025 because it belongs to the editorial timeline of this blog series.

The examples were reviewed on May 7, 2026. OpenAI and Anthropic APIs move quickly, so keep model names in configuration and verify current provider docs before shipping a production integration.

The short version

Do not wire an AI provider directly into controllers.

Use a small boundary:

  • One application interface: AiClient.
  • One implementation for OpenAI's Responses API.
  • One implementation for Anthropic's Messages API.
  • One tool executor with an allowlist.
  • One place to handle retries, timeouts, request IDs, token usage, and provider errors.

The important differences:

ConcernOpenAIClaude
Main endpoint for new text/tool appsPOST /v1/responsesPOST /v1/messages
AuthenticationAuthorization: Bearer ...x-api-key: ... plus anthropic-version
Streaming formatServer-sent events with response.output_text.delta eventsServer-sent events with content_block_delta text deltas
Tool callingModel returns function_call items; app sends function_call_output items backClaude returns tool_use blocks; app sends tool_result blocks back
Conversation stateManual history, previous_response_id, or Conversations APIStateless Messages API; send conversation history each request
Prompt cachingAutomatic for supported long prompts; optimize stable prefixesExplicit cache_control breakpoints for reusable prefixes

Keep both providers behind your own code. Product code should ask for a response, not know which JSON shape each vendor currently uses.

Environment configuration

Use server-side environment variables:

AI_PROVIDER=openai

OPENAI_API_KEY=sk-...
OPENAI_MODEL=your-openai-model

ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=your-claude-model
ANTHROPIC_VERSION=2023-06-01

Never expose these keys in browser JavaScript, mobile app bundles, or logs.

A minimal config object:

<?php

declare(strict_types=1);

final readonly class AiConfig
{
    public function __construct(
        public string $provider,
        public string $openAiApiKey,
        public string $openAiModel,
        public string $anthropicApiKey,
        public string $anthropicModel,
        public string $anthropicVersion = '2023-06-01',
    ) {}

    public static function fromEnvironment(): self
    {
        return new self(
            provider: self::required('AI_PROVIDER'),
            openAiApiKey: self::required('OPENAI_API_KEY'),
            openAiModel: self::required('OPENAI_MODEL'),
            anthropicApiKey: self::required('ANTHROPIC_API_KEY'),
            anthropicModel: self::required('ANTHROPIC_MODEL'),
            anthropicVersion: getenv('ANTHROPIC_VERSION') ?: '2023-06-01',
        );
    }

    private static function required(string $key): string
    {
        $value = getenv($key);

        if (! is_string($value) || trim($value) === '') {
            throw new RuntimeException("Missing required environment variable: {$key}");
        }

        return $value;
    }
}

Make the model required instead of hardcoding a default. That forces deployment config to make a conscious provider and cost choice.

A small interface

Application code should depend on this:

<?php

declare(strict_types=1);

interface AiClient
{
    /**
     * @param list<AiMessage> $messages
     */
    public function complete(array $messages): AiResponse;

    /**
     * @param list<AiMessage> $messages
     * @param callable(string): void $onText
     */
    public function stream(array $messages, callable $onText): void;
}

final readonly class AiMessage
{
    public function __construct(
        public string $role,
        public string $content,
    ) {}
}

final readonly class AiResponse
{
    /**
     * @param array<string, mixed> $usage
     */
    public function __construct(
        public string $text,
        public array $usage = [],
        public ?string $providerRequestId = null,
    ) {}
}

Now a controller or job can stay boring:

<?php

declare(strict_types=1);

final readonly class SupportReplyService
{
    public function __construct(private AiClient $ai) {}

    public function draftReply(string $customerMessage): string
    {
        $response = $this->ai->complete([
            new AiMessage('system', 'Write concise support replies. Do not invent policy.'),
            new AiMessage('user', $customerMessage),
        ]);

        return $response->text;
    }
}

Provider details stay outside this service.

Shared HTTP client

Use Guzzle, Symfony HTTP Client, Laravel HTTP client, Saloon, or PSR-18 in a real app. The examples below use cURL so the protocol details are visible.

<?php

declare(strict_types=1);

final class JsonHttpClient
{
    /**
     * @param array<string, string> $headers
     * @param array<string, mixed> $payload
     * @return array{body: array<string, mixed>, headers: array<string, string>, status: int}
     */
    public function postJson(string $url, array $headers, array $payload, int $timeoutSeconds = 60): array
    {
        $curl = curl_init($url);

        $responseHeaders = [];

        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => $this->formatHeaders($headers + ['Content-Type' => 'application/json']),
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
            CURLOPT_TIMEOUT => $timeoutSeconds,
        ]);

        $raw = curl_exec($curl);

        if ($raw === false) {
            $error = curl_error($curl);
            curl_close($curl);

            throw new RuntimeException("AI HTTP request failed: {$error}");
        }

        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        $body = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);

        if (! is_array($body)) {
            throw new RuntimeException('AI provider returned an invalid JSON body.');
        }

        if ($status >= 400) {
            $message = $body['error']['message'] ?? $body['error']['type'] ?? 'Unknown AI provider error';
            throw new RuntimeException("AI provider returned HTTP {$status}: {$message}");
        }

        return [
            'body' => $body,
            'headers' => $responseHeaders,
            'status' => $status,
        ];
    }

    /**
     * @param array<string, string> $headers
     * @return list<string>
     */
    private function formatHeaders(array $headers): array
    {
        $formatted = [];

        foreach ($headers as $name => $value) {
            $formatted[] = "{$name}: {$value}";
        }

        return $formatted;
    }
}

Production additions:

  • Retry only safe transient errors.
  • Add a hard timeout.
  • Log provider request IDs.
  • Track status code, model, input tokens, output tokens, and cached tokens.
  • Do not log prompts unless your privacy policy and customer contract allow it.

[IMAGE: Supporting visual 1 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 1]

[IMAGE: Supporting visual 1 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 1]

OpenAI Responses API client

OpenAI recommends the Responses API for new projects. It supports text generation, multimodal inputs, stateful interactions, and tools.

<?php

declare(strict_types=1);

final readonly class OpenAiResponsesClient implements AiClient
{
    public function __construct(
        private JsonHttpClient $http,
        private string $apiKey,
        private string $model,
    ) {}

    public function complete(array $messages): AiResponse
    {
        $payload = [
            'model' => $this->model,
            'input' => $this->toOpenAiInput($messages),
            'store' => false,
        ];

        $result = $this->http->postJson(
            url: 'https://api.openai.com/v1/responses',
            headers: [
                'Authorization' => "Bearer {$this->apiKey}",
                'X-Client-Request-Id' => bin2hex(random_bytes(16)),
            ],
            payload: $payload,
        );

        return new AiResponse(
            text: $this->extractText($result['body']),
            usage: $result['body']['usage'] ?? [],
            providerRequestId: $result['headers']['x-request-id'] ?? null,
        );
    }

    public function stream(array $messages, callable $onText): void
    {
        $payload = [
            'model' => $this->model,
            'input' => $this->toOpenAiInput($messages),
            'stream' => true,
            'store' => false,
        ];

        (new SseClient())->post(
            url: 'https://api.openai.com/v1/responses',
            headers: [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type' => 'application/json',
            ],
            payload: $payload,
            onEvent: static function (string $event, array $data) use ($onText): void {
                if (($data['type'] ?? null) === 'response.output_text.delta') {
                    $delta = $data['delta'] ?? '';

                    if (is_string($delta) && $delta !== '') {
                        $onText($delta);
                    }
                }

                if (($data['type'] ?? null) === 'error') {
                    $message = $data['error']['message'] ?? 'OpenAI streaming error';
                    throw new RuntimeException((string) $message);
                }
            },
        );
    }

    /**
     * @param list<AiMessage> $messages
     * @return list<array{role: string, content: string}>
     */
    private function toOpenAiInput(array $messages): array
    {
        return array_map(
            static fn (AiMessage $message): array => [
                'role' => $message->role,
                'content' => $message->content,
            ],
            $messages,
        );
    }

    /**
     * @param array<string, mixed> $body
     */
    private function extractText(array $body): string
    {
        if (isset($body['output_text']) && is_string($body['output_text'])) {
            return $body['output_text'];
        }

        $text = '';

        foreach (($body['output'] ?? []) as $item) {
            foreach (($item['content'] ?? []) as $content) {
                if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
                    $text .= $content['text'];
                }
            }
        }

        return $text;
    }
}

store: false disables default response storage for this request. Use provider-managed conversation state only when your product needs it and your retention policy allows it.

Claude Messages API client

Claude's Messages API is stateless. You send the current conversation history on each request.

<?php

declare(strict_types=1);

final readonly class ClaudeMessagesClient implements AiClient
{
    public function __construct(
        private JsonHttpClient $http,
        private string $apiKey,
        private string $model,
        private string $anthropicVersion,
    ) {}

    public function complete(array $messages): AiResponse
    {
        [$system, $conversation] = $this->splitSystemMessages($messages);

        $payload = [
            'model' => $this->model,
            'max_tokens' => 1024,
            'messages' => $conversation,
        ];

        if ($system !== '') {
            $payload['system'] = $system;
        }

        $result = $this->http->postJson(
            url: 'https://api.anthropic.com/v1/messages',
            headers: [
                'x-api-key' => $this->apiKey,
                'anthropic-version' => $this->anthropicVersion,
            ],
            payload: $payload,
        );

        return new AiResponse(
            text: $this->extractText($result['body']),
            usage: $result['body']['usage'] ?? [],
            providerRequestId: $result['headers']['request-id'] ?? null,
        );
    }

    public function stream(array $messages, callable $onText): void
    {
        [$system, $conversation] = $this->splitSystemMessages($messages);

        $payload = [
            'model' => $this->model,
            'max_tokens' => 1024,
            'stream' => true,
            'messages' => $conversation,
        ];

        if ($system !== '') {
            $payload['system'] = $system;
        }

        (new SseClient())->post(
            url: 'https://api.anthropic.com/v1/messages',
            headers: [
                'x-api-key' => $this->apiKey,
                'anthropic-version' => $this->anthropicVersion,
                'Content-Type' => 'application/json',
            ],
            payload: $payload,
            onEvent: static function (string $event, array $data) use ($onText): void {
                if (($data['type'] ?? null) === 'content_block_delta') {
                    $delta = $data['delta'] ?? [];

                    if (($delta['type'] ?? null) === 'text_delta' && is_string($delta['text'] ?? null)) {
                        $onText($delta['text']);
                    }
                }

                if (($data['type'] ?? null) === 'error') {
                    $message = $data['error']['message'] ?? 'Claude streaming error';
                    throw new RuntimeException((string) $message);
                }
            },
        );
    }

    /**
     * @param list<AiMessage> $messages
     * @return array{0: string, 1: list<array{role: string, content: string}>}
     */
    private function splitSystemMessages(array $messages): array
    {
        $system = [];
        $conversation = [];

        foreach ($messages as $message) {
            if ($message->role === 'system') {
                $system[] = $message->content;
                continue;
            }

            $conversation[] = [
                'role' => $message->role,
                'content' => $message->content,
            ];
        }

        return [implode("\n\n", $system), $conversation];
    }

    /**
     * @param array<string, mixed> $body
     */
    private function extractText(array $body): string
    {
        $text = '';

        foreach (($body['content'] ?? []) as $block) {
            if (($block['type'] ?? null) === 'text' && is_string($block['text'] ?? null)) {
                $text .= $block['text'];
            }
        }

        return $text;
    }
}

Claude does not use a "system" role in the messages array. Put system instructions in the top-level system field.

Streaming with server-sent events

Both APIs stream as server-sent events. The event names differ, but the wire format is similar.

A minimal SSE client:

<?php

declare(strict_types=1);

final class SseClient
{
    /**
     * @param array<string, string> $headers
     * @param array<string, mixed> $payload
     * @param callable(string, array<string, mixed>): void $onEvent
     */
    public function post(string $url, array $headers, array $payload, callable $onEvent): void
    {
        $buffer = '';
        $event = 'message';
        $dataLines = [];

        $flush = static function () use (&$event, &$dataLines, $onEvent): void {
            if ($dataLines === []) {
                return;
            }

            $rawData = implode("\n", $dataLines);
            $dataLines = [];

            if ($rawData === '[DONE]') {
                return;
            }

            $decoded = json_decode($rawData, true, flags: JSON_THROW_ON_ERROR);

            if (is_array($decoded)) {
                $onEvent($event, $decoded);
            }

            $event = 'message';
        };

        $curl = curl_init($url);

        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => $this->formatHeaders($headers),
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$buffer, &$event, &$dataLines, $flush): int {
                $buffer .= $chunk;

                while (($position = strpos($buffer, "\n")) !== false) {
                    $line = rtrim(substr($buffer, 0, $position), "\r");
                    $buffer = substr($buffer, $position + 1);

                    if ($line === '') {
                        $flush();
                        continue;
                    }

                    if (str_starts_with($line, 'event:')) {
                        $event = trim(substr($line, 6));
                        continue;
                    }

                    if (str_starts_with($line, 'data:')) {
                        $dataLines[] = ltrim(substr($line, 5));
                    }
                }

                return strlen($chunk);
            },
            CURLOPT_TIMEOUT => 120,
        ]);

        $ok = curl_exec($curl);

        if ($ok === false) {
            $error = curl_error($curl);
            curl_close($curl);

            throw new RuntimeException("AI stream failed: {$error}");
        }

        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        if ($status >= 400) {
            throw new RuntimeException("AI stream returned HTTP {$status}");
        }
    }

    /**
     * @param array<string, string> $headers
     * @return list<string>
     */
    private function formatHeaders(array $headers): array
    {
        $formatted = [];

        foreach ($headers as $name => $value) {
            $formatted[] = "{$name}: {$value}";
        }

        return $formatted;
    }
}

In a web app, stream to the browser from your server:

<?php

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');

$ai->stream($messages, static function (string $delta): void {
    echo 'data: '.json_encode(['text' => $delta], JSON_THROW_ON_ERROR)."\n\n";
    flush();
});

Do moderation and policy checks before streaming when possible. Streaming partial output is harder to inspect than a complete response.

Function calling with OpenAI

Function calling is not magic. The model asks to call a tool. Your application decides whether to run it.

Define tools:

<?php

$tools = [
    [
        'type' => 'function',
        'name' => 'lookup_order',
        'description' => 'Look up an order by public order number.',
        'parameters' => [
            'type' => 'object',
            'properties' => [
                'order_number' => [
                    'type' => 'string',
                    'description' => 'The customer-facing order number.',
                ],
            ],
            'required' => ['order_number'],
            'additionalProperties' => false,
        ],
        'strict' => true,
    ],
];

Call the model:

<?php

$first = $http->postJson(
    'https://api.openai.com/v1/responses',
    ['Authorization' => "Bearer {$openAiKey}"],
    [
        'model' => $openAiModel,
        'input' => [
            [
                'role' => 'user',
                'content' => 'Where is order A10045?',
            ],
        ],
        'tools' => $tools,
        'tool_choice' => 'auto',
        'store' => false,
    ],
)['body'];

Execute only allowlisted calls:

<?php

$toolOutputs = [];

foreach (($first['output'] ?? []) as $item) {
    if (($item['type'] ?? null) !== 'function_call') {
        continue;
    }

    $name = $item['name'] ?? '';
    $arguments = json_decode((string) ($item['arguments'] ?? '{}'), true, flags: JSON_THROW_ON_ERROR);

    if ($name !== 'lookup_order') {
        throw new RuntimeException("Unsupported tool call: {$name}");
    }

    $order = $orderRepository->findPublicSummary((string) $arguments['order_number']);

    $toolOutputs[] = [
        'type' => 'function_call_output',
        'call_id' => $item['call_id'],
        'output' => json_encode($order, JSON_THROW_ON_ERROR),
    ];
}

Send the tool result back:

<?php

$secondInput = [
    ...($first['output'] ?? []),
    ...$toolOutputs,
];

$final = $http->postJson(
    'https://api.openai.com/v1/responses',
    ['Authorization' => "Bearer {$openAiKey}"],
    [
        'model' => $openAiModel,
        'input' => $secondInput,
        'tools' => $tools,
        'store' => false,
    ],
)['body'];

Security rules:

  • Never let the model choose arbitrary PHP functions.
  • Never execute SQL, shell commands, or HTTP requests from raw model text.
  • Validate tool arguments with normal application validation.
  • Enforce authorization before returning private data to a tool result.
  • Make write tools idempotent where possible.

Tool use with Claude

Claude's client-side tool flow is similar, but the JSON shape is different.

Tool definition:

<?php

$tools = [
    [
        'name' => 'lookup_order',
        'description' => 'Look up an order by public order number.',
        'input_schema' => [
            'type' => 'object',
            'properties' => [
                'order_number' => [
                    'type' => 'string',
                ],
            ],
            'required' => ['order_number'],
        ],
    ],
];

First request:

<?php

$messages = [
    [
        'role' => 'user',
        'content' => 'Where is order A10045?',
    ],
];

$first = $http->postJson(
    'https://api.anthropic.com/v1/messages',
    [
        'x-api-key' => $anthropicKey,
        'anthropic-version' => '2023-06-01',
    ],
    [
        'model' => $claudeModel,
        'max_tokens' => 1024,
        'tools' => $tools,
        'messages' => $messages,
    ],
)['body'];

Handle tool_use blocks:

<?php

$toolResults = [];

foreach (($first['content'] ?? []) as $block) {
    if (($block['type'] ?? null) !== 'tool_use') {
        continue;
    }

    if (($block['name'] ?? '') !== 'lookup_order') {
        throw new RuntimeException('Unsupported Claude tool call.');
    }

    $input = $block['input'] ?? [];
    $order = $orderRepository->findPublicSummary((string) $input['order_number']);

    $toolResults[] = [
        'type' => 'tool_result',
        'tool_use_id' => $block['id'],
        'content' => json_encode($order, JSON_THROW_ON_ERROR),
    ];
}

Second request:

<?php

$messages[] = [
    'role' => 'assistant',
    'content' => $first['content'],
];

$messages[] = [
    'role' => 'user',
    'content' => $toolResults,
];

$final = $http->postJson(
    'https://api.anthropic.com/v1/messages',
    [
        'x-api-key' => $anthropicKey,
        'anthropic-version' => '2023-06-01',
    ],
    [
        'model' => $claudeModel,
        'max_tokens' => 1024,
        'tools' => $tools,
        'messages' => $messages,
    ],
)['body'];

Do not normalize OpenAI and Claude tool payloads too early. Normalize at your application boundary, but preserve provider payloads inside provider clients so you can follow each provider's protocol correctly.

Context management

Context is not memory. It is input you pay to send.

For OpenAI:

  • You can manually send prior messages.
  • You can chain turns with previous_response_id.
  • You can use Conversations API when you need durable provider-managed state.
  • Even with previous_response_id, previous input tokens in the chain are billed as input tokens.
  • Response objects are stored by default; set store: false when provider-side storage is not needed.

[IMAGE: Supporting visual 2 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 2]

For Claude:

  • The Messages API is stateless.
  • Send prior turns in messages.
  • Use the top-level system parameter for system instructions.
  • Count tokens before large requests with /v1/messages/count_tokens.
  • Summarize or trim old turns when the conversation gets too large.

[IMAGE: Supporting visual 2 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 2]

A simple application-managed conversation table:

ai_conversations
  id
  user_id
  provider
  model
  summary
  created_at
  updated_at

ai_messages
  id
  conversation_id
  role
  content
  token_estimate
  created_at

Before each call:

  1. Load the system prompt.
  2. Add the conversation summary.
  3. Add recent messages.
  4. Add the new user message.
  5. Estimate tokens.
  6. If too large, summarize older messages and retry.

Do not store raw prompts forever by accident. Put retention rules on these tables.

Prompt caching and cost control

Cost control starts with fewer tokens and fewer calls.

Use these rules for both providers:

  • Put stable instructions first.
  • Put tool definitions before volatile user context.
  • Put user-specific or request-specific data at the end.
  • Keep large reference context stable across turns when possible.
  • Log input tokens, output tokens, and cached token fields.
  • Route simple tasks to smaller models when quality is sufficient.
  • Use batch APIs for offline bulk work.

OpenAI prompt caching is automatic on supported long prompts. You can improve hit rate by keeping an exact stable prefix and using prompt_cache_key consistently for related requests:

<?php

$payload = [
    'model' => $openAiModel,
    'instructions' => $staticInstructions,
    'input' => $dynamicUserQuestion,
    'prompt_cache_key' => 'support-assistant-v4',
    'store' => false,
];

Claude prompt caching is explicit. Mark the end of reusable content with cache_control:

<?php

$payload = [
    'model' => $claudeModel,
    'max_tokens' => 1024,
    'system' => [
        [
            'type' => 'text',
            'text' => $longStablePolicyManual,
            'cache_control' => [
                'type' => 'ephemeral',
                'ttl' => '5m',
            ],
        ],
    ],
    'messages' => [
        [
            'role' => 'user',
            'content' => 'Draft a reply for ticket A10045.',
        ],
    ],
];

Do not cache content that changes every request. A timestamp in the cached prefix can destroy the cache hit rate.

Provider selection

Select the provider at composition time:

<?php

declare(strict_types=1);

final readonly class AiClientFactory
{
    public function __construct(
        private JsonHttpClient $http,
        private AiConfig $config,
    ) {}

    public function make(): AiClient
    {
        return match ($this->config->provider) {
            'openai' => new OpenAiResponsesClient(
                http: $this->http,
                apiKey: $this->config->openAiApiKey,
                model: $this->config->openAiModel,
            ),
            'anthropic' => new ClaudeMessagesClient(
                http: $this->http,
                apiKey: $this->config->anthropicApiKey,
                model: $this->config->anthropicModel,
                anthropicVersion: $this->config->anthropicVersion,
            ),
            default => throw new InvalidArgumentException("Unsupported AI provider: {$this->config->provider}"),
        };
    }
}

Keep provider switching operationally controlled. A fallback from one model family to another can change answers, tool behavior, latency, safety behavior, and cost.

Testing

Do not run live LLM calls in normal unit tests.

Test the boundary:

<?php

declare(strict_types=1);

final class FakeAiClient implements AiClient
{
    public function __construct(private string $reply = 'Test reply') {}

    public function complete(array $messages): AiResponse
    {
        return new AiResponse($this->reply);
    }

    public function stream(array $messages, callable $onText): void
    {
        $onText($this->reply);
    }
}

Integration tests should use recorded fixtures or a separate provider test suite:

  • One test proves OpenAI request mapping.
  • One test proves Claude request mapping.
  • One test proves tool allowlisting.
  • One test proves malformed provider responses fail cleanly.
  • One scheduled smoke test can hit real APIs with a tiny prompt.

[IMAGE: Supporting visual 3 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 3]

Production checklist

  • API keys stay server-side.
  • Provider, model, and timeout are configurable.
  • User prompts are validated and length-limited.
  • Request IDs are logged.
  • Token usage is logged per customer, feature, provider, and model.
  • Tool calls use an allowlist.
  • Tool arguments go through normal validation and authorization.
  • Streaming endpoints disable buffering and handle disconnects.
  • Long-running calls run in jobs when the user does not need an immediate response.
  • Retry logic handles rate limits and transient 5xx errors with backoff.
  • Prompt and response retention are documented.
  • High-cost features have per-user or per-account quotas.
  • Provider SDK or raw HTTP client versions are tracked in dependency updates.

[IMAGE: Supporting visual 3 for PHP and AI: Integrating ChatGPT & Claude APIs Into Your PHP Application, showing PHP APIs decisions, examples, and PHP, OpenAI, ChatGPT. Alt: PHP APIs php-ai-integrating-chatgpt-claude-apis-php-application visual 3]

Common mistakes

Do not call LLM APIs from Blade, Twig, or frontend JavaScript.

Do not concatenate untrusted user text into tool instructions and then execute arbitrary operations.

Do not assume OpenAI and Claude have the same conversation state model.

Do not stream unreviewed model output into sensitive workflows where partial output can cause harm.

Do not send entire database records when a small public summary is enough.

Do not let "fallback provider" mean "silently change model behavior in production."

Do not use model-generated JSON without schema validation.

FAQ

What is PHP APIs?

PHP APIs is a practical apis topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP APIs?

Use PHP APIs when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP APIs?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP APIs?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP APIs affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP APIs is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top