Reading Time: 6 minutes

A few years ago, when Symfony 2.8 was still a thing, there was a bundle called https://github.com/BeSimple/BeSimpleSoap, which we used in one of our client projects to handle SOAP requests on the server-side with annotations. Time flies, and I wanted to extract some functionality into a separate bundle to use it in a Symfony 4 project. The problem is that BeSimpleSoap is not maintained anymore. In this blog post, I want to tell you how I replaced the bundle by a native PHP solution in Symfony, working for both project versions.

So let’s get our hands dirty! Grab a coffee and let’s talk about: SOAP.

SOAP

SOAP is a messaging protocol used to communicate between web services via HTTP using XML. It originally stood for Simple Object Access Protocol. A SOAP message (i.e., an XML document) usually contains the following blocks:

  • Envelope
  • Header
  • Body
  • Fault

The WSDL – Web Services Definition Language – which is, again, an XML-based language, is used to describe the interfaces. By these definitions, we tell our server which endpoints are handled, and at the same time, the client knows how to call our API.

That’s just a rough summary, and there’s a lot more to get into. For this post, it’s sufficient to know that there is a WSDL defining our endpoints and that SOAP is sending some kind of XML via HTTP between clients and our server.

The replacement process

Before writing any code, I checked the Symfony documentation for any hints or best practices. Turns out: you don’t need any third-party library to make SOAP servers work with Symfony. As I don’t have a lot of experience with SOAP and those endpoints are crucial for the application, I decided to do the replacement test-driven. But test-driven, in this case, means functional testing. My thought behind it was: I need to make sure that every endpoint is still working after I replaced the BeSimpleSoapBundle by my implementation. For this reason, I played around with the WSDL2 to PHP Generator to create my two SOAP clients. All I needed to do is getting the WSDL file for each web service by making a GET request to the URL where the SOAP service is located.

In retrospect, I might not have needed the client generator, but I thought there’s a little more to it than creating a SoapClient consuming a WSDL file and some methods calling __soapCall. Still, I thought it’s worth mentioning it when you have a service that is more complex or has a lot of endpoints.

So here is one of the SoapClients that have been generated (and slightly adjusted by myself):

<?php

namespace AppBundle\Tests\Controller\SOAP;

/**
 * @group functional
 */
class LivestreamApiSoapClient extends \SoapClient
{

    /**
     * @param array $options A array of config values
     * @param string $wsdl The wsdl file to use
     * @throws \SoapFault
     */
    public function __construct(array $options, string $wsdl)
    {
        $options = array_merge(array(
            'features' => 1,
        ), $options);

        parent::__construct($wsdl, $options);
    }

    public function setRecordingInformation(string $identifier, string $startDate, string $endDate): int
    {
        return $this->__soapCall('setRecordingInformation', array($identifier, $startDate, $endDate));
    }

    public function startLiveStream(string $identifier, string $startTime): int
    {
        return $this->__soapCall('startLiveStream', array($identifier, $startTime));
    }

    public function stopLiveStream(string $identifier, string $stopTime): int
    {
        return $this->__soapCall('stopLiveStream', array($identifier, $stopTime));
    }
}

Note that I added the SoapClient to the Test namespace as this is for testing purposes only. With this SoapClient, I was now able to write a few functional tests:

<?php

namespace AppBundle\Tests\Controller\SOAP;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class LivestreamApiTest extends KernelTestCase
{
    /** @var string */
    private $wsdl;

    /** @var array  */
    private $soapClientOptions;

    public function setUp(): void
    {
        $opts = array(
            'http' => array(
                'user_agent' => 'PHPSoapClient'
            )
        );
        $context = stream_context_create($opts);

        $this->soapClientOptions = array(
            'stream_context' => $context,
            'cache_wsdl' => WSDL_CACHE_NONE,
            'trace' => true
        );

        $this->wsdl = 'http://host.docker.internal/wsdl/LivestreamApi?wsdl';
    }

    public function testSetRecordingInformation()
    {
        $start = new \DateTime('now');
        $end = new \DateTime('tomorrow');

        $client = new LivestreamApiSoapClient($this->soapClientOptions, $this->wsdl);
        $response = $client->setRecordingInformation(
            1234,
            $start->format(\DateTime::ATOM),
            $end->format(\DateTime::ATOM)
        );
        $this->assertEquals(1, $response);
    }

    public function testStartLiveStream()
    {
        $start = new \DateTime('now');

        $client = new LivestreamApiSoapClient($this->soapClientOptions, $this->wsdl);
        $response = $client->startLiveStream(
            1234,
            $start->format(\DateTime::ATOM)
        );
        $this->assertEquals(1, $response);
    }

    public function testStopLiveStream()
    {
        $stop = new \DateTime('now');

        $client = new LivestreamApiSoapClient($this->soapClientOptions, $this->wsdl);
        $response = $client->stopLiveStream(
            1234,
            $stop->format(\DateTime::ATOM)
        );
        $this->assertEquals(1, $response);
    }
}

A few things to note

I had quite a bit of trouble making those tests run:

  1. When testing and trying to make it work – you might need to disable the WSDL cache and set a specific user-agent, so your requests work successfully.
  2. The WSDL and PHP annotation said dateTime for some arguments. So naively, I tried to send \DateTime objects which did not work. After some googling, I found out that the dateTime objects are transferred in a specific format (→ \DateTime::ATOM).
  3. The docker container was not able to call itself via the same URL you access it from a browser or someone external would call your service with. My solution was to use host.docker.internal as hostname inside the test. But this led to another issue later on.

