Skip to content

JSON-RPC Request Builder — PHP source

Build valid JSON-RPC 2.0 requests, notifications, success responses, and error responses, plus batch arrays. Validate message structure.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * json-rpc-builder - polyglot showcase port (PHP).
 *
 * Pure JSON-RPC 2.0 message builder for the JSON-RPC Builder tool on CosmoDev
 * (dev.cosmolabs.org). Ported from the canonical TypeScript source at
 * src/lib/jsonRpc.ts so the tool page can display the same logic across six
 * languages.
 *
 * Language: PHP 7.4+ (standard library only - no Composer packages).
 * Display source - part of CosmoDev's polyglot tool pages.
 *
 * Behavior is identical to the TS lib: same inputs -> same outputs. Builds
 * requests, notifications, success/error responses, and batches. Never throws;
 * every builder returns an Outcome array.
 */

namespace CosmoDev\JsonRpc;

// Protocol version emitted on every message.
const JSONRPC_VERSION = '2.0';

/**
 * Pre-defined messages for the error codes reserved by the JSON-RPC 2.0 spec
 * (https://www.jsonrpc.org/specification#error_object, section 5.1). The
 * -32000..-32099 band is reserved for server-defined "Server error" values.
 */
const STANDARD_ERRORS = [
    -32700 => ['message' => 'Parse error'],
    -32600 => ['message' => 'Invalid Request'],
    -32601 => ['message' => 'Method not found'],
    -32602 => ['message' => 'Invalid params'],
    -32603 => ['message' => 'Internal error'],
    -32000 => ['message' => 'Server error'],
];

/**
 * Build the canonical return shape: ok flag, serialized JSON, error reason.
 * Mirrors the TS Outcome one-for-one so the UI consumes both ports identically.
 */
function outcome(bool $ok, string $json, ?string $error): array
{
    return ['ok' => $ok, 'json' => $json, 'error' => $error];
}

function is_non_empty_string($value): bool
{
    return is_string($value) && strlen($value) > 0;
}

/**
 * Serialize a value to JSON with a TS-compatible indent policy.
 *
 * PHP's json_encode produces compact output by default and multi-line output
 * with JSON_PRETTY_PRINT (4-space indent). To match the TS lib:
 *   - indent <= 0  -> compact, single line.
 *   - indent  > 0  -> pretty (PHP uses its fixed 4-space width; the parsed
 *     result is identical to the TS output, only the whitespace differs).
 */
function safe_stringify($value, int $indent): string
{
    $flags = 0;
    if ($indent > 0) {
        $flags = JSON_PRETTY_PRINT;
    }
    // JSON_UNESCAPED_SLASHES keeps "/" readable, matching JS JSON.stringify
    // (which never escapes "/"); JSON_UNESCAPED_UNICODE keeps multibyte text
    // human-readable in the showcase.
    $flags |= JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
    $json = json_encode($value, $flags);
    // json_encode only returns false on broken UTF-8 / recursion, neither of
    // which occurs for these controlled shapes; fall back to a safe value.
    return $json === false ? '' : $json;
}

/**
 * Build a JSON-RPC 2.0 Request.
 *
 * A request carries an id that the server echoes back in its response, making
 * it a synchronous ask/reply pair (unlike a notification). The id may be a
 * string, int, or null.
 *
 * Note on optional params: PHP cannot distinguish "argument omitted" from
 * "argument is null" without func_num_args gymnastics. The idiomatic PHP
 * convention here is: null params = omitted (no `params` field emitted). This
 * matches the overwhelming majority of real call sites; the exotic
 * explicitly-null-params case is intentionally collapsed.
 *
 * @param mixed    $method  remote method name
 * @param mixed    $params  structured params, or null to omit
 * @param int|float|string|null $id correlation id (default 1)
 * @param int      $indent  0 = compact, N = pretty with N-space indent
 */
function build_request($method, $params = null, $id = 1, int $indent = 0): array
{
    if (!is_non_empty_string($method)) {
        return outcome(false, '', 'method must be a non-empty string');
    }
    $obj = ['jsonrpc' => JSONRPC_VERSION, 'method' => $method];
    if ($params !== null) {
        $obj['params'] = $params;
    }
    $obj['id'] = $id;
    return outcome(true, safe_stringify($obj, $indent), null);
}

