I really hope you never have to, but if you need to work with XML-RPC requests in PHP this post is for you

One of my freelance projects required me to maintain a connection with a legacy XML-RPC api in the context of HTML E-mails. From PHP version 8 onwards the xmlrpc_encode_request and xmlrpc_decode has been deprecated.

First of all this library works pretty well as a replacement - so before we jump into the weeds of writing our own client, I’m gonna push you to a more community sourced approach. However the library wasn’t right for my situation. However I rekon this’ll have most people who need to be working with XML-RPC protocol requests in PHP covered. So I highly recommend checking it out before you go any further with this post.

Opening ramble

In XML-RPC, a client performs the remote procedure call (RPC) by sending an HTTP request with the body formatted as compliant XML to a server that implements XML-RPC standard. A call can have multiple parameters and one result. The protocol defines a few data types for the parameters and result. Some of these data types are complex, i.e. nested structs and arrays.

There are a few things to be aware of when using the code we’re going to write:

  • The code written in this post will not be considering XML External Entity attacks - thats outside the scope of this blog post.
  • The code written in this post will assume that strings will be html entity escaped, and not encoded in CDATA[] tags.
  • The code written in this post will handle multi-dimensional arrays and structures.

Client interface

Our client interface is going to give us a few things:

  • A way of encoding some values to a valid XML-RPC request.
  • A way of sending those requests.
  • Handling XML-RPC responses ( including faults ).

The function for encoding requests is probably going to want an interface that’ll handle:

  • a method ( The action you want the rpc to perform )
  • params ( The data you want to encode in a request )
  • also, depending on your needs, an interface for managing some internals based on things like escaping, character encoding etc.

XML Formatting

So the first problem we’ll want to tackle is the ‘furniture’ of an xml document. This blog post isn’t a deep-dive on xml itself, but if you need to do some reading here is the link to MDN again.

/**
* Generates an XML-RPC encoded string
* @since 1.0.0
* @return string - The Encoded XML request string
*/
function nfp_xmlrpc_encode_request(string $method, array $params)
{
  // Begin XML-RPC request
  $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
  $xml .= "<methodCall>\n";
  $xml .= "<methodName>" . htmlspecialchars($method) . "</methodName>\n";

  // More goodness incoming

  $xml .= "</methodCall>";

  return $xml;
}

So at a high level; We’re using string concatenation to create a document that conforms to the xml spec. The request is enclosed in the <methodCall/> tag. With the immediate content being our method wrapped in the <methodName/> tag. So far so good.

Next up, let’s extend the function to handle the params ( the less easy bit ). This will be quite a jump. The goal is to take in a series of raw php values and then parse that to a compliant xml value string.

Each param needs to be enclosed in a <param> tag with the inner value wrapped with a <value> tag. and then a further tag to identify the expected type such as <int> or <sctruct> with the value it’s representing enclosed in the type tag.

/**
 * Generates an XML-RPC encoded string
 * @since 1.0.0
 * @return string - The Encoded XML request string
 */
function nfp_xmlrpc_encode_request(string $method, array $params)
{
    // Begin XML-RPC request
    $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    $xml .= "<methodCall>\n";
    $xml .= "<methodName>" . $method . "</methodName>\n";

    // Add parameters
    $xml .= "<params>\n";

    foreach ($params as $param)
    {
        $xml .= "<param>\n";
        $xml .= nfp_create_xml_value($param);
        $xml .= "</param>\n";
    }

    $xml .= "</params>\n";
    $xml .= "</methodCall>";

    return $xml;
}

Converting raw PHP values to XML

Now we need the nfp_create_xml_value function. We know we want something thats going to take in any php value and return an xml compliant value, and we also know it needs to handle multi-dimension arrays. So we can also guess that recursion is gonna crop up.

I’ve only needed to handle strings, integers and arrays. YMMV with this approach, but I knew I didn’t need to handle objects. oh the beauty of small-scale legacy projects.

 /**
 * Parses a php value to an encoded xml value
 * @since 1.0.0
 * @param string|int|array[] $value - The value to parse
 * @return string - The xml encoded value string
 */
function nfp_create_xml_value($value)
{
    // Integers
    if (is_int($value)) {
        return "<value><int>" . $value . "</int></value>\n";
    }
    // Strings
    elseif (is_string($value)) {
        return "<value><string>" .  htmlspecialchars($value) . "</string></value>\n";
    }
    // Array (Associative or Indexed)
    elseif (is_array($value)) {
        $arrayType = array_keys($value) === range(0, count($value) - 1) ? "array" : "struct";

        $xml = "<value>\n<$arrayType>\n";

        foreach ($value as $key => $item) {
            if ($arrayType === "struct") {
                $xml .= "<member>\n<name>" . htmlspecialchars($key) . "</name>\n";
                $xml .= nfp_create_xml_value($item);
                $xml .= "</member>\n";
            } else {
                $xml .= nfp_create_xml_value($item);
            }
        }
        $xml .= "</$arrayType>\n</value>\n";

        return $xml;
    }
}

