Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Open Responses Platform Laravel Package

symfony/ai-open-responses-platform

Integrates Open Responses into Symfony AI Platform, letting you use the Open Responses specification and OpenAI Responses API contract through Symfony's unified AI components. Includes documentation links and standard Symfony contribution resources for reporting issues and submitting pull requests.

View on GitHub
Deep Wiki
Context7

Getting Started

This isn't a Laravel package — it's a Symfony AI bridge for the Open Responses spec. You're pulling a piece of symfony/ai-platform into a Laravel app, so treat it as a low-level SDK you wire up yourself rather than a turnkey composer require experience.

Minimal steps:

composer require symfony/ai-open-responses-platform:0.14.* symfony/ai-platform:^0.14 symfony/http-client:^7.3
  • Confirm PHP 8.2+ and that symfony/http-client isn't shadowed by an old Guzzle pin.
  • There is no service provider, facade, or config/ to publish. You'll bind things in AppServiceProvider.
  • There is no default HTTP client wiring — you must inject a Symfony\Component\HttpClient\HttpClientInterface instance yourself.
  • Pin the bridge to an exact minor (0.14.*), not a caret. It bumped five times between 0.8 and 0.14 and has shipped breaking changes inside that range.

Where to look first:

  1. OpenResponsesProvider — the top-level entry point you'll call from your app.
  2. ModelClient — the low-level transport; it owns the endpoint URL and serialization.
  3. DeltaInterface (from v0.7.0) — the typed streaming chunks you'll iterate over.
  4. The async/batch job classes (new in v0.14.0, #2556) — the entry point for anything that shouldn't block a request.
  5. TokenUsage — now carries a model property (v0.14.0, #1566), which is the key to any cost accounting.
  6. The Open Responses spec — because the bridge does not shield you from response shape; your parsing code assumes the spec.

First use case: point it at an OpenAI-compatible /v1/responses endpoint (Ollama, LM Studio, vLLM, or the real OpenAI Responses API) and get a single non-streaming completion back as a typed object. That's the smallest end-to-end loop.

// app/Services/AiService.php
use Symfony\AI\OpenResponses\Client\ModelClient;
use Symfony\AI\OpenResponses\Provider\OpenResponsesProvider;

$provider = new OpenResponsesProvider(
    new ModelClient(
        endpoint: config('services.openresponses.url'),
        httpClient: app(\Symfony\Component\HttpClient\HttpClientInterface::class),
    ),
);

$response = $provider->complete('Summarize today\'s orders.');

Wire that in AppServiceProvider as a singleton so you're not rebuilding clients per request.

Implementation Patterns

1. Bind provider + client in the container (one place, configurable endpoint).

// app/Providers/AppServiceProvider.php
$this->app->singleton(ModelClient::class, fn () => new ModelClient(
    endpoint: config('services.openresponses.url'),
    apiKey: config('services.openresponses.key'),
    httpClient: $this->app->make(\Symfony\Component\HttpClient\HttpClientInterface::class),
));

$this->app->singleton(OpenResponsesProvider::class, fn ($app) =>
    new OpenResponsesProvider($app->make(ModelClient::class))
);

Add services.openresponses.* entries in config/services.php — this is the only "Laravel-native" thing you get, and it's worth setting up immediately so local/staging/prod share one config path.

2. Route by feature, not by provider name. Define a thin LlmGateway interface in your app that returns an OpenResponsesProvider, and resolve a different one per feature flag:

interface LlmGateway { public function provider(): OpenResponsesProvider; }
// Binding: chat → OpenAI-backed, batch-summaries → self-hosted vLLM.

Keep the branch out of your controllers; the abstraction only pays for itself if callers don't know which provider answered.

3. Streaming into a Laravel response. Use StreamedResponse and pull chunks off the provider's delta iterator. Do not return a plain JsonResponse and expect chunks — you'll buffer everything and defeat the purpose.

return response()->stream(function () use ($provider, $prompt) {
    foreach ($provider->stream($prompt) as $delta) {
        echo 'data: ' . json_encode([
            'type' => $delta->type,       // semantic, per DeltaInterface
            'text' => $delta->content,
        ]) . "\n\n";
        @ob_flush(); @flush();
    }
    echo "data: [DONE]\n\n";
}, 200, [
    'Content-Type'  => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

X-Accel-Buffering: no matters behind nginx; without it you'll see "streaming works locally, not in staging."

4. Batch requests belong in the queue, not in the request cycle (new in v0.14.0). The batch support added in #2556 is built on asynchronous jobs: you submit, you poll. That maps almost 1:1 onto Laravel's queue, and you should lean into it rather than fighting it.

  • A SubmitBatchJob that builds the request, calls the bridge, and persists the returned job/batch identifier plus your own correlation key (tenant id, export id) in a ai_batches table.
  • A scheduled PollBatchJob (every 1–5 minutes, not seconds) that checks status, stores results, and fans out follow-up work. Guard it with WithoutOverlapping.
  • Never poll from a web request. Batch latency is measured in minutes to hours; a request-scoped poll loop is how you get 504s.

Batch is where the cost savings live — treat "can this be async?" as the first design question for any non-interactive AI feature.

5. Hosted web search replay across turns (new in v0.14.0, #2492). If the model performed a hosted web search on turn one, the bridge can now replay those results on later turns instead of re-running (and re-billing) the search. The catch: you must carry the turn state. Persist the response/conversation identifier from each turn and pass it back on the next request.

// ai_conversations: id, user_id, remote_response_id, updated_at
$previous = $conversation->remote_response_id;

$response = $provider->complete($prompt, previousResponseId: $previous);

$conversation->update(['remote_response_id' => $response->id]);

This is genuinely valuable for agentic chat, but it means your AI feature now has durable state in your database that mirrors state on the provider's side. Plan for drift: when someone deletes a conversation locally, the remote chain still exists, and replaying a stale id will fail in ways the spec — not the bridge — defines.

6. Tool calls. Tool-call serialization is reliable post-#1690, and v0.14.0's #2504 fixed forward-slash escaping inside tool arguments (important the moment a tool argument is a URL or file path). Tools are still declared on your side. Wrap registration in a small resolver so DI-friendly discovery works from Laravel:

$provider->withTools([
    new LookupOrderTool(app(OrderRepository::class)),
    new FetchUrlTool(app(HttpFetcher::class)),
]);

Keep tool classes free of framework dependencies (inject via constructor at the boundary, not inside the tool) — they need to be cheap to instantiate because the bridge may rebuild them per turn.

7. Reasoning streams (Anthropic-style ThinkingStart). Added in v0.7.0. If you're rendering to end users, filter them at the boundary — expose only content deltas to the frontend, and route thinking deltas into a separate channel (or drop them) unless you're building a developer console.

8. Meter tokens with the model property, not just the counts. TokenUsage now includes the model that produced the usage (#1566), which is the piece that was missing for cost attribution. Record usage rows keyed by (feature, model, user_id) at the point of response, not in an aggregate job later:

AiUsage::create([
    'feature'        => 'support_chat',
    'model'          => $response->usage->model,
    'input_tokens'   => $response->usage->inputTokens,
    'output_tokens'  => $response->usage->outputTokens,
    'web_searches'   => $response->usage->webSearchCalls ?? 0,
]);

The model property matters specifically because your LlmGateway fans out to different models. Aggregating by feature alone silently averages across price tiers.

9. Test with a fake HTTP client, not a fake provider. The bridge has almost no logic of its own; the value is serialization. Use Symfony\Component\HttpClient\MockHttpClient inside OpenResponsesProvider so you're asserting on the request shape your code sends:

$mock = new MockHttpClient(new MockResponse($fixture));
$provider = new OpenResponsesProvider(new ModelClient(httpClient: $mock));

Fixture-based tests against the Open Responses spec catch breakage from upstream far earlier than integration tests against a live model. Add fixtures for a batch submission, a replay-across-turns request, and a tool call whose arguments contain a URL — those are the three v0.14.0 surface areas most likely to regress.

Gotchas and Tips

Version pinning is the biggest risk. This is a pre-1.0 package that shipped six minor releases in the span the assessment has existed, with breaking changes inside them (v0.7.0 replaced untyped stream chunks wholesale). Pin exact versions in composer.json, keep symfony/ai-platform aligned to what the bridge declares, and add a composer.lock diff check to CI. Do not rely on ^0.9 — or ^0.14 — resolving to a safe update.

ModelClient throws InvalidArgumentException on a string payload (v0.6.0). If you're used to openai-php where $client->chat()->create(['messages' => [...]]) accepts an array, here you'll build a DTO. Read ModelClient's signature before guessing — the error message is not always obvious about which parameter it wants.

Batch requests invert your error handling. With a synchronous call, failure is an exception you catch. With a batch job, failure is a status you poll for — sometimes hours later, sometimes per-item inside an otherwise-successful batch. Design the result table so a partially-failed batch is representable, and don't let a per-item failure roll back the whole submission.

Stateful replay is a distributed-state problem dressed up as a convenience. Hosted web search replay (#2492) and any future server-side turn state mean deleting a row in ai_conversations doesn't delete anything on the provider. Build an explicit "start a new conversation" path that omits the previous response id, and don't assume a stale id degrades gracefully — test it.

Tool-argument escaping changed in v0.14.0. #2504 keeps forward slashes unescaped in tool call arguments. If you added a str_replace('\/', '/', ...) or similar workaround against escaped slashes, remove it — otherwise you'll now be double-processing already-correct URLs. Grep your tool handlers before upgrading.

No retry, no backoff, no circuit breaker ships with the bridge. Either wrap calls in Symfony HttpClient's ->retry() decorators, or (simpler in Laravel) put a RetryableJob around anything non-streaming. Batch polling should likewise have a maximum attempt count and a dead-letter state — an infinite reschedule is a very expensive way to discover the provider is down. Streaming calls can't be retried mid-stream — you must handle partial output in your UI.

Buffering is silent and total. If you stream and don't see chunks, check three things, in order: ob_get_level() in the controller, output_buffering in php.ini, and the reverse proxy in front of you. This is the #1 "it doesn't work" report for any SSE-through-PHP setup.

No Http::fake() equivalent for this. Laravel's Http facade doesn't intercept Symfony HttpClient calls. You must inject MockHttpClient explicitly — which means your provider must be resolvable through the container in tests. Bind it as a singleton (see pattern 1) rather than new-ing it in controllers, or testing becomes miserable.

Logging: ModelClient's traffic goes through Symfony HttpClient, not Laravel's Log facade. Add a LoggerInterface decorator (PSR-3) around the client and log request.body / response.status — but redact the Authorization header yourself; the bridge won't.

Extension points are class-based, not config-based. The way to add a custom transport is to implement the provider interface and substitute it via the container binding. There's no "config array of adapters" you can append to. That's a Symfony habit bleeding through: wiring is composition, not configuration.

Per-model usage doesn't mean per-model pricing. TokenUsage::model gives you the identifier the endpoint reported, which for self-hosted vLLM/Ollama is whatever string that server was started with. Keep a price table keyed on your own model aliases and map to it explicitly; don't array_key_exists against raw provider strings.

Cost is now measurable — so measure the right unit. Web search replay, reasoning streams, and tool-call turns all inflate token counts more than a casual complete() loop suggests, and hosted searches are billed per call rather than per token. Meter by feature and model, not per LlmGateway call: a "cheap" model doing five tool turns plus a web search can exceed one premium completion.

Hosted web search is not the same as a web-search tool you write. Replay only applies to searches the provider executed. If you registered a FetchUrlTool yourself, #2492 does nothing for you and you're still paying for the fetch on every turn. Know which of the two you're using before attributing savings.

Expect to read the source, not the docs. README is ~10 lines; release notes are the real changelog, and v0.14.0's most consequential line (#2556) is four words long. vendor/symfony/ai-open-responses-platform/src/ is still small enough to read in one sitting — do that before building anything non-trivial. The abstraction surface is narrower than the pitch implies, and you'll save a day of guessing by confirming exactly which DTOs, properties, and methods exist rather than assuming from openai-php muscle memory — especially now that TokenUsage and the batch APIs have grown.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings