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 ClaudeStream que chama a API da Anthropic por HTTP e devolve um Generator com os eventos já parseados.
  • Uma rota POST /conversations/{conversation}/stream que repassa o texto ao navegador em tempo real via SSE.
  • A mensagem do assistente salva no PostgreSQL ao final, com input_tokens, output_tokens e stop_reason, inclusive quando o usuário fecha a aba no meio.
  • Um front com fetch que lê o stream e vai montando a resposta na tela.
  • Testes com Http::fake usando um stream gravado.

Pré-requisitos:

  1. PHP 8.2+ e Laravel 11 ou 12.
  2. Uma chave da API da Anthropic (ANTHROPIC_API_KEY), gerada no console da Anthropic.
  3. Guzzle, que já vem com o Laravel (o Http:: usa ele por baixo).
  4. 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:

  1. 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.
  2. connection_aborted() depois de escrever: o PHP só descobre que o cliente sumiu quando tenta enviar algo. Por isso a checagem vem depois do send.
  3. 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.
  4. X-Accel-Buffering: no: diz ao Nginx para não acumular a resposta antes de repassar.
  5. Erro como evento: o error que chega com HTTP 200 vira exceção, cai no catch e 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

  1. A resposta aparece toda de uma vez no fim. É buffering em alguma camada. Na ordem de checagem: o header X-Accel-Buffering: no está saindo? O output_buffering do PHP-FPM está ligado (o php.ini-production vem com 4096) e você não está chamando ob_flush()? Tem gzip on no Nginx aplicando compressão em text/event-stream? Algum middleware lê o conteúdo da resposta (Debugbar, minificador de HTML)? Remova cada um até o stream voltar.
  2. 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. Use PHP_CLI_SERVER_WORKERS=4 no ambiente local ou rode atrás de Nginx.
  3. cURL error 28: Operation timed out em respostas longas. O timeout() do Http:: 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: timeout alto para o total, read_timeout curto para silêncio.
  4. 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.
  5. JSON_THROW_ON_ERROR estourando com Syntax error. Você está fazendo json_decode em cada pedaço lido, não em cada bloco SSE completo. Acumule no buffer até achar \n\n.
  6. A próxima mensagem da conversa volta 400. Provavelmente uma mensagem assistant com content vazio foi salva depois de um erro, ou duas mensagens user seguidas ficaram no histórico. Não salve texto vazio e, ao montar o histórico, garanta a alternância de papéis.
  7. 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_use chegam como input_json_delta parcial 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.