Handling XML-RPC Responses

Before we send off any requests, it would be nice to have a way of handling our responses. XML response strings are wrapped in a <methodResponse/> element which will hopefully contain the data you want, or if something went wrong an XML fault.

XML errors ( or faults ) are returned as XML strings, with some specific tags that we’ll need to identify. The error itself is wrapped in a <fault/> element, and it will usually contain a <faultCode/> tag which usually represents a number. The <faultString/> will be the real goodness of telling you the problem.

Before we can handle a response, we need to parse the XML string into something we can work with in PHP. Standard lib to the rescue again with SimpleXMLElement. The SimpleXMLElement class will give us everything we need to parse xml strings, inspect the results for faults and work with the data it contains. If the SimpleXMLElement fails to parse the input, it will also return false. So we’ve already got a lot for free in terms of just being able to tell if what we get back is valid XML or not.

/**
 * Decodes an XML String to it's PHP value
 * @since 1.0.0
 * @param string $xml - The string to decode
 */
function nfp_xmlrpc_decode(string $xml)
{
    $xml = new SimpleXMLElement($xml);

    // Check for a fault in the XML-RPC response
    if (isset($xml->fault)) {
        return nfp_parse_xml_fault($xml->fault->value);
    }

    return isset($xml->params) ? nfp_parse_xml_value($xml->params->param->value) : null;
}

We’re also goin to want some logic to handle XML errors. For my situation it was enough to simple get back errors as an associated array with the key of fault

/**
 * Parse an xml fault to a php value
 * @since 1.0.0
 * @param object $xml - The xml fault to decode
 */
function nfp_parse_xml_fault(object $xml)
{
    $faultArray = [];
    foreach ($xml->struct->member as $member) {
        $faultArray[(string)$member->name] = nfp_parse_xml_value($member->value);
    }
    return ['fault' => $faultArray];
}

Now into the meaty bit - handling the multitude of datatypes you might expect back from your server. I was lucky in that the legacy tech i was dealing with was a relative “closed system” but you’ll essentially want something to handle any different data type you might expect. I’ve opted to use PHPs Type casting to give me that extra layer of safety, but really this part could look totally different for you, so I’ll just give you the code and let you get on with your life.

/**
 * Parse an xml value to a php value
 * @param object $xml - The value to parse
 * @since 1.0.0
 */
function nfp_parse_xml_value(SimpleXMLElement $xml)
{
    foreach ($xml->children() as $type => $value) {
        switch ($type) {
            case 'int':
                return (int) $value;
            case 'string':
                return (string) $value;
            case 'boolean':
                return (string) $value === '1';
            case 'double':
                return (float) $value;
            case 'dateTime.iso8601':
                return new DateTime((string)$value);
            case 'base64':
                return base64_decode((string)$value);
            case 'array':
                $array = [];
                foreach ($value->data->value as $arrayValue) {
                    $array[] = nfp_parse_xml_value($arrayValue);
                }
                return $array;
            case 'struct':
                $struct = [];
                foreach ($value->member as $member) {
                    $struct[(string)$member->name] = nfp_parse_xml_value($member->value);
                }
                return $struct;
        }
    }
    return null; // Or throw an exception
}

Sending the XML-RPC Request

Now we’re ready to send our requests. for that the standard lib cURL functions will give us everything we need to get going. Deepdiving into cURL is another thing I’m not going to cover in this post. This simple setup worked for my situation, you will probably want to tweak to your taste. If you have more complex needs this might not be enough. Check out the library linked at the beginning of this post if thats you.

/**
 * @param $method
 * @param $params
 * @return array|bool
 */
function nfp_send_xmlrpc_request(string $method, array $params)
{
    // your creds go here
    $config = nfp_get_settings();
    $user = $config->api_username;
    $pass = $config->api_password;

    // bring in out encoding fn()
    $request = nfp_xmlrpc_encode_request($method, $params);

    // handle auth anyway you like - just do it properly for your situation
    $auth = base64_encode($user . ":" . $pass);
    $url = "https://yourxmlrpcserver.com/api/xmlrpc";

    $header = ["Content-Type: text/xml", "Authorization: Basic $auth"];

    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $request);

    $response = curl_exec($ch);

    if ($response) {
        return nfp_xmlrpc_decode($response);
    } else {
        return curl_error($ch);
    }
}