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
Tags: coding, dev, development, howto, legacy, php, projects, silex, slim, maintenance