Skip to content

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 armazenamento
  • decodeToolInput() 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:

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

php
$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
<?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:

php
{}

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:

php
JsonUtil::encodeToolInput('{"city":"Panama"}');
// {"city":"Panama"}

Mas strings em formato de lista ou inválidas são rejeitadas:

php
JsonUtil::encodeToolInput('["a","b"]');
// {}

JsonUtil::encodeToolInput('not json');
// {}

Escalares São Rejeitados ​

Payloads de entrada não deveriam ser inteiros, booleanos ou strings simples:

php
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():

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

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

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:

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

EntradaencodeToolInput()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:

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

php
$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.