Para fazer streaming das respostas do Claude no Laravel, chame a Messages API com "stream": true, leia o corpo da resposta em pedaços com o Http:: em modo stream e repasse cada text_delta ao navegador por um response()->stream() com Content-Type: text/event-stream. O que costuma dar errado não é o código, e sim a infraestrutura: buffering do PHP e do Nginx, timeouts e o usuário fechando a aba no meio da resposta. Abaixo está o passo a passo completo, com a persistência da mensagem e dos tokens no final.
Em chat de painel, a diferença entre esperar 15 segundos por um bloco de texto e ver a resposta surgindo em menos de um segundo é a diferença entre o usuário achar que travou ou não. O tempo total é o mesmo; a percepção muda completamente.
O que você vai ter funcionando no final
- Uma classe
ClaudeStreamque chama a API da Anthropic por HTTP e devolve umGeneratorcom os eventos já parseados. - Uma rota
POST /conversations/{conversation}/streamque repassa o texto ao navegador em tempo real via SSE. - A mensagem do assistente salva no PostgreSQL ao final, com
input_tokens,output_tokensestop_reason, inclusive quando o usuário fecha a aba no meio. - Um front com
fetchque lê o stream e vai montando a resposta na tela. - Testes com
Http::fakeusando um stream gravado.
Pré-requisitos:
- PHP 8.2+ e Laravel 11 ou 12.
- Uma chave da API da Anthropic (
ANTHROPIC_API_KEY), gerada no console da Anthropic. - Guzzle, que já vem com o Laravel (o
Http::usa ele por baixo). - Nginx + PHP-FPM em produção (os ajustes de buffering abaixo assumem isso).
Não uso SDK nenhum aqui. Chamar a API direto com Http:: deixa claro o que trafega e não te prende a um pacote.
O que é SSE e como o Claude manda os eventos?
Server-Sent Events (SSE) é um protocolo simples sobre HTTP em que o servidor mantém a conexão aberta e envia blocos de texto no formato event: nome + data: json, separados por uma linha em branco. A Messages API do Claude, quando recebe "stream": true, responde exatamente nesse formato. Os eventos que importam:
| Evento | O que traz | O que fazer |
|---|---|---|
message_start |
message.usage.input_tokens |
Guardar os tokens de entrada |
content_block_start |
Início de um bloco (texto ou tool_use) | Normalmente ignorar |
content_block_delta |
delta.type = text_delta com delta.text |
Repassar ao navegador e acumular |
content_block_stop |
Fim do bloco | Ignorar |
message_delta |
delta.stop_reason e usage.output_tokens (acumulado) |
Guardar motivo de parada e tokens de saída |
message_stop |
Fim da mensagem | Encerrar |
ping |
Nada | Ignorar (serve para manter a conexão viva) |
error |
error.type e error.message |
Tratar como falha, mesmo com HTTP 200 |
Esse último ponto é o que mais pega: a resposta HTTP já começou com 200, e um overloaded_error pode chegar no meio do stream. Seu código precisa tratar erro como evento, não só como status HTTP.
1. Configurar a chave e o modelo no .env
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 ANTHROPIC_MAX_TOKENS=1024
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), 'max_tokens' => (int) env('ANTHROPIC_MAX_TOKENS', 1024), ],
Antes de escrever qualquer PHP, confirme que o stream sai da API. O -N desliga o buffer do curl:
curl -N https://api.anthropic.com/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{"model":"claude-sonnet-4-5","max_tokens":256,"stream":true,"messages":[{"role":"user","content":"Explique Pix em uma frase"}]}'
Você deve ver os eventos chegando um a um. Se aparece tudo de uma vez aqui, o problema é rede ou proxy, não Laravel.
2. Criar o cliente que lê o stream da API
A classe abaixo faz a requisição com stream => true (o Guzzle não baixa o corpo inteiro antes de devolver), lê em pedaços de 1 KB e só emite um evento quando encontra a linha em branco que fecha o bloco SSE. Isso importa: um pedaço de 1 KB pode cortar um JSON no meio.
<?php namespace App\Services\Claude; use Generator; use Illuminate\Support\Facades\Http; use RuntimeException; class ClaudeStream { /** * @return Generator<int, array{type: string, data: array}> */ public function stream(string $system, array $messages): Generator { $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->accept('text/event-stream') ->connectTimeout(5) ->timeout(180) ->withOptions([ 'stream' => true, 'read_timeout' => 30, ]) ->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => config('services.anthropic.max_tokens'), 'stream' => true, 'system' => $system, 'messages' => $messages, ]); if ($response->failed()) { throw new RuntimeException( 'Claude HTTP '.$response->status().': '.$response->body() ); } $body = $response->toPsrResponse()->getBody(); $buffer = ''; while (! $body->eof()) { $buffer .= $body->read(1024); $buffer = str_replace("\r\n", "\n", $buffer); while (($pos = strpos($buffer, "\n\n")) !== false) { $raw = substr($buffer, 0, $pos); $buffer = substr($buffer, $pos + 2); if ($event = $this->parse($raw)) { yield $event; } } } } private function parse(string $raw): ?array { $type = null; $data = ''; foreach (explode("\n", $raw) as $line) { if (str_starts_with($line, 'event:')) { $type = trim(substr($line, 6)); } elseif (str_starts_with($line, 'data:')) { $data .= trim(substr($line, 5)); } } if ($type === null || $data === '') { return null; } return [ 'type' => $type, 'data' => json_decode($data, true, flags: JSON_THROW_ON_ERROR), ]; } }
Sobre os timeouts: connectTimeout(5) limita a abertura da conexão; timeout(180) é o teto da requisição inteira; read_timeout é o tempo máximo sem receber nenhum byte. Como a API manda ping periodicamente, 30 segundos de silêncio significam que algo quebrou de verdade.
O Generator é a peça que deixa o resto simples: quem consome faz um foreach e não precisa saber nada de buffer ou de parsing.
3. Criar a tabela de mensagens e os models
php artisan make:model Conversation -m php artisan make:model Message -m
Na migration de messages:
Schema::create('messages', function (Blueprint $table) { $table->id(); $table->foreignId('conversation_id')->constrained()->cascadeOnDelete(); $table->string('role'); // user | assistant $table->text('content'); $table->unsignedInteger('input_tokens')->nullable(); $table->unsignedInteger('output_tokens')->nullable(); $table->string('stop_reason')->nullable(); $table->timestamps(); });
A de conversations precisa só de id, user_id e timestamps. No model Conversation, a relação:
public function messages(): HasMany { return $this->hasMany(Message::class); }
E em Message, protected $guarded = []; ou o $fillable com as colunas acima.
Guardar os tokens de cada resposta é o que permite depois medir quanto cada cliente gasta; o post sobre como medir tokens e limitar o custo de IA por cliente no Laravel parte exatamente dessas colunas.
4. Criar a rota e o controller que repassa o SSE ao navegador
Em routes/web.php:
use App\Http\Controllers\ChatStreamController; Route::post('/conversations/{conversation}/stream', ChatStreamController::class) ->middleware('auth');
O controller valida a entrada, salva a mensagem do usuário, monta o histórico e devolve um StreamedResponse. Dentro do closure, ele consome o Generator, repassa só o texto ao navegador (o front não precisa conhecer o formato da Anthropic) e salva a resposta no fim.
<?php namespace App\Http\Controllers; use App\Models\Conversation; use App\Services\Claude\ClaudeStream; use Illuminate\Http\Request; use Throwable; class ChatStreamController extends Controller { private const SYSTEM = 'Você é o assistente do painel. Responda em português, de forma curta e objetiva.'; public function __invoke(Request $request, Conversation $conversation, ClaudeStream $claude) { abort_unless($conversation->user_id === $request->user()->id, 403); $data = $request->validate([ 'message' => ['required', 'string', 'max:4000'], ]); $conversation->messages()->create([ 'role' => 'user', 'content' => $data['message'], ]); $history = $conversation->messages() ->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); $text = ''; $inputTokens = null; $outputTokens = null; $stopReason = null; try { foreach ($claude->stream(self::SYSTEM, $history) as $event) { $d = $event['data']; match ($event['type']) { 'message_start' => $inputTokens = $d['message']['usage']['input_tokens'] ?? null, 'content_block_delta' => ($d['delta']['type'] ?? null) === 'text_delta' ? $this->send('delta', ['text' => $d['delta']['text']]) . ($text .= $d['delta']['text']) : null, 'message_delta' => [ $stopReason = $d['delta']['stop_reason'] ?? null, $outputTokens = $d['usage']['output_tokens'] ?? null, ], 'error' => throw new \RuntimeException($d['error']['message'] ?? 'stream error'), default => null, }; if (connection_aborted()) { $stopReason = 'client_disconnected'; break; } } } catch (Throwable $e) { report($e); $stopReason = 'error'; $this->send('error', ['message' => 'Não consegui terminar a resposta. Tente de novo.']); } if ($text !== '') { $message = $conversation->messages()->create([ 'role' => 'assistant', 'content' => $text, 'input_tokens' => $inputTokens, 'output_tokens' => $outputTokens, 'stop_reason' => $stopReason, ]); } $this->send('done', [ 'message_id' => $message->id ?? null, 'stop_reason' => $stopReason, ]); }, 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]); } private function send(string $event, array $data): string { echo "event: {$event}\n"; echo 'data: '.json_encode($data, JSON_UNESCAPED_UNICODE)."\n\n"; // Em testes, ob_flush vazaria o conteúdo para fora do buffer do TestResponse. if (! app()->runningUnitTests() && ob_get_level() > 0) { ob_flush(); } flush(); return ''; } }
Se o match com efeitos colaterais incomodar (incomoda um pouco), troque por switch; deixei assim para caber. O que não é negociável aqui:
ignore_user_abort(true): sem isso, quando o usuário fecha a aba, o PHP mata o script na próxima tentativa de escrita e você nunca salva a resposta nem os tokens que já pagou.connection_aborted()depois de escrever: o PHP só descobre que o cliente sumiu quando tenta enviar algo. Por isso a checagem vem depois dosend.- Só salvar se
$text !== '': a API rejeita mensagens com conteúdo vazio no histórico. Uma resposta vazia salva quebra a próxima chamada da conversa. X-Accel-Buffering: no: diz ao Nginx para não acumular a resposta antes de repassar.- Erro como evento: o
errorque chega com HTTP 200 vira exceção, cai nocatche o front recebe uma mensagem amigável.
Quando a falha é a API inteira fora do ar, e não um erro pontual no meio do stream, vale ter um plano B; detalhei isso em fallback de LLM no Laravel trocando de Claude para OpenAI. Com streaming, o fallback só faz sentido antes do primeiro delta: depois que o usuário viu metade de uma resposta, trocar de modelo no meio gera texto incoerente.
5. Consumir o stream no front com fetch
O EventSource do navegador só faz GET e não manda corpo. Como a mensagem vai no POST (e tem CSRF), uso fetch e leio o ReadableStream na mão:
export async function enviarMensagem(conversationId, mensagem, 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: mensagem }), }); 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 (!event || !data) continue; const payload = JSON.parse(data); if (event === 'delta') onDelta(payload.text); if (event === 'error') throw new Error(payload.message); if (event === 'done') return payload; } } }
O Accept: application/json é proposital: se a validação falhar, o Laravel devolve 422 em JSON em vez de um redirect, e o res.ok pega. O { stream: true } no TextDecoder evita quebrar acentos quando um caractere UTF-8 vem dividido entre dois pedaços, o que em português acontece o tempo todo.
Uso no componente:
let resposta = ''; await enviarMensagem(42, 'Quanto vendi ontem?', (t) => { resposta += t; elemento.textContent = resposta; });
Use textContent, não innerHTML. O texto vem do modelo e não é confiável; se quiser renderizar Markdown, passe por um sanitizador.
6. Testar com Http::fake e um stream gravado
Grave a saída real do curl do passo 1 em tests/Fixtures/claude-stream.txt. Um exemplo enxuto:
event: message_start
data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","content":[],"stop_reason":null,"usage":{"input_tokens":25,"output_tokens":1}}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Olá"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", tudo certo?"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":6}}
event: message_stop
data: {"type":"message_stop"}
O Http::fake devolve o corpo como um stream do Guzzle, então eof() e read() funcionam igual ao de verdade:
use App\Models\Conversation; use App\Models\User; use Illuminate\Support\Facades\Http; it('faz streaming e salva a resposta com os tokens', function () { Http::fake([ 'api.anthropic.com/*' => Http::response( file_get_contents(base_path('tests/Fixtures/claude-stream.txt')), 200, ['Content-Type' => 'text/event-stream'], ), ]); $user = User::factory()->create(); $conversation = Conversation::create(['user_id' => $user->id]); $response = $this->actingAs($user) ->post("/conversations/{$conversation->id}/stream", ['message' => 'Oi']); $content = $response->streamedContent(); expect($content)->toContain('event: delta') ->and($content)->toContain('event: done'); $this->assertDatabaseHas('messages', [ 'conversation_id' => $conversation->id, 'role' => 'assistant', 'content' => 'Olá, tudo certo?', 'input_tokens' => 25, 'output_tokens' => 6, 'stop_reason' => 'end_turn', ]); });
Vale ter um segundo fixture com um evento error no meio, para garantir que o texto parcial é salvo e o front recebe event: error. A estratégia de gravar respostas reais e reaproveitar nos testes está detalhada em como testar código que chama o Claude com Http::fake e respostas gravadas.
Erros comuns
- A resposta aparece toda de uma vez no fim. É buffering em alguma camada. Na ordem de checagem: o header
X-Accel-Buffering: noestá saindo? Ooutput_bufferingdo PHP-FPM está ligado (ophp.ini-productionvem com 4096) e você não está chamandoob_flush()? Temgzip onno Nginx aplicando compressão emtext/event-stream? Algum middleware lê o conteúdo da resposta (Debugbar, minificador de HTML)? Remova cada um até o stream voltar. - Funciona com
php artisan serve, mas trava as outras abas. O servidor embutido atende uma requisição por vez por padrão. Enquanto o stream está aberto, o resto da aplicação espera. UsePHP_CLI_SERVER_WORKERS=4no ambiente local ou rode atrás de Nginx. cURL error 28: Operation timed outem respostas longas. Otimeout()doHttp::vale para a requisição inteira, incluindo a leitura do stream. Se você deixou o padrão de 30 segundos, uma resposta longa morre no meio. Separe:timeoutalto para o total,read_timeoutcurto para silêncio.- Resposta some quando o usuário fecha a aba. Faltou
ignore_user_abort(true). O PHP encerra o script na próxima escrita e o código de persistência nunca roda. JSON_THROW_ON_ERRORestourando comSyntax error. Você está fazendojson_decodeem cada pedaço lido, não em cada bloco SSE completo. Acumule no buffer até achar\n\n.- A próxima mensagem da conversa volta 400. Provavelmente uma mensagem
assistantcomcontentvazio foi salva depois de um erro, ou duas mensagensuserseguidas ficaram no histórico. Não salve texto vazio e, ao montar o histórico, garanta a alternância de papéis. - Workers do PHP-FPM esgotados com poucos usuários. Cada stream segura um worker pelo tempo inteiro da geração, que pode passar de 20 segundos. Se o
pm.max_childrené 10, dez conversas simultâneas param o site. Dimensione o pool pensando nisso, separe um pool para as rotas de chat ou avalie Octane com FrankenPHP.
Quando não vale a pena usar streaming?
Streaming resolve percepção de latência para um humano olhando a tela. Fora disso, é complexidade sem retorno:
- Saída estruturada em JSON: você só consegue validar quando o JSON termina. Mostrar meio objeto ao usuário não ajuda.
- Jobs em fila e processamento em lote: ninguém está olhando. Chamada normal, com retry.
- WhatsApp: a API do WhatsApp não tem como editar uma mensagem token a token. Num agente de atendimento, você espera a resposta completa e envia de uma vez.
- Agentes com tool calling: dá para fazer streaming, mas os blocos
tool_usechegam comoinput_json_deltaparcial e o loop fica bem mais complexo. Normalmente faço streaming só da resposta final ao usuário.
O custo em tokens é o mesmo com ou sem streaming; se quiser estimar o gasto mensal do chat por modelo, a calculadora de custo de IA ajuda a decidir entre Sonnet e Haiku antes de colocar no ar.
Conclusão prática
Comece pelo curl com -N: se o stream não sai limpo dali, nada no Laravel vai resolver. Depois implemente o Generator isolado e teste com um fixture gravado antes de tocar no controller. No controller, as três linhas que salvam você em produção são ignore_user_abort(true), o header X-Accel-Buffering: no e a persistência no fim do closure, fora do try. E antes de subir, faça a conta de quantos workers do PHP-FPM ficam presos com o pico de conversas simultâneas que você espera, porque esse é o gargalo que aparece primeiro.
Perguntas frequentes
Preciso de um SDK para fazer streaming do Claude no Laravel?
Não. O Http:: do Laravel com withOptions(['stream' => true]) e a leitura do corpo via toPsrResponse()->getBody() resolvem. Você controla o parsing e os timeouts sem dependência extra.
Posso usar EventSource em vez de fetch?
Só se a rota for GET. O EventSource não envia corpo nem headers customizados. Uma alternativa é fazer POST para criar a mensagem e abrir um GET de stream com o id dela, mas isso exige guardar o estado entre as duas requisições.
Streaming deixa a resposta mais rápida?
O tempo total de geração é praticamente o mesmo. O que muda é o tempo até o primeiro texto aparecer, que cai de segundos para menos de um segundo na maioria dos casos.
Como saber quantos tokens a resposta usou no streaming?
Os tokens de entrada vêm em message_start (message.usage.input_tokens) e os de saída em message_delta (usage.output_tokens, já acumulado). Guarde o último valor recebido de cada um.
O streaming funciona com Laravel Octane?
Funciona, e ajuda na concorrência, mas exige cuidado com estado compartilhado entre requisições e com o servidor escolhido. Teste o buffering de novo depois de migrar, porque a camada que entrega a resposta muda.