# Como validar o JSON do Claude no Laravel e pedir correção quando a validação falha

> Tutorial: force o Claude a responder por uma ferramenta, valide o JSON com o Validator do Laravel e devolva os erros para ele corrigir, com limite de tentativas e testes.

Por Lucas Roberto · IA · publicado em 2026-10-26 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-validar-o-json-do-claude-no-laravel-e-pedir-correcao-quando-a-validacao-falha

Para validar a saída do Claude no Laravel, force a resposta por uma ferramenta com `tool_choice`, passe o `input` pelo `Validator` com as regras do seu domínio e, se falhar, devolva os erros num `tool_result` com `is_error: true` para o modelo corrigir. Limite o processo a duas ou três tentativas. Separe dois casos: erro de formato, que vale tentar de novo, e informação que falta no texto, que exige perguntar ao usuário.

Na prática, o JSON do LLM quase nunca quebra a sintaxe quando você usa tool calling. O que quebra é a regra de negócio: parcelamento em Pix, data de vencimento no passado, valor em reais quando o sistema espera centavos. O JSON Schema não pega isso. O `Validator` do Laravel pega, e o próprio modelo consegue corrigir se você mostrar o erro.

## O que você vai ter funcionando no final

Um serviço `StructuredExtractor` que recebe um pedido em texto livre de um lojista, como "manda um link de 150 reais pro João Silva no cartão em 3x, vence sexta". Ele devolve:

1. `completo`: dados válidos e prontos para criar a cobrança.
2. `incompleto`: dados válidos, mas faltou informação, e você sabe exatamente qual perguntar.
3. `falhou`: o modelo não conseguiu produzir algo válido dentro do limite de tentativas.

O pacote inclui um comando Artisan para testar no terminal e testes com `Http::fake()` que simulam a correção.

**Pré-requisitos:**

- PHP 8.2+ e Laravel 11 ou 12.
- Uma chave da API da Anthropic (`ANTHROPIC_API_KEY`).
- Pest para os testes. Com PHPUnit funciona igual, muda só a sintaxe.
- Nenhum SDK. Vou chamar a Messages API direto com o `Http::` do Laravel, porque assim você enxerga o JSON que vai e volta.

## Por que validar a saída do LLM se eu já uso JSON Schema?

O `input_schema` da ferramenta garante a forma: tipos, campos obrigatórios, enums. Ele não expressa regras que dependem de mais de um campo ou do contexto. Três exemplos do domínio de cobrança:

| Regra | JSON Schema resolve? | Validator resolve? |
|---|---|---|
| `parcelas` é inteiro | Sim | Sim |
| Pix e boleto só aceitam 1 parcela | Não, na prática | Sim, com `after()` |
| Vencimento hoje ou depois | Não | Sim (`after_or_equal:today`) |
| Campo nulo precisa constar em `faltando` | Não | Sim |
| 150 reais virou 150 centavos | Não | Não, isso exige confirmação humana |

A última linha mostra o limite da técnica. A validação pega incoerência, mas não lê a mente do lojista. Para valores, a tela de confirmação continua obrigatória.

No post sobre [extrair dados de comprovantes em PDF com saída estruturada](/blog/como-extrair-dados-de-comprovantes-em-pdf-com-claude-e-saida-estruturada-no-laravel) eu tratei da extração em si. Aqui o foco é o que fazer quando a extração volta errada.

## 1. Configure a chave e o modelo

No `.env`:

```dotenv
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_TIMEOUT=30
APP_TIMEZONE=America/Sao_Paulo
```

Em `config/services.php`:

```php
'anthropic' => [
    'key' => env('ANTHROPIC_API_KEY'),
    'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
    'timeout' => (int) env('ANTHROPIC_TIMEOUT', 30),
],
```

O `APP_TIMEZONE` importa mais do que parece. A regra `after_or_equal:today` usa o fuso da aplicação, e "vence amanhã" às 22h em São Paulo já é depois de amanhã em UTC.

## 2. Crie o cliente HTTP da Messages API

Aqui o retry cuida só de **transporte**: conexão caída, 429 e 5xx. Ele não tem nada a ver com o retry de validação que vem depois. Misturar os dois é a origem da maioria dos bugs que vejo nesse tipo de código.