Extracting the SOAP methods from controller

BeSimpleSoapBundle provides some helpful annotations so you could use your typical controller structure in Symfony – with all its disadvantages you might get. In this case, the complete logic was inside the controller, all dependencies were fetched from the container, and there were obviously no tests for it.

The first step to transform the former SOAP controller into the structure proposed by the Symfony documentation is to move all the previous actions into methods (without the Action suffix) inside a separate class/service. I started by identifying all dependencies, which are called inside the former actions. Most of them can be found with a few typical search terms like $this->getContainer->get(...) or $this->getDoctrine()..., etc. I replaced them by injecting the called services into the constructor. Finally, I removed the BeSimpleSoap Annotations from the former actions, and we’re ready to go!

Time to put things together

Now it’s time to replace the bundle completely. I removed the old routing, the composer dependency, the config file and now it’s time to create our new SoapController:

<?php

namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class SoapController extends Controller
{
    /**
     * @Route("/LivestreamApi", name="_soap_livestream")
     *
     * @return Response
     */
    public function handleLivestreamAction(): Response
    {
        return $this->handleSoapServer('livestream', 'soap.livestream');
    }

    /**
     * @Route("/TranscodingApi", name="_soap_transcoding")
     *
     * @return Response
     */
    public function handleTranscodingAction(): Response
    {
        return $this->handleSoapServer('transcoding', 'soap.transcoding');
    }

    protected function handleSoapServer(string $identifier, string $serviceId): Response
    {
        $response = new Response();
        $response->headers->set('Content-Type', 'text/xml; charset=ISO-8859-1');

        $wsdlUrl = "path/to/{$identifier}/wsdl";

        $soapServer = new \SoapServer($wsdlUrl);
        $soapServer->setObject($this->get($serviceId));

        ob_start();
        $soapServer->handle();
        $response->setContent(ob_get_clean());

        return $response;
    }
}

As I mentioned before, there were two SOAP endpoints/services that I needed to replace. For this reason, I made the handleSoapServer take two arguments that are telling it which WSDL and service to use.

Didn’t you say something about problems?

Now the fun part begins! While the code above looks nice and in theory should work, I experienced a couple of issues:

SOAP address

The soap:address cannot be hardcoded. Already during development, I noticed that it’s either working in my browser or my tests. So I needed to replace it dynamically. I chose to use Twig for this. Note that the host url needs to be absolute:

/**
 * @Route("/{identifier}.wsdl", name="_soap_wsdl")
 *
 * @param string $identifier
 * @return Response
 */
public function getWsdlFileContent(string $identifier): Response
{
    $template = sprintf('AppBundle:SOAP:%s.wsdl.twig', $identifier);

    $body = $this->renderView($template, array(
        'host' => $this->generateUrl('_soap_' . $identifier, [], UrlGeneratorInterface::ABSOLUTE_URL)
    ));

    $response = new Response();
    $response->setContent($body);
    $response->headers->set('Content-Type', 'application/wsdl+xml');

    return $response;
}

WSDL issues

This was probably the most annoying part to debug because SOAP’s error handling isn’t very explanatory. I think I had them all: SoapFault: Could not connect to host, SoapFault: SOAP-ERROR: Parsing WSDL: Couldn't load from '...', Procedure 'functionName' not present. I spent quite some time googling and trying to find answers on StackOverflow, but it seemed if somehow all errors were solved by disabling the WSDL cache. For me, this didn’t help. I tried looking into the BeSimpleSoap implementation where the WSDL was generated, but I couldn’t find anything helpful.

I was desperate! My next move was to load the content of the WSDL and add the WSDL URL as a Data URL. While I knew this is not an excellent solution, it was the first time it worked for my tests and the browser!

public function handleAction(Request $request): Response
{
    $wsdlPath = $this->generateUrl('_soap_wsdl', ['wsdlIdentifier' => 'dds'], UrlGeneratorInterface::ABSOLUTE_URL);
    $content = file_get_contents($wsdlPath);

    $soapServer = new \SoapServer($content);

  	// ...
}

But when I deployed the code to our staging system, I realized that the PHP configurations are not 100% the same: allow_url_fopen must be enabled in php.ini. So Data URLs were no options.

But when I rechecked the staging system and accessed the WSDL, I noticed that the server served it as a download. After some more googling, I finally found the answer I was looking for:

The Mime-Type of a WSDL file has to be application/wsdl+xml

https://media.giphy.com/media/27EhcDHnlkw1O/source.gif

So I just needed to add the Content-Type header to the action I created that is delivering the rendered WSDL file:

$response->headers->set('Content-Type', 'application/wsdl+xml');

Conclusion

With the method above, I’ve accomplished to replace an abandoned SOAP bundle by a native PHP solution. Additionally, I created some functional tests to make sure the SOAP endpoints still work the same, and I extracted the logic from the controller. By the latter, I was able to declare the specific dependencies and make the actions testable with unit tests.

You can check out the exemplary code from above in this repository:

https://github.com/moritzwachter/replace-besimple-soap

All changes mentioned can be found in the pull request.

Moritz Wachter

Author Moritz Wachter

More posts by Moritz Wachter