unknowingpro / php-ai
A unified PHP interface for LLM providers — Anthropic, OpenAI and Gemini — with streaming, tool calling, structured output and provider-agnostic middleware.
Requires
- php: ^8.2
- ext-json: *
- illuminate/support: ^10.0|^11.0|^12.0
- nyholm/psr7: ^1.8
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
Requires (Dev)
- laravel/pint: ^1.20
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Suggests
- ext-curl: Enables the bundled zero-configuration HTTP transport.
- guzzlehttp/guzzle: Use Guzzle as the PSR-18 client instead of the bundled transport.
- phpunit/phpunit: Required by the assertion helpers in PhpAi\Testing\FakeProvider.
- psr/log-implementation: Required by LoggingMiddleware.
- psr/simple-cache-implementation: Required by CacheMiddleware.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 10:21:46 UTC
README
A unified PHP interface for Anthropic, OpenAI, Gemini, and OpenAI-compatible providers, with streaming, tool calling, structured output, embeddings, and provider-agnostic middleware.
Status
The core library, provider drivers, streaming adapters, middleware, testing kit, and Laravel integration are implemented. The current verification environment passes the automated suite; see Verification for the exact results and cross-version qualification.
Requirements
- PHP 8.2 or newer
ext-json- A PSR-18 HTTP client and PSR-17 factories, or the bundled cURL transport (
ext-curl) - Laravel 10.x, 11.x, or 12.x for the Laravel integration, subject to the Composer constraints
Installation
composer require unknowingpro/php-ai
The package includes Laravel support in its main Composer requirements. Laravel package discovery registers the service provider automatically; no manual provider registration is required.
Framework-agnostic PHP
The core API works without Laravel:
<?php use PhpAi\AI; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); echo $ai->prompt('claude-opus-5', 'Say hello in five words.');
For longer requests, use the immutable Request builder:
<?php use PhpAi\AI; use PhpAi\Request; $ai = AI::openai($_ENV['OPENAI_API_KEY']); $response = $ai->run( Request::to('gpt-4o-mini') ->system('You are concise.') ->user('Explain server-sent events.') ->maxTokens(512), ); echo $response->text; echo $response->usage->totalTokens();
generate() performs exactly one provider round trip. run() performs the tool loop when tools are present and otherwise behaves like a normal generation call.
Providers
Built-in entry points are available for:
- Anthropic Messages API:
AI::anthropic($apiKey) - OpenAI Responses API:
AI::openai($apiKey) - OpenAI Chat Completions:
AI::openaiChat($apiKey) - Google Gemini:
AI::gemini($apiKey)
OpenAI-compatible presets are available through PhpAi\Providers\OpenAI\Compatible:
<?php use PhpAi\AI; use PhpAi\ProviderRegistry; use PhpAi\Providers\OpenAI\Compatible; $ai = AI::of( (new ProviderRegistry()) ->register('ollama', Compatible::ollama()) ->register('groq', Compatible::groq($_ENV['GROQ_API_KEY'])) ->register('router', Compatible::openRouter($_ENV['OPENROUTER_API_KEY'])), ); echo $ai->provider('ollama')->prompt('llama3.1', 'Say hello.');
Additional presets include DeepSeek, Mistral, xAI, Together, and arbitrary local OpenAI-compatible servers through Compatible::localServer().
Model IDs are passed through to the provider. The built-in catalogs include capability data for model families, but provider accounts and model availability remain the final authority. Use a model ID enabled for your account. The catalog includes examples such as claude-opus-5, gpt-4o-mini, gemini-2.0-flash, and text-embedding-3-small for the corresponding providers.
Streaming
Use streamText() for the common text-only case:
<?php use PhpAi\AI; use PhpAi\Request; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); $stream = $ai->streamText(Request::to('claude-opus-5')->user('Write a haiku.')); foreach ($stream as $delta) { echo $delta; flush(); } $response = $stream->getReturn();
For typed events, use stream():
<?php use PhpAi\AI; use PhpAi\Request; use PhpAi\Streaming\Events\StreamEnd; use PhpAi\Streaming\Events\TextDelta; use PhpAi\Streaming\Events\ToolCallComplete; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); $request = Request::to('claude-opus-5')->user('Write a haiku.'); foreach ($ai->stream($request) as $event) { match (true) { $event instanceof TextDelta => print $event->text, $event instanceof ToolCallComplete => print "\\nCalling {$event->call->name}\\n", $event instanceof StreamEnd => print "\\n{$event->response->usage->outputTokens} output tokens\\n", default => null, }; }
Events include text, thinking, tool-call, usage, step, error, and stream-end events. A provider stream ends with StreamEnd carrying the assembled response. When using streamRun(), tool calls are executed between turns.
Streaming failover is deliberately conservative: a fallback may be selected if the primary fails before emitting an event. Once output has been emitted, the exception is propagated rather than restarting the request and duplicating output.
Tool calling
Give a tool a handler and run() executes the loop:
<?php use PhpAi\AI; use PhpAi\Request; use PhpAi\Schema\Schema; use PhpAi\Tools\Tool; $weather = Tool::make( name: 'get_weather', description: 'Call whenever the user asks for current weather conditions in a city.', parameters: ['city' => Schema::string('City name')], handler: static fn (array $input): array => [ 'city' => $input['city'], 'celsius' => 22, 'sky' => 'clear', ], ); $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); $response = $ai->run( Request::to('claude-opus-5') ->user('What is the weather in Paris?') ->tools([$weather]), ); echo $response->text;
A throwing handler becomes an error result visible to the model. A missing handler returns pending tool calls so the caller can execute them. maxSteps() limits the number of model round trips; the default is 10.
Structured output
<?php use PhpAi\AI; use PhpAi\Schema\Schema; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); $data = $ai->structured( 'claude-opus-5', 'Extract Jane Doe (jane@example.com) upgraded to Enterprise.', Schema::object([ 'name' => Schema::string('Full name'), 'email' => Schema::string()->format('email'), 'plan' => Schema::enum(['Free', 'Pro', 'Enterprise']), 'notes' => Schema::string()->optional(), ]), );
The library selects the strongest available strategy from the model catalog:
| Strategy | Description |
|---|---|
| Native | Provider-native schema-constrained output when supported |
| Tool | A synthetic tool whose input schema is the requested schema |
| JSON | Prompted JSON with tolerant parsing as a final fallback |
Use Request::structuredMode() to force a strategy. Schema properties are required by default; use optional() or nullable() as appropriate.
Conversations and media
Requests are immutable, so append the previous assistant message to continue a conversation:
<?php use PhpAi\AI; use PhpAi\Request; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY']); $request = Request::to('claude-opus-5')->user('My name is Alice.'); $response = $ai->run($request); $response = $ai->run( $request ->message($response->toMessage()) ->user('What is my name?'), );
Images and documents can be attached as content parts:
<?php use PhpAi\Messages\Content\DocumentPart; use PhpAi\Messages\Content\ImagePart; use PhpAi\Messages\Content\TextPart; use PhpAi\Messages\UserMessage; use PhpAi\Request; $request = Request::to('claude-opus-5')->message( UserMessage::withImages('What is in these images?', [ 'https://example.com/chart.png', '/local/path/photo.jpg', 'data:image/png;base64,...', ]), ); $request = Request::to('claude-opus-5')->message( new UserMessage([ new TextPart('Summarise this contract.'), DocumentPart::fromPath('/path/contract.pdf')->withCitations(), ]), );
Remote image URLs are supported by Anthropic and OpenAI. Gemini image input uses bytes; use ImagePart::fromPath() or ImagePart::fromBase64() for Gemini requests. The UserMessage::withImages() helper accepts image sources and converts them into image parts.
Embeddings
OpenAI and Gemini provide embeddings through the unified API:
<?php use PhpAi\AI; $ai = AI::openai($_ENV['OPENAI_API_KEY']); $vectors = $ai->embed('text-embedding-3-small', ['hello', 'world']);
Embedding calls pass through configured middleware, including retry, failover, logging, and cache middleware. Anthropic does not provide embeddings.
Middleware
Middleware factories are applied in listed order; the first middleware is outermost. They apply consistently to generation, streaming, and embeddings:
<?php use PhpAi\AI; use PhpAi\Middleware\CacheMiddleware; use PhpAi\Middleware\LoggingMiddleware; use PhpAi\Middleware\RetryMiddleware; $ai = AI::anthropic($_ENV['ANTHROPIC_API_KEY'])->with( static fn ($provider) => RetryMiddleware::wrap($provider, maxAttempts: 3), static fn ($provider) => CacheMiddleware::wrap($provider, $psr16Cache, ttlSeconds: 60), static fn ($provider) => LoggingMiddleware::wrap($provider, $psr3Logger), );
Available middleware:
| Middleware | Behavior |
|---|---|
RetryMiddleware |
Retries retryable API failures with exponential backoff and jitter |
LoggingMiddleware |
Logs provider operations through PSR-3 |
CacheMiddleware |
Caches unary generation and embedding results through PSR-16 |
FailoverMiddleware |
Uses the next provider for retryable failures; never restarts a partial stream |
Provider-specific fields can be passed with Request::withProviderOptions().
Laravel integration
The Laravel service provider is registered automatically through Composer package discovery.
Configuration
composer require unknowingpro/php-ai php artisan vendor:publish --tag=php-ai-config
Configure credentials and defaults in .env:
PHP_AI_PROVIDER=openai PHP_AI_MODEL=gpt-4o-mini OPENAI_API_KEY=your-key OPENAI_MODEL=gpt-4o-mini ANTHROPIC_API_KEY=your-key GEMINI_API_KEY=your-key PHP_AI_RETRY_ENABLED=true PHP_AI_CACHE_ENABLED=true
The published config/php-ai.php file supports provider keys and URLs, default provider/model, timeout, connect timeout, stream idle timeout, proxy, TLS verification, custom headers, retry settings, cache settings, failover providers, and custom middleware. Configuration is loaded with mergeConfigFrom() and works with php artisan config:cache.
Retry, cache, failover, and custom middleware are applied in the listed order, with the first configured middleware outermost. Failover providers are named entries in php-ai.providers:
'failover' => [ 'enabled' => true, 'providers' => ['openai', 'anthropic'], ],
Custom middleware entries must be middleware class names resolvable by Laravel or closures returning a PhpAi\Contracts\Middleware. Invalid entries produce a clear configuration exception during manager construction.
Container and facade
The configured PhpAi\AI manager is bound as a singleton and is also available as php-ai. The facade resolves that same instance:
<?php use PhpAi\Laravel\Facades\AI; use PhpAi\Request; $response = AI::run( Request::to(config('php-ai.providers.openai.model', 'gpt-4o-mini')) ->user('Summarise this text.'), );
You can also inject PhpAi\AI into controllers, jobs, and commands. The package includes Laravel integration tests using Orchestra Testbench; applications can use PhpAi\Testing\FakeProvider for isolated tests.
The core package also supports manual construction through AI::anthropic(), AI::openai(), and the other static factories. Manual registration is only needed when you intentionally do not use the package's Laravel service provider.
See the dedicated Laravel guide for more examples.
Errors
All library exceptions extend PhpAi\Exceptions\AiException. API failures are classified as authentication, permission, not-found, request, rate-limit, overloaded, server, or transport exceptions. Retry only retryable failures and inspect normalized Response::$finishReason values such as Stop, ToolCalls, Length, Refusal, Pause, and Unknown.
Testing
The package includes FakeProvider, ResponseFactory, and StreamFactory for tests without network calls. The assertion helpers receive a PHPUnit TestCase instance:
<?php use PhpAi\AI; use PhpAi\Request; use PhpAi\Testing\FakeProvider; use PhpAi\Testing\ResponseFactory; $provider = FakeProvider::make() ->willReturn(ResponseFactory::text('Hello')) ->willReturn(ResponseFactory::text('Again')); $ai = AI::using($provider); $response = $ai->generate(Request::to('model')->user('Hi')); $provider->assertCalledTimes($this, 1);
The fake provider has independent generation, streaming, and embedding exception queues. It records requests and provides deterministic queued responses, stream events, and vectors.
Provider guides
Verification
The current repository verification passed with the documented Docker workflow. See CHANGELOG.md for the initial 1.0.0 release notes:
./bin/php-docker composer validate ./bin/php-docker composer install --no-interaction ./bin/php-docker vendor/bin/pint --test ./bin/php-docker vendor/bin/phpstan analyse --memory-limit=1G ./bin/php-docker vendor/bin/phpunit --no-coverage
Results:
- Composer validation: passed
- Composer install: passed
- Pint: passed
- PHPStan: passed
- PHPUnit: passed — 385 tests, 867 assertions
Laravel 12 was exercised in the verification environment through Orchestra Testbench. The GitHub Actions matrix runs Laravel 10, 11, and 12 independently; the first release should be considered fully multi-version verified only after those CI jobs pass.
Development
The Docker helper runs the project toolchain without requiring local PHP:
./bin/php-docker composer install ./bin/php-docker vendor/bin/phpunit --no-coverage ./bin/php-docker vendor/bin/phpstan analyse --memory-limit=1G ./bin/php-docker vendor/bin/pint --test
License
This project is open-sourced under the MIT License.