```php
<?php

namespace App\Ai;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class ClaudeClient
{
    public function messages(array $payload): array
    {
        return Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ])
            ->acceptJson()
            ->timeout(config('services.anthropic.timeout'))
            ->retry(2, 1000, function ($e) {
                if ($e instanceof ConnectionException) {
                    return true;
                }

                return $e instanceof RequestException
                    && ($e->response->status() === 429 || $e->response->serverError());
            })
            ->post('https://api.anthropic.com/v1/messages', [
                'model' => config('services.anthropic.model'),
                ...$payload,
            ])
            ->throw()
            ->json();
    }
}
```

Um 400 não entra no retry. Ele quase sempre indica payload malformado, e repetir a chamada só gasta tempo.

## 3. Defina a ferramenta e as regras no mesmo lugar

A ferramenta, chamada de *tool* na API, é a definição com nome, descrição e `input_schema`. Com `tool_choice` forçado, o modelo é obrigado a responder chamando essa ferramenta, então o resultado sempre chega como objeto estruturado em vez de texto solto.

O detalhe que mais faz diferença é o campo `faltando`. Ele dá ao modelo uma saída legítima para "não sei" e reduz bastante a tendência de inventar valor.

```php
<?php

namespace App\Ai\Schemas;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class CobrancaSchema
{
    public const TOOL = 'registrar_cobranca';

    public const CAMPOS = ['valor_centavos', 'parcelas', 'metodo', 'cliente_nome', 'vencimento'];

    public static function tool(): array
    {
        return [
            'name' => self::TOOL,
            'description' => 'Registra os dados de uma cobrança extraídos do pedido do lojista.',
            'input_schema' => [
                'type' => 'object',
                'properties' => [
                    'valor_centavos' => [
                        'type' => ['integer', 'null'],
                        'description' => 'Valor total em centavos. 150 reais = 15000. null se não informado.',
                    ],
                    'parcelas' => ['type' => ['integer', 'null'], 'description' => 'De 1 a 12.'],
                    'metodo' => ['type' => ['string', 'null'], 'enum' => ['pix', 'boleto', 'cartao', null]],
                    'cliente_nome' => ['type' => ['string', 'null']],
                    'vencimento' => ['type' => ['string', 'null'], 'description' => 'Data no formato YYYY-MM-DD.'],
                    'descricao' => ['type' => ['string', 'null'], 'description' => 'Até 140 caracteres.'],
                    'faltando' => [
                        'type' => 'array',
                        'items' => ['type' => 'string', 'enum' => self::CAMPOS],
                        'description' => 'Campos que o texto não informa. Todo campo null deve aparecer aqui.',
                    ],
                ],
                'required' => [...self::CAMPOS, 'descricao', 'faltando'],
            ],
        ];
    }

    public static function rules(): array
    {
        return [
            'valor_centavos' => ['nullable', 'integer', 'min:100', 'max:10000000'],
            'parcelas' => ['nullable', 'integer', 'between:1,12'],
            'metodo' => ['nullable', Rule::in(['pix', 'boleto', 'cartao'])],
            'cliente_nome' => ['nullable', 'string', 'max:120'],
            'vencimento' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:today'],
            'descricao' => ['nullable', 'string', 'max:140'],
            'faltando' => ['present', 'array'],
            'faltando.*' => [Rule::in(self::CAMPOS)],
        ];
    }

    /** @return list<string> */
    public static function validar(array $dados): array
    {
        $validator = Validator::make($dados, self::rules());

        $validator->after(function ($v) use ($dados) {
            $metodo = $dados['metodo'] ?? null;
            $parcelas = $dados['parcelas'] ?? null;

            if (in_array($metodo, ['pix', 'boleto'], true) && is_int($parcelas) && $parcelas > 1) {
                $v->errors()->add('parcelas', 'Pix e boleto aceitam só 1 parcela. Parcelamento só no cartão.');
            }

            $faltando = (array) ($dados['faltando'] ?? []);

            foreach (self::CAMPOS as $campo) {
                $nulo = ($dados[$campo] ?? null) === null;

                if ($nulo !== in_array($campo, $faltando, true)) {
                    $v->errors()->add('faltando', "O campo {$campo} deve estar em faltando se, e somente se, for null.");
                }
            }
        });

        return $validator->fails() ? $validator->errors()->all() : [];
    }
}
```

Schema e regras ficam na mesma classe de propósito. Quando alguém adiciona um campo, os dois lados mudam juntos no mesmo diff.

## 4. Monte o loop de validação com feedback do erro

Esse é o coração do tutorial. Quando a validação falha, eu não mando a pergunta de novo do zero. Acrescento à conversa a resposta do assistente, com o bloco `tool_use`, e em seguida uma mensagem do usuário com um `tool_result` marcado como `is_error: true`, contendo os erros. O modelo vê o que mandou, entende o que estava errado e chama a ferramenta de novo.

Primeiro, o objeto de resultado:

```php
<?php

namespace App\Ai;

final class ExtractionResult
{
    private function __construct(
        public readonly string $status,
        public readonly array $dados,
        public readonly array $erros,
        public readonly int $tentativas,
    ) {}

    public static function ok(array $dados, int $tentativas): self
    {
        $status = empty($dados['faltando']) ? 'completo' : 'incompleto';

        return new self($status, $dados, [], $tentativas);
    }

    public static function falhou(array $erros, int $tentativas): self
    {
        return new self('falhou', [], $erros, $tentativas);
    }
}
```

Agora o extrator:

```php
<?php

namespace App\Ai;

use App\Ai\Schemas\CobrancaSchema;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class StructuredExtractor
{
    public function __construct(
        private ClaudeClient $claude,
        private int $maxTentativas = 3,
    ) {}

    public function extrair(string $texto): ExtractionResult
    {
        $messages = [['role' => 'user', 'content' => $texto]];
        $erros = [];

        for ($tentativa = 1; $tentativa <= $this->maxTentativas; $tentativa++) {
            $response = $this->claude->messages([
                'max_tokens' => 1024,
                'system' => $this->system(),
                'tools' => [CobrancaSchema::tool()],
                'tool_choice' => ['type' => 'tool', 'name' => CobrancaSchema::TOOL],
                'messages' => $messages,
            ]);

            if (($response['stop_reason'] ?? null) === 'max_tokens') {
                throw new RuntimeException('Resposta truncada pelo max_tokens.');
            }

            $toolUse = collect($response['content'] ?? [])->firstWhere('type', 'tool_use');

            if (! $toolUse) {
                throw new RuntimeException('O modelo não chamou a ferramenta.');
            }

            $dados = (array) $toolUse['input'];
            $erros = CobrancaSchema::validar($dados);

            if ($erros === []) {
                return ExtractionResult::ok($dados, $tentativa);
            }

            Log::warning('llm.validacao_falhou', [
                'tentativa' => $tentativa,
                'erros' => $erros,
                'usage' => $response['usage'] ?? null,
            ]);

            $messages[] = ['role' => 'assistant', 'content' => $response['content']];
            $messages[] = ['role' => 'user', 'content' => [[
                'type' => 'tool_result',
                'tool_use_id' => $toolUse['id'],
                'is_error' => true,
                'content' => "Os dados não passaram na validação. Corrija e chame a ferramenta de novo:\n- "
                    . implode("\n- ", $erros),
            ]]];
        }

        return ExtractionResult::falhou($erros, $this->maxTentativas);
    }

    private function system(): string
    {
        $hoje = now()->toDateString();

        return <<<TXT
        Você extrai dados de pedidos de cobrança escritos por lojistas brasileiros.
        Hoje é {$hoje}. Converta datas relativas (amanhã, sexta) para YYYY-MM-DD.
        Nunca invente valores. Se o texto não informa um campo, use null e liste o campo em faltando.
        Valores sempre em centavos.
        TXT;
    }
}
```

Três decisões aqui merecem explicação:

1. **A data de hoje vai no system prompt.** Sem isso, o modelo não tem como saber o que é "sexta", e a regra `after_or_equal:today` passa a reprovar por um motivo que ele não consegue corrigir.
2. **O histórico cresce a cada tentativa.** A terceira tentativa reenvia tudo: o pedido, as duas respostas e os dois erros. É por isso que o limite é baixo.
3. **`falhou` não lança exceção.** É um resultado esperado do negócio. Quem chama decide se vai mostrar um formulário manual ou abrir um ticket.

## 5. Separe erro de formato de informação que falta

Esse é o ponto que mais economiza dinheiro. Se o lojista escreveu "cobra o João" sem dizer valor, nenhum retry resolve. Insistir só empurra o modelo a inventar um número para passar na validação.

