# Como fazer streaming das respostas do Claude no Laravel com Server-Sent Events

> Passo a passo para mostrar a resposta do Claude token a token no Laravel: ler o SSE da API com Http::, repassar ao navegador, salvar os tokens e fugir do buffering.

Por Lucas Roberto · IA · publicado em 2026-11-01 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/como-fazer-streaming-das-respostas-do-claude-no-laravel-com-server-sent-events

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

```dotenv
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_MAX_TOKENS=1024
```

Em `config/services.php`:

```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:

```bash
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
<?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

```bash
php artisan make:model Conversation -m
php artisan make:model Message -m
```

Na migration de `messages`:

```php
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:

```php
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](/blog/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`:

```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
<?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](/blog/fallback-de-llm-no-laravel-como-trocar-de-claude-para-openai-quando-a-api-cai). 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:

```js
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:

```js
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:

```text
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:

```php
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](/blog/como-testar-codigo-que-chama-o-claude-no-laravel-com-httpfake-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](/ferramentas/calculadora-custo-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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
