Normalizar entradas JSON de ferramentas em PHP: preservar payloads com formato de objeto e recuperar dupla codificação
Introdução
Quando você persiste argumentos de tool calls em PHP, "JSON válido" nem sempre é suficiente.
Entradas de ferramentas normalmente precisam ser objetos JSON. Mas no caminho, o PHP pode facilmente transformar um payload vazio em [], decodificar {} como um array vazio ou deixar você com uma string JSON duplamente codificada como "{"key":"value"}".
Se sua aplicação salva entradas de ferramentas no banco e depois as reaproveita para reenviar a um provedor de IA ou a outro serviço interno, essas diferenças de formato acabam virando bugs sutis.
Este artigo mostra uma classe pequena chamada JsonUtil que normaliza essas entradas nos dois sentidos:
encodeToolInput()garante JSON com formato de objeto para armazenamentodecodeToolInput()garante um resultado reutilizável com formato de objeto
O objetivo é simples: aceitar apenas objetos JSON, rejeitar listas e escalares e recuperar erros comuns de persistência.
Por Que Isso Quebra Tão Fácil em PHP
Existem dois comportamentos do PHP por trás da maior parte desse problema:
1. json_encode([]) Produz [], Não {}
Se um tool call não tem argumentos, muitos sistemas ainda esperam um objeto JSON vazio porque o payload continua sendo conceitualmente "um objeto sem propriedades".
Mas o PHP trata um array vazio como lista:
json_encode([]); // "[]"Isso é JSON válido, mas representa o formato errado para entradas de ferramentas baseadas em objetos.
2. json_decode('{}', true) Produz um Array Vazio
Aqui acontece o problema inverso:
$decoded = json_decode('{}', true);
var_dump($decoded); // []Com associative: true, o PHP converte objetos JSON em arrays. Isso costuma ser conveniente, mas também faz com que um objeto vazio vire um array vazio, e depois você precisa normalizar o resultado se realmente se importa com o formato.
Importante
Se o formato do JSON importa, não trate "JSON válido" e "objeto JSON válido" como se fossem a mesma coisa.
A Classe Utilitária
Esta é a utilidade:
<?php
namespace App\Utils;
class JsonUtil
{
/**
* Codifica a entrada da ferramenta para salvar no banco, preservando
* objetos vazios como "{}". json_encode([]) produz "[]", mas a entrada
* da ferramenta é esperada como objeto.
*/
public static function encodeToolInput(mixed $input): string
{
if (empty($input)) {
return '{}';
}
// Se a entrada já for uma string JSON, só a mantém quando ela
// representa um objeto JSON decodificado como array associativo.
if (is_string($input)) {
$decoded = json_decode($input, associative: true);
if (! is_array($decoded) || array_is_list($decoded)) {
return '{}';
}
return $input;
}
// Aceita apenas arrays associativos e stdClass.
if (is_array($input) || $input instanceof \stdClass) {
return json_encode($input) ?: '{}';
}
return '{}';
}
/**
* Decodifica a entrada do banco e garante um resultado com formato de objeto.
* Retorna array associativo ou stdClass, nunca strings, arrays indexados ou escalares.
*/
public static function decodeToolInput(?string $json): array|\stdClass
{
if (! $json) {
return new \stdClass;
}
$decoded = json_decode(json: $json, associative: true);
// Lida com JSON inválido, escalares e strings JSON duplamente codificadas.
if (! is_array($decoded)) {
if (is_string($decoded)) {
$inner = json_decode($decoded, associative: true);
if (is_array($inner) && ! empty($inner) && ! array_is_list($inner)) {
return $inner;
}
}
return new \stdClass;
}
// Arrays indexados representam listas JSON, não objetos de entrada.
if (array_is_list($decoded)) {
return new \stdClass;
}
return $decoded;
}
}Regras de Codificação
encodeToolInput() é estrita de propósito. Ela prefere um objeto vazio seguro em vez de preservar um valor ambíguo ou inválido.
Valores Vazios Viram {}
Se a entrada for null, [], false ou outro valor vazio, o método retorna:
{}Isso dá a você um formato estável para representar "sem argumentos".
Strings JSON Só São Aceitas Se Representarem Objetos
Se um caller passar uma string JSON diretamente, o método só a mantém quando ela decodifica para um array associativo:
JsonUtil::encodeToolInput('{"city":"Panama"}');
// {"city":"Panama"}Mas strings em formato de lista ou inválidas são rejeitadas:
JsonUtil::encodeToolInput('["a","b"]');
// {}
JsonUtil::encodeToolInput('not json');
// {}Escalares São Rejeitados
Payloads de entrada não deveriam ser inteiros, booleanos ou strings simples:
JsonUtil::encodeToolInput(42); // {}
JsonUtil::encodeToolInput(true); // {}Isso é conservador de forma intencional. Se o payload não for claramente um objeto, a utilidade o normaliza para objeto vazio.
Regras de Decodificação
decodeToolInput() resolve o problema inverso: pega o que veio do armazenamento e transforma em algo seguro para reutilizar como argumentos.
JSON Ausente ou Inválido Vira Objeto Vazio
Todos estes casos retornam new stdClass():
JsonUtil::decodeToolInput(null);
JsonUtil::decodeToolInput('');
JsonUtil::decodeToolInput('not json at all');
JsonUtil::decodeToolInput('42');
JsonUtil::decodeToolInput('true');
JsonUtil::decodeToolInput('"just a string"');Assim o código downstream nunca precisa lidar com entradas escalares.
Arrays Indexados São Rejeitados
Listas JSON são válidas, mas continuam com o formato errado para argumentos de ferramentas:
JsonUtil::decodeToolInput('["a","b"]');
// stdClass {}Strings JSON Duplamente Codificadas São Desempacotadas uma Vez
Um dos erros de persistência mais chatos acontece quando algum trecho executa json_encode() sobre uma string JSON em vez de sobre um array ou objeto PHP:
$doubleEncoded = json_encode('{"key":"value"}');
echo $doubleEncoded;
// "{\"key\":\"value\"}"O primeiro json_decode() retorna uma string, não um array. A utilidade detecta esse caso e tenta decodificar mais uma vez:
JsonUtil::decodeToolInput($doubleEncoded);
// ['key' => 'value']Isso oferece uma recuperação prática sem aceitar strings escalares arbitrárias como payload válido.
Resumo do Comportamento
Este é o contrato efetivo:
| Entrada | encodeToolInput() | decodeToolInput() |
|---|---|---|
null | '{}' | stdClass {} |
[] | '{}' | stdClass {} ao ler '[]' |
['k' => 'v'] | '{"k":"v"}' | ['k' => 'v'] |
'{"k":"v"}' | '{"k":"v"}' | ['k' => 'v'] |
'["a","b"]' | '{}' | stdClass {} |
'42' | '{}' | stdClass {} |
json_encode('{"k":"v"}') | '{}' se for passado direto como JSON de uma string | ['k' => 'v'] |
A assimetria é intencional:
- a codificação é estrita e recusa entradas ambíguas
- a decodificação é um pouco mais tolerante e consegue recuperar um erro comum de armazenamento
Como Usar em um Pipeline de Tool Calls
Esse padrão é útil quando sua aplicação:
- recebe argumentos de ferramentas de um provedor LLM
- salva esses argumentos em uma coluna do banco
- depois os reidrata para retries, auditorias ou replay
Exemplo:
// Persistindo entrada da ferramenta
$toolInputJson = JsonUtil::encodeToolInput($toolCall['input'] ?? null);
ToolExecution::create([
'tool_name' => $toolCall['name'],
'tool_input' => $toolInputJson,
]);
// Reexecutando entrada da ferramenta
$decodedInput = JsonUtil::decodeToolInput($execution->tool_input);
$providerPayload = [
'name' => $execution->tool_name,
'input' => $decodedInput,
];Com essa abordagem, o banco armazena uma representação estável em string, enquanto o código de replay sempre recebe de volta um payload com formato de objeto.
Teste Explicitamente os Casos de Borda
Utilidades como essa parecem simples, mas os casos de borda são justamente o motivo da existência delas. Escreva testes focados para:
- entrada vazia
- objeto JSON vazio
- arrays associativos
- arrays indexados
- valores JSON escalares
- JSON inválido
- strings JSON duplamente codificadas
- comportamento de round-trip
Um teste compacto de round-trip é especialmente valioso:
$original = ['thought' => 'I need to search the web'];
$encoded = JsonUtil::encodeToolInput($original);
$decoded = JsonUtil::decodeToolInput($encoded);
expect($decoded)->toBe($original);Conclusão
Quando um payload deveria ser um objeto JSON, deixar o comportamento padrão de json_encode() e json_decode() passar sem controle costuma ser permissivo demais.
Esse padrão com JsonUtil oferece um contrato mais estrito:
- armazena entrada vazia como
{} - rejeita listas e payloads escalares
- recupera strings JSON duplamente codificadas
- sempre retorna um valor com formato de objeto ao decodificar
Essa pequena camada de normalização evita uma quantidade desproporcional de bugs em fluxos de tool calling, persistência e replay.