Por isso o campo `faltando` existe e a regra de consistência no `after()` obriga cada `null` a aparecer nele. O fluxo fica assim:

1. O dado vem incoerente (Pix em 3x, data no passado, `null` sem estar em `faltando`): **retry com feedback**.
2. O dado vem coerente, mas com campos em `faltando`: **status `incompleto`**, e você pergunta ao usuário só o que faltou.
3. Depois de N tentativas o dado continua incoerente: **status `falhou`**, e o caso cai num fluxo manual.

No WhatsApp do Horalis ou no app da Granpag, o `incompleto` vira uma pergunta direta: "Qual o valor da cobrança?". Quando o usuário responde, você junta a resposta ao texto original e chama `extrair()` de novo.

## 6. Teste no terminal com um comando Artisan

Em `routes/console.php`:

```php
<?php

use App\Ai\StructuredExtractor;
use Illuminate\Support\Facades\Artisan;

Artisan::command('cobranca:extrair {texto}', function (StructuredExtractor $extractor) {
    $r = $extractor->extrair($this->argument('texto'));

    $this->info("Status: {$r->status} ({$r->tentativas} tentativa(s))");
    $this->line(json_encode($r->dados ?: $r->erros, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
});
```

Rode:

```bash
php artisan cobranca:extrair "manda um pix de 150 reais pro João Silva em 3x, vence sexta"
```

Esse texto é bom para teste porque é contraditório: Pix em 3x. Você deve ver um `llm.validacao_falhou` no log. Depois disso, o modelo costuma fazer uma de duas coisas: troca para `parcelas: 1` ou marca o caso como ambíguo. Se ele escolher trocar o método para cartão por conta própria, você descobriu uma instrução que faltava no system prompt, algo como "nunca troque o método escolhido pelo lojista". Testar textos contraditórios no terminal é o jeito mais rápido de achar essas lacunas.

## 7. Escreva os testes com Http::fake e sequência de respostas

O teste importante não é o caminho feliz. É provar que, quando a primeira resposta falha, a segunda requisição leva o `tool_result` com o erro. `Http::sequence()` devolve uma resposta diferente a cada chamada:

```php
<?php

use App\Ai\StructuredExtractor;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

function respostaTool(array $input): array
{
    return [
        'id' => 'msg_'.Str::random(8),
        'type' => 'message',
        'role' => 'assistant',
        'content' => [[
            'type' => 'tool_use',
            'id' => 'toolu_'.Str::random(8),
            'name' => 'registrar_cobranca',
            'input' => $input,
        ]],
        'stop_reason' => 'tool_use',
        'usage' => ['input_tokens' => 600, 'output_tokens' => 90],
    ];
}

it('devolve o erro ao modelo e aceita a correção', function () {
    $base = [
        'valor_centavos' => 15000,
        'cliente_nome' => 'João Silva',
        'vencimento' => now()->addDays(3)->toDateString(),
        'descricao' => null,
        'faltando' => [],
    ];

    Http::fake([
        'api.anthropic.com/*' => Http::sequence()
            ->push(respostaTool([...$base, 'metodo' => 'pix', 'parcelas' => 3]))
            ->push(respostaTool([...$base, 'metodo' => 'pix', 'parcelas' => 1])),
    ]);

    $r = app(StructuredExtractor::class)->extrair('pix de 150 pro João em 3x');

    expect($r->status)->toBe('completo')
        ->and($r->tentativas)->toBe(2)
        ->and($r->dados['parcelas'])->toBe(1);

    Http::assertSentCount(2);
    Http::assertSent(fn ($req) => count($req['messages']) === 3
        && $req['messages'][2]['content'][0]['is_error'] === true);
});

it('não tenta de novo quando só falta informação', function () {
    Http::fake([
        'api.anthropic.com/*' => Http::response(respostaTool([
            'valor_centavos' => null,
            'parcelas' => 1,
            'metodo' => 'pix',
            'cliente_nome' => 'João',
            'vencimento' => null,
            'descricao' => null,
            'faltando' => ['valor_centavos', 'vencimento'],
        ])),
    ]);

    $r = app(StructuredExtractor::class)->extrair('cobra o João no pix');

    expect($r->status)->toBe('incompleto');
    Http::assertSentCount(1);
});
```