/**
 * Build a JSON-RPC 2.0 Notification: fire-and-forget, no id, server MUST NOT
 * reply. See build_request for the params-null convention.
 */
function build_notification($method, $params = null, int $indent = 0): array
{
    if (!is_non_empty_string($method)) {
        return outcome(false, '', 'method must be a non-empty string');
    }
    $obj = ['jsonrpc' => JSONRPC_VERSION, 'method' => $method];
    if ($params !== null) {
        $obj['params'] = $params;
    }
    return outcome(true, safe_stringify($obj, $indent), null);
}

/**
 * Build a JSON-RPC 2.0 success Response. The id echoes the request id this
 * answers; the result is always present (even if null).
 */
function build_success_response($id, $result, int $indent = 0): array
{
    $obj = ['jsonrpc' => JSONRPC_VERSION, 'result' => $result, 'id' => $id];
    return outcome(true, safe_stringify($obj, $indent), null);
}

/**
 * Build a JSON-RPC 2.0 error Response.
 *
 * The message falls back through a chain: explicit argument -> the standard
 * text for the given code -> the generic word "Error". `data` carries
 * optional structured detail (null means "no data field").
 */
function build_error_response($id, int $code, ?string $message = null, $data = null, int $indent = 0): array
{
    if ($message !== null && $message !== '') {
        $msg = $message;
    } elseif (isset(STANDARD_ERRORS[$code])) {
        $msg = STANDARD_ERRORS[$code]['message'];
    } else {
        $msg = 'Error';
    }
    $error = ['code' => $code, 'message' => $msg];
    if ($data !== null) {
        $error['data'] = $data;
    }
    $obj = ['jsonrpc' => JSONRPC_VERSION, 'error' => $error, 'id' => $id];
    return outcome(true, safe_stringify($obj, $indent), null);
}

/**
 * Serialize a batch of pre-built JSON-RPC messages.
 *
 * Per spec section 6, a batch is a non-empty array of messages exchanged in a
 * single round-trip. Callers supply already-built message objects (not
 * strings); we only wrap and serialize them.
 */
function build_batch($messages, int $indent = 0): array
{
    if (!is_array($messages) || count($messages) === 0) {
        return outcome(false, '', 'batch must be a non-empty array');
    }
    return outcome(true, safe_stringify(array_values($messages), $indent), null);
}

/**
 * Lightweight structural check for a JSON-RPC 2.0 message.
 *
 * Permissive about field VALUES (it does not recurse into params/error data);
 * it only verifies the message "shape" so a UI can surface what is wrong in
 * plain English. Returns every problem found, not just the first.
 *
 * The expected input is an associative array (json_decode's representation of
 * a JSON object). In JS every `typeof === "object"` value passes the first
 * gate (incl. arrays); the PHP port narrows that to associative arrays - the
 * idiomatic decoded-JSON shape.
 *
 * @return array{valid: bool, errors: string[]}
 */
function validate_rpc($obj): array
{
    if (!is_array($obj)) {
        return ['valid' => false, 'errors' => ['Not an object.']];
    }

    $errors = [];
    if (($obj['jsonrpc'] ?? null) !== JSONRPC_VERSION) {
        $errors[] = 'jsonrpc must be "2.0".';
    }
    if (array_key_exists('method', $obj) && !is_string($obj['method'])) {
        $errors[] = 'method must be a string.';
    }
    $hasResult = array_key_exists('result', $obj);
    $hasError = array_key_exists('error', $obj);
    if ($hasResult && $hasError) {
        $errors[] = 'cannot have both result and error.';
    }
    // A well-formed message is exactly one of: request/notification (method),
    // success response (result), or error response (error).
    $hasMethod = array_key_exists('method', $obj);
    if (!$hasMethod && !$hasResult && !$hasError) {
        $errors[] = 'must have method, result, or error.';
    }
    return ['valid' => count($errors) === 0, 'errors' => $errors];
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →