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.