Esses testes rodam sem rede e em milissegundos. Eles cobrem o contrato do loop, não a qualidade do modelo. Para medir se o prompt acerta em textos reais, você precisa de evals, que eu detalhei em [como testar código que usa LLM com Http::fake, respostas gravadas e evals](/blog/como-testar-codigo-que-usa-llm-no-laravel-httpfake-respostas-gravadas-e-evals).

## Erros comuns

1. **`tool_result` sem o `tool_use` antes.** A API exige que todo `tool_result` venha logo depois da mensagem do assistente que contém o `tool_use` com aquele `id`. Se você mandar só a mensagem de erro, sem reenviar o `content` do assistente, recebe um 400. Reenvie `$response['content']` inteiro como mensagem `assistant`.
2. **Retry de validação dentro do retry de transporte.** Se o job que chama o extrator também tem `$tries = 5`, um timeout na terceira tentativa reinicia o loop do zero, e você paga por até 15 chamadas. Deixe o loop de validação idempotente e trate o job como explico em [como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo](/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo).
3. **Não calcular o custo das tentativas.** Cada retry reenvia o histórico inteiro, então a terceira tentativa custa bem mais que a primeira. Monitore a taxa de `validacao_falhou`. Se ela passar de alguns por cento, o problema está no prompt ou no schema, não no número de tentativas. A [calculadora de custo de IA](/ferramentas/calculadora-custo-ia) ajuda a estimar quanto um retry médio adiciona por mês.
4. **`stop_reason: max_tokens`.** Se o `max_tokens` for baixo, o `input` da ferramenta chega cortado ou incompleto. Trate como erro de configuração, não como erro de validação, porque o modelo não consegue corrigir isso sozinho.
5. **Fuso horário.** Validação com `today` em UTC e system prompt com a data de São Paulo geram reprovações que o modelo nunca vai corrigir. Use a mesma fonte, `now()` com `APP_TIMEZONE` configurado, nos dois lugares.
6. **`tool_choice` forçado com extended thinking.** A API não aceita `tool_choice` do tipo `tool` ou `any` com o raciocínio estendido ligado e devolve 400. Se precisar de thinking, use `auto` e trate o caso em que o modelo responde em texto.
7. **Acreditar que validação substitui confirmação.** "150 reais" extraído como `150` centavos passa em qualquer regra razoável de mínimo. Para dinheiro, mostre o valor formatado e peça confirmação antes de criar a cobrança.

## Conclusão prática

Comece pelo schema com `faltando` e pela regra de consistência entre `null` e `faltando`. Só isso já elimina boa parte das alucinações de campo. Depois adicione as regras de negócio no `after()` do `Validator`, com mensagens escritas como se fossem para um colega, porque é o modelo que vai ler. Limite o loop a três tentativas, registre cada falha com os erros e o `usage`, e revise esse log toda semana. As mensagens que se repetem mostram exatamente qual linha do system prompt você precisa reescrever.

## Perguntas frequentes

### Por que não pedir JSON no texto e usar json_decode?

Porque você passa a lidar também com JSON inválido, texto antes do objeto e blocos de markdown. Com `tool_choice` forçado, a API entrega o `input` já como objeto, e sua validação cuida só do conteúdo.

### Quantas tentativas devo permitir?

Duas ou três. Se o modelo não corrige com o erro explícito na segunda tentativa, raramente corrige na quinta, e o custo cresce a cada rodada porque o histórico é reenviado.

### Isso funciona com a OpenAI?

Funciona. O conceito é o mesmo com function calling: você devolve o erro numa mensagem de papel `tool` referenciando o `tool_call_id`. O `CobrancaSchema` e o `Validator` continuam iguais, muda só o cliente HTTP.

### Posso usar Form Request em vez de Validator::make?

Form Request é feito para requisições HTTP. Aqui os dados vêm do modelo, não do usuário. Use `Validator::make` com as regras numa classe própria, que você reaproveita tanto no extrator quanto nos testes.

### Devo mandar a mensagem de erro em português ou inglês?

Tanto faz para o modelo. O que importa é a mensagem dizer o que está errado e qual é o valor aceito. "Pix aceita só 1 parcela" funciona muito melhor que "The parcelas field is invalid".

---

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