Para fazer streaming de respostas do Claude no Laravel, chame a Messages API da Anthropic com "stream": true usando o Http:: com a opção stream do Guzzle. Depois leia os eventos SSE à medida que chegam e repasse cada pedaço de texto ao navegador por um response()->stream() com Content-Type: text/event-stream. O usuário começa a ler em menos de um segundo, mesmo que a resposta inteira leve vinte, e você grava o texto completo e os tokens no banco ao final.
No fim deste tutorial você vai ter:
- um cliente PHP que lê o stream do Claude e devolve cada evento por um
Generator; - um comando Artisan para testar o stream no terminal;
- um endpoint que repassa os tokens ao navegador e salva a resposta completa, inclusive quando o usuário fecha a aba no meio;
- um front-end com
fetchque renderiza o texto conforme chega; - a configuração de nginx e PHP-FPM para o buffering não acabar com o efeito.
Pré-requisitos: PHP 8.2+, Laravel 11 ou 12, uma chave de API da Anthropic e um servidor com nginx e PHP-FPM, que é o cenário mais comum. Não é preciso instalar nenhum pacote: o HTTP client do Laravel já traz o Guzzle.
Quando vale a pena usar streaming em vez de esperar a resposta inteira?
Streaming é a técnica de enviar a resposta do modelo em pedaços (tokens ou grupos de tokens) conforme ele gera, em vez de esperar o texto completo. Vale a pena quando existe uma pessoa olhando para a tela: chat de suporte, assistente dentro do painel, explicação de uma cobrança. Não vale a pena quando o consumidor é outro sistema.
Se a resposta vai para o WhatsApp, para um campo no banco ou para uma extração estruturada, o caminho certo é um job na fila, e eu já escrevi sobre isso em como chamar LLMs no Laravel com filas, retries e timeouts. O WhatsApp não mostra mensagem parcial, então o streaming ali só adiciona complexidade.
| Cenário | Melhor abordagem |
|---|---|
| Chat no navegador ou app | Streaming via SSE |
| Resposta enviada por WhatsApp ou e-mail | Job na fila, resposta completa |
| Extração de dados em JSON | Requisição normal ou Batch API |
| Respostas longas em painel interno | Streaming via SSE |
SSE (Server-Sent Events) é um padrão HTTP em que o servidor mantém a conexão aberta e envia eventos de texto no formato event: ... / data: ..., separados por uma linha em branco. É mais simples que WebSocket porque a comunicação é de mão única e roda sobre HTTP comum.
1. Configurar a chave e o modelo do Claude no .env
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5
Confira na documentação da Anthropic o nome do modelo que você quer usar. Os modelos mudam e o nome precisa ser exato.
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), 'version' => '2023-06-01', ],
O version vai no header anthropic-version, que é obrigatório em toda chamada à API.
2. Criar o cliente que lê o stream do Claude
Com "stream": true, a Messages API devolve uma sequência de eventos SSE. Os que importam são estes:
message_start: traz ousage.input_tokens.content_block_delta: traz o texto emdelta.textquandodelta.typeétext_delta.message_delta: traz ostop_reasone ousage.output_tokensacumulado.message_stop: indica o fim.pingeerror: o primeiro é só keep-alive; o segundo pode chegar no meio do stream, por exemplo quando a API está sobrecarregada.
Cada evento tem uma linha data: com um JSON que já contém o campo type, então não precisamos ler a linha event:.
<?php namespace App\Services\Ai; use Generator; use Illuminate\Support\Facades\Http; use RuntimeException; class ClaudeStream { /** * @param array<int, array{role: string, content: string}> $messages * @return Generator<int, array> */ public function stream(string $system, array $messages, int $maxTokens = 1024): Generator { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => config('services.anthropic.version'), ]) ->accept('text/event-stream') ->connectTimeout(5) ->timeout(120) ->withOptions(['stream' => true]) ->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => $maxTokens, 'system' => $system, 'messages' => $messages, 'stream' => true, ]); if ($response->failed()) { throw new RuntimeException( 'Claude respondeu '.$response->status().': '.$response->body() ); } $body = $response->toPsrResponse()->getBody(); $buffer = ''; while (! $body->eof()) { $buffer .= str_replace("\r\n", "\n", $body->read(1024)); while (($pos = strpos($buffer, "\n\n")) !== false) { $raw = substr($buffer, 0, $pos); $buffer = substr($buffer, $pos + 2); if (($event = $this->parse($raw)) !== null) { yield $event; } } } } private function parse(string $raw): ?array { foreach (explode("\n", $raw) as $line) { if (str_starts_with($line, 'data:')) { $decoded = json_decode(trim(substr($line, 5)), true); return is_array($decoded) ? $decoded : null; } } return null; } }
Vale explicar três decisões aqui:
withOptions(['stream' => true]): sem essa opção, o Guzzle baixa o corpo inteiro antes de devolver, e o streaming deixa de existir.- Buffer até
\n\n: umread(1024)pode cortar um evento ao meio. Só faça o parse depois de achar a linha em branco que fecha o evento. timeout(120): no Guzzle,timeouté o tempo total da transferência, não o tempo de inatividade. Ajuste conforme omax_tokens. Uma resposta longa pode passar de um minuto.
3. Testar o stream no terminal com um comando Artisan
Antes de envolver navegador e nginx, confirme que o stream funciona. Em routes/console.php:
use App\Services\Ai\ClaudeStream; use Illuminate\Support\Facades\Artisan; Artisan::command('claude:stream {pergunta}', function (ClaudeStream $claude) { $events = $claude->stream( 'Responda em português do Brasil, de forma objetiva.', [['role' => 'user', 'content' => $this->argument('pergunta')]] ); foreach ($events as $event) { if (($event['type'] ?? null) === 'content_block_delta' && ($event['delta']['type'] ?? null) === 'text_delta') { $this->output->write($event['delta']['text']); } } $this->newLine(); });
php artisan claude:stream "Explique o que é conciliação bancária em 5 linhas"
Se o texto aparece aos poucos, o cliente está certo. Se aparece tudo de uma vez, falta a opção stream ou há um proxy no meio fazendo buffering.
4. Criar o endpoint SSE no Laravel
As mensagens ficam numa tabela messages com as colunas conversation_id, role, content, input_tokens, output_tokens, stop_reason e status. O controller:
<?php namespace App\Http\Controllers; use App\Models\Conversation; use App\Services\Ai\ClaudeStream; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; class ChatStreamController extends Controller { public function __invoke(Request $request, Conversation $conversation, ClaudeStream $claude): StreamedResponse { // Tudo que pode falhar com 4xx acontece ANTES do stream começar. $this->authorize('view', $conversation); $data = $request->validate(['message' => ['required', 'string', 'max:4000']]); $conversation->messages()->create(['role' => 'user', 'content' => $data['message']]); $history = $conversation->messages() ->where('status', '!=', 'failed') ->latest('id')->take(20)->get()->reverse() ->map(fn ($m) => ['role' => $m->role, 'content' => $m->content]) ->values()->all(); return response()->stream(function () use ($claude, $conversation, $history) { ignore_user_abort(true); set_time_limit(150); $text = ''; $usage = ['input_tokens' => 0, 'output_tokens' => 0]; $stopReason = null; $status = 'complete'; try { $events = $claude->stream('Você é o assistente do painel. Responda em português.', $history); foreach ($events as $event) { if (connection_aborted()) { $status = 'aborted'; break; } match ($event['type'] ?? null) { 'message_start' => $usage['input_tokens'] = $event['message']['usage']['input_tokens'] ?? 0, 'message_delta' => [ $usage['output_tokens'] = $event['usage']['output_tokens'] ?? 0, $stopReason = $event['delta']['stop_reason'] ?? null, ], 'error' => throw new \RuntimeException($event['error']['message'] ?? 'Erro no stream'), default => null, }; if (($event['type'] ?? null) === 'content_block_delta' && ($event['delta']['type'] ?? null) === 'text_delta') { $text .= $event['delta']['text']; $this->send('delta', ['text' => $event['delta']['text']]); } } } catch (Throwable $e) { report($e); $status = 'failed'; $this->send('error', ['message' => 'Não consegui terminar a resposta. Tente de novo.']); } finally { if ($text !== '') { $conversation->messages()->create([ 'role' => 'assistant', 'content' => $text, 'input_tokens' => $usage['input_tokens'], 'output_tokens' => $usage['output_tokens'], 'stop_reason' => $stopReason, 'status' => $status, ]); } } $this->send('done', ['status' => $status]); }, 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]); } private function send(string $event, array $data): void { echo "event: {$event}\n"; echo 'data: '.json_encode($data, JSON_UNESCAPED_UNICODE)."\n\n"; if (ob_get_level() > 0) { ob_flush(); } flush(); } }
Rota em routes/web.php:
use App\Http\Controllers\ChatStreamController; Route::post('/conversations/{conversation}/stream', ChatStreamController::class) ->middleware('auth');
As decisões que mais pesam neste passo:
- Validar e autorizar antes do stream. Depois que o primeiro byte sai, o status 200 já foi enviado. Não dá para devolver 422 no meio de um stream.
ignore_user_abort(true)econnection_aborted(). Sem o primeiro, o PHP mata o script quando o usuário fecha a aba, e ofinallypode nem rodar. Com ele, você detecta o abort, para de consumir o Claude e salva o texto parcial comstatus = aborted. Interromper a leitura também corta a geração, então você para de pagar por tokens que ninguém vai ler.- Salvar no
finally. O texto que o usuário viu tem que ser o mesmo que está no banco. Caso contrário, o histórico da próxima pergunta fica diferente do que ele leu. - O histórico precisa começar com
user. A API exige que a primeira mensagem seja do usuário. Se o corte de 20 mensagens cair numa resposta do assistente, descarte essa primeira mensagem antes de enviar.
5. Consumir o stream no front-end com fetch
EventSource, a API nativa de SSE do navegador, só faz GET e não envia corpo. Como a mensagem vai no POST, use fetch e leia o ReadableStream:
export async function perguntar(conversationId, message, onDelta) { const res = await fetch(`/conversations/${conversationId}/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, body: JSON.stringify({ message }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let pos; while ((pos = buffer.indexOf('\n\n')) !== -1) { const raw = buffer.slice(0, pos); buffer = buffer.slice(pos + 2); const event = raw.match(/^event: (.*)$/m)?.[1]; const data = raw.match(/^data: (.*)$/m)?.[1]; if (!data) continue; const payload = JSON.parse(data); if (event === 'delta') onDelta(payload.text); if (event === 'error') throw new Error(payload.message); } } }
O Accept: application/json é proposital: se a validação falhar, o Laravel devolve 422 em JSON em vez de um redirect 302, que o fetch seguiria em silêncio.
Renderize o texto como texto puro ou passe por um sanitizador de Markdown. O que o modelo devolve é entrada não confiável, pelos mesmos motivos que discuti em prompt injection e LGPD em agentes de IA.
6. Ajustar nginx e PHP-FPM para não bufferizar o SSE
O header X-Accel-Buffering: no já desliga o buffering do nginx para essa resposta. Se houver gzip ou outro proxy na frente, ajuste também:
location ~ \.php$ { # ... fastcgi_pass e includes de sempre fastcgi_read_timeout 180s; } # gzip não deve comprimir SSE: ele acumula bytes antes de enviar gzip_types text/plain text/css application/json application/javascript;
No php.ini ou no pool do FPM, confira se output_buffering não está com um valor alto que segure tudo. O ob_flush() do controller cobre o caso comum, mas extensões como zlib com zlib.output_compression = On também acumulam saída.
O trade-off que mais pesa em produção: cada stream ocupa um worker do PHP-FPM durante toda a resposta. Com pm.max_children = 20 e respostas de 15 segundos, vinte usuários conversando ao mesmo tempo travam o resto do painel. Há três saídas:
- Um pool FPM separado só para as rotas de chat.
- Laravel Octane com FrankenPHP ou Swoole.
- Um serviço pequeno em Node.js só para o streaming, que lê do mesmo banco.
Eu começaria pela primeira opção, que é a de menor esforço.
7. Escrever um teste automatizado para o parser
O Http::fake funciona com a opção stream, porque o corpo falso também é um stream PSR. Assim dá para testar o parser sem gastar tokens:
<?php namespace Tests\Feature; use App\Services\Ai\ClaudeStream; use Illuminate\Support\Facades\Http; use Tests\TestCase; class ClaudeStreamTest extends TestCase { public function test_le_eventos_sse_do_claude(): void { $sse = collect([ ['type' => 'message_start', 'message' => ['usage' => ['input_tokens' => 12]]], ['type' => 'content_block_delta', 'index' => 0, 'delta' => ['type' => 'text_delta', 'text' => 'Olá']], ['type' => 'message_delta', 'delta' => ['stop_reason' => 'end_turn'], 'usage' => ['output_tokens' => 3]], ['type' => 'message_stop'], ])->map(fn ($e) => "event: {$e['type']}\ndata: ".json_encode($e, JSON_UNESCAPED_UNICODE)."\n\n") ->implode(''); Http::fake([ 'api.anthropic.com/*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']), ]); $events = iterator_to_array( app(ClaudeStream::class)->stream('sys', [['role' => 'user', 'content' => 'oi']]), false ); $this->assertSame( ['message_start', 'content_block_delta', 'message_delta', 'message_stop'], array_column($events, 'type') ); $this->assertSame('Olá', $events[1]['delta']['text']); } }
Para cobrir o restante (respostas gravadas, evals de qualidade), o roteiro está em como testar código que usa LLM no Laravel.
Erros comuns
- O texto aparece todo de uma vez no navegador. Quase sempre é buffering: falta
X-Accel-Buffering: no, o gzip está comprimindotext/event-streamou o Cloudflare ou outro proxy está segurando a resposta. Teste comcurl -Ndireto no servidor, sem passar pelo proxy, para descobrir em qual camada está o problema. - Funciona no terminal e não funciona na rota. Algum middleware está lendo o conteúdo da resposta. O Laravel Debugbar e middlewares de compressão ou minificação de HTML são os suspeitos de sempre. Tire esses middlewares da rota de stream.
- Erro 400 da API com o histórico. A primeira mensagem precisa ter
roleigual auser, e ocontentnão pode estar vazio. Mensagens abortadas sem texto não devem entrar no histórico. - O stream para no meio sem erro. Pode ser o
timeoutdo Guzzle, ofastcgi_read_timeoutdo nginx ou omax_execution_timedo PHP. Os três precisam ser maiores que a resposta mais longa que você aceita. Verifique também ostop_reason: se viermax_tokens, o modelo bateu no limite e não houve falha de rede. - Validação devolve 302 e o
fetchrecebe HTML. Falta oAccept: application/jsonna requisição. - Respostas cobradas sem ninguém lendo. Sem
ignore_user_abort(true)econnection_aborted(), o comportamento ao fechar a aba fica imprevisível. Registreinput_tokenseoutput_tokenspor mensagem para enxergar esse custo.
Perguntas frequentes
Preciso de WebSocket ou Laravel Reverb para fazer streaming de LLM?
Não. SSE sobre HTTP comum resolve, porque o fluxo é de mão única, do servidor para o navegador. Reverb faz sentido quando outros usuários precisam receber a mesma mensagem em tempo real.
Dá para usar o mesmo código com a OpenAI ou o Gemini?
A estrutura é a mesma: HTTP com stream, buffer até \n\n e parse do data:. O que muda é o formato de cada evento e o endpoint. Isole o parser numa classe por provedor e mantenha o controller falando só em delta, error e done.
Como contar tokens e custo de uma resposta em streaming?
Pegue input_tokens no message_start e output_tokens no message_delta, que traz o valor acumulado. Salve os dois junto com a mensagem e calcule o custo a partir deles.
Streaming funciona com Laravel Octane?
Sim. O ganho é não prender um worker do PHP-FPM por resposta. Teste o flush() no servidor escolhido (FrankenPHP, Swoole ou RoadRunner), porque cada um trata a saída de forma diferente.
Posso usar streaming dentro de um job na fila?
Pode, mas não há ninguém para receber os tokens. Só faz sentido se o job publicar os pedaços num canal de broadcast. Para respostas que vão para WhatsApp ou e-mail, peça a resposta completa, sem streaming.
Para colocar isso em produção, comece pelo comando Artisan: valida a chave e o parser em um minuto. Depois suba o endpoint num pool FPM separado com curl -N como teste de fumaça. O que mais dá dor de cabeça em streaming é o buffering de alguma camada da infraestrutura, não o código PHP, então teste cada camada isolada antes de culpar o Laravel.