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 fetch que 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:

  1. message_start: traz o usage.input_tokens.
  2. content_block_delta: traz o texto em delta.text quando delta.type é text_delta.
  3. message_delta: traz o stop_reason e o usage.output_tokens acumulado.
  4. message_stop: indica o fim.
  5. ping e error: 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: um read(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 o max_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:

  1. 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.
  2. ignore_user_abort(true) e connection_aborted(). Sem o primeiro, o PHP mata o script quando o usuário fecha a aba, e o finally pode nem rodar. Com ele, você detecta o abort, para de consumir o Claude e salva o texto parcial com status = aborted. Interromper a leitura também corta a geração, então você para de pagar por tokens que ninguém vai ler.
  3. 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.
  4. 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:

  1. Um pool FPM separado só para as rotas de chat.
  2. Laravel Octane com FrankenPHP ou Swoole.
  3. 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

  1. O texto aparece todo de uma vez no navegador. Quase sempre é buffering: falta X-Accel-Buffering: no, o gzip está comprimindo text/event-stream ou o Cloudflare ou outro proxy está segurando a resposta. Teste com curl -N direto no servidor, sem passar pelo proxy, para descobrir em qual camada está o problema.
  2. 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.
  3. Erro 400 da API com o histórico. A primeira mensagem precisa ter role igual a user, e o content não pode estar vazio. Mensagens abortadas sem texto não devem entrar no histórico.
  4. O stream para no meio sem erro. Pode ser o timeout do Guzzle, o fastcgi_read_timeout do nginx ou o max_execution_time do PHP. Os três precisam ser maiores que a resposta mais longa que você aceita. Verifique também o stop_reason: se vier max_tokens, o modelo bateu no limite e não houve falha de rede.
  5. Validação devolve 302 e o fetch recebe HTML. Falta o Accept: application/json na requisição.
  6. Respostas cobradas sem ninguém lendo. Sem ignore_user_abort(true) e connection_aborted(), o comportamento ao fechar a aba fica imprevisível. Registre input_tokens e output_tokens por 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.