Porting Silex to Slim, Round 2

Several months ago, I blogged about porting Silex web apps to the Slim Framework. I recently went through the same process for a slightly more complex site (NAMR.xyz). This exposed a few areas that were missed in the previous post.

Functional test cases, service providers, and error handlers are among the things that Slim does differently than Silex. Here's my approach for moving things over.

WebTestCase in Slim

Silex provides the WebTestCase class to make it easier to simulate browser testing inside PHPUnit. Slim does not ship with such a class, but the concepts that Slim uses (such as PSR-7) make it possible to craft a suitable replacement.

When I was porting NAMR.xyz, I accidentally did things backwards and didn't port the tests until I was finished porting all the routes. Don't be me! Learn from my mistakes! Your tests will guide you through getting everything working.

Although these tests are written using PHPUnit, they are not true unit tests. Your actual unit tests should run fine with zero changes, because such tests would rarely touch framework code. Tests that use WebTestCase simulate web requests like GET and POST in order to capture what the application's response would be. Then, the test asserts that things like the HTTP status code and response body content match expected values.

Because this simulation happens entirely inside PHP, things like JS or other front-end considerations are not executed. If you want your test suite to test front-end interactions, you'd want to integrate a tool like Nightwatch.js into your flow.

My Silex tests use Symfony components like BrowserKit, DomCrawler, and CssSelector to virtually navigate the website's DOM and assert that it meets expectations. I ended up creating my own SlimTestCase and SlimTestClient classes to get things working the way they used to under Silex.

This work was partly based on Rob Allen's guide to Testing Slim Framework Actions.

SlimTestCase.php

This new abstract class inherits from the standard PHPunit TestCase class and declares an abstract createApplication() function that subclasses will have to implement. It also specifies a createClient() method that wraps the application object in a SlimTestClient class.

<?php

namespace MyNamespace;

use PHPUnit\Framework\TestCase;
use Slim\App;

abstract class SlimTestCase extends TestCase {
    /** @var App */
    protected $app;

    // implement this method to bootstrap your
    // application's test environment
    abstract protected function createApplication();

    protected function createClient()
    {
        if (!$this->app) {
            $this->app = $this->createApplication();
        }

        return new SlimTestClient($this->app);
    }
}

Further research has shown that the Slim Skeleton includes a class named BaseTestCase which would offer a Slim-specific approach for this type of testing.

SlimTestClient.php

This class receives a call to ->request() and returns a DomCrawler instance. It can also return the raw Response object if needed.

<?php

namespace MyNamespace;

use Slim\App;
use Slim\Http\Environment;
use Slim\Http\Request;
use Slim\Http\Response;
use Symfony\Component\DomCrawler\Crawler;

class SlimTestClient
{
    /** @var App */
    public $app;

    /** @var Response */
    public $response;

    public function __construct(App $app)
    {
        $this->app = $app;
    }

    public function request($method, $uri, $queryString = '')
    {
        $request = Request::createFromEnvironment(new Environment([
            'REQUEST_METHOD' => $method,
            'REQUEST_URI'    => $uri,
            'QUERY_STRING'   => $queryString,
        ]));

        $this->response = new Response();

        $this->response = $this->app->process($request, $this->response);

        $crawler = new Crawler(null, $uri);
        $crawler->addContent(
            (string)$this->response->getBody(),
            $this->response->getHeaderLine('Content-type')
        );

        return $crawler;
    }

    public function getResponse()
    {
        return $this->response;
    }
}

Porting Existing Tests

Porting existing tests involved changing the classes to inherit from SlimTestCase instead of WebTestCase. I also had to treat the Response object a little bit differently - you can see an example of that in the ->request(...) method above. Getting the string output from the response required casting to string; the helper method that I expected to do this for me ($response->getBody()->getContent()) turned out to be doing something else entirely.

Silex's response object had a version of ->getContent() built in, and I think this is what sent me down the wrong road at first.

// Silex
$this->assertContains('Coming Soon', $response->getContent());

Becomes:

// Slim Framework
$this->assertContains('Coming Soon!', (string)$response->getBody());

If I had put more time into SlimTestClient, I could have made it more 1:1 compatible with WebTestClient, but one thing it does differently is requests.

// Silex
$crawler = $client->request('GET', '/my-route/?items=2');

Becomes:

// Slim Framework
$crawler = $client->request('GET', '/my-route/', 'items=2');

Those were actually the only changes I needed. I have other projects that might require additional work to get going with this, such as POST-ing, following redirects, and grabbing links/forms from the HTML source. Maybe at that point I'll spin up a SlimTestCase composer package, if one doesn't already exist.

