Skip to content

Normalize JSON Tool Inputs in PHP: Preserve Object-Shaped Payloads and Recover from Double Encoding

Introduction

When you persist tool call arguments in PHP, "valid JSON" is not always enough.

Tool inputs are usually meant to be JSON objects. But along the way, PHP can easily turn an empty payload into [], decode {} into an empty array, or leave you with a double-encoded JSON string like "{"key":"value"}".

If your application stores tool inputs in the database and later replays them to an AI provider or another internal service, these shape mismatches become subtle bugs.

This article shows a small JsonUtil class that normalizes tool inputs both ways:

  • encodeToolInput() guarantees object-shaped JSON for storage
  • decodeToolInput() guarantees an object-like result for reuse

The goal is simple: accept only JSON objects, reject lists and scalars, and recover from common persistence mistakes.

Why This Breaks So Easily in PHP

There are two PHP behaviors behind most of the trouble:

1. json_encode([]) Produces [], Not {}

If a tool call has no arguments, many systems still expect an empty JSON object because the payload is conceptually "an object with no properties."

But PHP treats an empty array as a list:

php
json_encode([]); // "[]"

That is valid JSON, but it represents the wrong shape for object-based tool inputs.

2. json_decode('{}', true) Produces an Empty Array

This is the opposite problem:

php
$decoded = json_decode('{}', true);

var_dump($decoded); // []

With associative: true, PHP converts JSON objects into arrays. That is usually convenient, but it also means an empty object becomes an empty array, which is indistinguishable from a decoded JSON list unless you normalize it afterward.

Important

If you care about JSON shape, do not treat "valid JSON" and "valid JSON object" as the same thing.

The Utility Class

Here is the utility:

php
<?php

namespace App\Utils;

class JsonUtil
{
    /**
     * Encode tool input for DB storage, preserving empty objects as "{}".
     * PHP's json_encode([]) produces "[]", but tool inputs are expected to be objects.
     */
    public static function encodeToolInput(mixed $input): string
    {
        if (empty($input)) {
            return '{}';
        }

        // If the input is already a JSON string, keep it only when it decodes
        // to an associative array (that is, a JSON object).
        if (is_string($input)) {
            $decoded = json_decode($input, associative: true);

            if (! is_array($decoded) || array_is_list($decoded)) {
                return '{}';
            }

            return $input;
        }

        // Accept associative arrays and stdClass instances only.
        if (is_array($input) || $input instanceof \stdClass) {
            return json_encode($input) ?: '{}';
        }

        return '{}';
    }

    /**
     * Decode tool input from DB storage, ensuring the result is always object-shaped.
     * Returns associative array or stdClass, never strings, indexed arrays, or scalars.
     */
    public static function decodeToolInput(?string $json): array|\stdClass
    {
        if (! $json) {
            return new \stdClass;
        }

        $decoded = json_decode(json: $json, associative: true);

        // Handle invalid JSON, scalars, and double-encoded JSON strings.
        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;
        }

        // Indexed arrays represent JSON lists, not tool input objects.
        if (array_is_list($decoded)) {
            return new \stdClass;
        }

        return $decoded;
    }
}

Encoding Rules

encodeToolInput() is strict by design. It prefers a safe empty object over preserving an ambiguous or invalid value.

Empty Values Become {}

If the input is null, [], false, or another empty value, the method returns:

php
{}

That gives you a stable storage format for "no arguments."

JSON Strings Are Accepted Only If They Represent Objects

If a caller passes a JSON string directly, the method keeps it only when it decodes into an associative array:

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

But list-shaped or invalid strings are rejected:

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

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

Scalars Are Rejected

Tool input payloads should not be integers, booleans, or plain strings:

php
JsonUtil::encodeToolInput(42);    // {}
JsonUtil::encodeToolInput(true);  // {}

This is intentionally conservative. If a payload is not clearly an object, the utility normalizes it to an empty object.

Decoding Rules

decodeToolInput() solves the reverse problem: take whatever came from storage and turn it into something safe to use as tool arguments again.

Missing or Invalid JSON Becomes an Empty Object

These all return 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"');

That means downstream code never has to deal with scalar tool inputs.

Indexed Arrays Are Rejected

JSON lists are valid JSON, but they are still the wrong shape for object-like tool arguments:

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

Double-Encoded JSON Strings Are Unwrapped Once

One of the most annoying persistence bugs happens when code accidentally runs json_encode() on a JSON string instead of on a PHP array or object:

php
$doubleEncoded = json_encode('{"key":"value"}');

echo $doubleEncoded;
// "{\"key\":\"value\"}"

The first json_decode() returns a string, not an array. The utility detects that case and tries one more decode:

php
JsonUtil::decodeToolInput($doubleEncoded);
// ['key' => 'value']

That gives you a practical recovery path without accepting arbitrary scalar strings as valid payloads.

Behavior Summary

Here is the effective contract:

InputencodeToolInput()decodeToolInput()
null'{}'stdClass {}
[]'{}'stdClass {} when read from '[]'
['k' => 'v']'{"k":"v"}'['k' => 'v']
'{"k":"v"}''{"k":"v"}'['k' => 'v']
'["a","b"]''{}'stdClass {}
'42''{}'stdClass {}
json_encode('{"k":"v"}')'{}' when passed directly as a JSON string of a string['k' => 'v']

The asymmetry is intentional:

  • encoding is strict and refuses ambiguous input
  • decoding is slightly more forgiving and can recover one common storage mistake

Using It in a Tool-Call Pipeline

This pattern is useful when your application:

  • receives tool arguments from an LLM provider
  • stores them in a database column
  • later rehydrates them for retries, audits, or replay

Example:

php
// Persisting tool input
$toolInputJson = JsonUtil::encodeToolInput($toolCall['input'] ?? null);

ToolExecution::create([
    'tool_name' => $toolCall['name'],
    'tool_input' => $toolInputJson,
]);

// Replaying tool input
$decodedInput = JsonUtil::decodeToolInput($execution->tool_input);

$providerPayload = [
    'name' => $execution->tool_name,
    'input' => $decodedInput,
];

With this approach, the database stores a stable string representation, while your replay code always gets an object-shaped payload back.

Test the Edge Cases Explicitly

Utilities like this look simple, but the edge cases are the whole point. Write focused tests for:

  • empty input
  • empty JSON object
  • associative arrays
  • indexed arrays
  • scalar JSON values
  • invalid JSON
  • double-encoded JSON strings
  • round-trip behavior

A compact round-trip test is especially valuable:

php
$original = ['thought' => 'I need to search the web'];

$encoded = JsonUtil::encodeToolInput($original);
$decoded = JsonUtil::decodeToolInput($encoded);

expect($decoded)->toBe($original);

Conclusion

When a payload is supposed to be a JSON object, letting PHP's default encode/decode behavior pass through unchecked is usually too loose.

This JsonUtil pattern gives you a stricter contract:

  • store empty tool input as {}
  • reject lists and scalar payloads
  • recover from double-encoded JSON strings
  • always return an object-shaped value on decode

That small amount of normalization prevents a disproportionate number of bugs in tool-calling, persistence, and replay flows.