With your tests up and running, you'll be better equipped than I was to approach the next steps in the porting process.

Service Providers

This time around, the site was using two Silex service providers. I needed to find a Slim equivalent for both. This turned out to be fairly well documented for one service (Twig), but less so for the other (Doctrine DBAL).

Twig Views

The Twig Service Provider in Silex configures a bunch of services that ultimately expose Twig as $app['twig']. Controllers then access that service to render their results.

Instead of exposing the service by technology name, SlimPHP exposes the service by the name of the concept: views.

The slim/twig-view component performs very similar tasks to the Twig service provider.

$ composer require slim/twig-view

Then, in bootstrap.php:

$container['view'] = function ($container) {
    // instantiate Twig service with caching disabled
    // ‘auto_reload’ option also exists for development
    $view = new \Slim\Views\Twig(
        __DIR__ . '/views',
        ['cache' => false]
    );

    // add Slim-specific Twig extension
    $router = $container->get('router');
    $uri = \Slim\Http\Uri::createFromEnvironment(new \Slim\Http\Environment($_SERVER));
    $view->addExtension(new Slim\Views\TwigExtension($router, $uri));

    return $view;
};

Now that the twig-view component is configured, it's time to rethink the way controllers access Twig.

The way to render a view in Slim is to access the "view" service and pass it the Response object, the path to the template, and the view data.

Slim uses the Response object to fetch a StreamInterface for the response's body. Then, it fetches a rendered template and passes it to the ->write() method on the body's stream.

In Silex, it wasn't necessary to pass the Response object into Twig. As a result of that, all existing calls will need to be updated not only for the new way of accessing services, but also to include the new parameter.

Here is the main way I've found to do this:

return $this->view->render($response, 'view.html.twig', $data);

The Slim app object uses the __invoke magic method to see if requested services exist in the Container and can accept incoming calls. This is a convenient way to avoid having to pass the container itself around, in some cases.

If for some reason the controller method doesn't have access to the main Slim app object, this approach also works:

return $container['view']->render($response, 'view.html.twig', $data);

Doctrine DBAL

Slim has support for the full Doctrine ORM, but my projects tend to use Doctrine DBAL (Database Abstraction Layer), which provides a subset of the full ORM's features.

Exposing the DBAL in the code in the same way that I was consuming it in Silex meant reading the service provider source code and implementing a similar (but heavily simplified) version.

I didn't need support for multiple databases, and I only needed to add a single SQLite configuration:

$container['db'] = function () {
    $options = [
        'driver' => 'pdo_sqlite',
        'path'   => __DIR__ . '/data.db'
    ];

    $config  = new \Doctrine\DBAL\Configuration();
    $manager = new \Doctrine\Common\EventManager();

    return \Doctrine\DBAL\DriverManager::getConnection(
        $options,
        $config,
        $manager
    );
};

Once this was in place, I was able to convert my database method calls to use $this->db->... (or $container['db']->...).

Error Handlers

Silex treats error handler registration as a method on the Application object. Slim, on the other hand, refers to error handlers that are registered in the Container using reserved service names.

In order to port over the error handler I had been using in Silex, I actually ended up making two slightly different versions of my old error handler: one for 404 pages, and a separate one for other errors.

Both of them follow the same pattern of storing a lambda function that itself returns a lambda function. The returned function is then passed the Request and Response object (and for errors, the Exception).

Not Found Handler

$container['notFoundHandler'] = function ($container) {
    return function ($request, $response) use ($container) {
        return $container['view']->render(
            $response,
            'error.html.twig',
            [
                'message'   => 'Not Found.',
                'exception' => 'Not Found.',
                'code'      => 404,
            ]
        )->withStatus(404);
    };
};

Error Handler

$container['errorHandler'] = function ($container) {
    return function ($request, $response, $exception) use ($container) {
        return $container['view']->render(
            $response,
            'error.html.twig',
            [
                'message'   => 'An error has occurred.',
                'exception' => $exception,
                'code'      => 500,
            ]
        )->withStatus(500);
    };
};

Conclusion

Overall it only took about an hour to port the code over, with most of that being devoted to reading the Doctrine service provider's sourcecode to find the quickest way to get it spun up inside Slim.

The tests, which I should have done first but face-palmingly shoehorned in at the end, took an additional 45 minutes.

Hopefully this saves you some time. Thanks for reading!

Published: January 27, 2019

Categories: coding, howto

Tags: coding, dev, development, howto, legacy, php, projects, silex, slim, maintenance

comments powered by Disqus