Saturday 25 April 2015

PHP: Silex Controller Providers

G'day:
I'm getting to this earlier than expected (see my observation in yesterday's article: "PHP: Service Providers with Silex")... I was able to write the code last night whilst watching telly, and now I'm stuck on a train due to "signal failure", so have a few (hopefully just a few ~) minutes to start writing this on my phone. Apologies for any autocorruptions I miss during proofreading.

OK, so what's a Controller Provider. Well, for about the first thousand times I read the docs, I could still not answer that. Obviously one can kinda guess from the name: it provides controllers. Ooookay... but what's the ramifications of that, and why would I choose this approach to "providing controllers" over any other way? Obviously it seems to be Silex's prefers way, but they need to do a better sell job on me than that. But the docs weren't doing it. Furthermore, there's bugger-all useful material out there about them (that I could find, anyhow). All the articles I found just regurgitated the docs, or paraphrased them. And the docs just explain "how", but never really explains "why". This is a failing of a lot of docs... explaining the "how" or "what" is easy, but it's the "why" that's important for a given feature. Explain the "why" first, then the "what". Why do I care about this thing you're banging on about? If I'm left with "err... dunno?", that's a fail.



Anyway, drawing from a could statements on different web pages, I managed to have a eureka moment on the train to Heathrow last Fri.

[TFL update: now at my desk at work... train was only halted for those three paragraphs. All typos here on in are just me being rubbish]

[Further update: I didn't get a chance to finish this on Friday, so indeed, I'll be "pressing send" on this on Saturday morning]

Routing

In an earlier article ("PHP: messing around with Silex, Pimple & Guzzle") I documented how I configured all an application's routing using a YAML config file, eg:

# routes.yml
_hello:
    path:        /hello/{name}
    defaults:    { _controller: "controllers.hello:doGet"}

_user:
    path:        /user/{id}
    defaults:    { _controller: "controllers.user:getUser"}
    requirements:
        id:  \d+

I lifted this idea from someone else's blog ("Scaling Silex applications (part II). Using RouteCollection"), and thusfar from all the ways I've seen to configure routing, this remains the best. To me routing is config, not code, so it belongs in a config file, not in PHP files.

I mention this because Controller Providers handle... routing.

Binding routes to controllers

The docs page doesn't - at any point - mention this. Kinda important information, in the given context, I think. Once I "got" that, they started to make some sense, but I still had to mess around quite a bit to finally "get" how they work, and come up with a real-world (-ish) example.

Application


OK, so I have a fairly standard Silex Application file here:

<?php
// Application.php
namespace me\adamcameron\controllerprovider\app;

use Silex;
use me\adamcameron\controllerprovider\provider;

use Silex\Application as SilexApplication;

class Application extends SilexApplication {

    function __construct(){
        parent::__construct();
        $this['debug'] = true;
        $this->registerProviders();
        $this->mountControllers();
    }

    function registerProviders(){
        $this->register(new Silex\Provider\ServiceControllerServiceProvider());
        $this->register(new Silex\Provider\TwigServiceProvider(), [
            "twig.path" => __DIR__ . '\..\views'
        ]);
        $this->register(new provider\service\Controllers());
        $this->register(new provider\service\ControllerProviders());
    }

    function mountControllers(){
        $this->mount('/', $this["provider.controller.home"]);
        $this->mount('/user', $this["provider.controller.user"]);
    }

}

A bunch of this is irrelevant.


  • We're using Service Providers to configure our services again (as per the article a coupla days back: "PHP: Service Providers with Silex"),
  • and my controller providers are being treated as services as well.
  • As are my controllers.
  • The new thing here is the call to mountControllers(),
  • which calls the Silex app's own mount() method which…
  • binds the base part of a route to a controller provider.

To me, this was the missing piece of the puzzle that I just did not get, especially when one sees an example of a controller provider, which also has routing stuff in it. Confusing. For me.

(Please note, I do not make up the names of these things. I need to register this so I can use Pimple to define Controllers and then use the Pimple references in the Controller Providers... I'll get to that. I have no idea why I need to do this... but the obscure Silex docs says I need to. Well: one can interpret the docs that way, and it seems to be the case).

Basically the mount() method says "for this "base" part of the route, use this Controller Provider to do all the routing for any subsequent fragments of the route". This will become clear when I show a Controller Provider.

Controller Provider


<?php
// User.php
namespace me\adamcameron\controllerprovider\provider\controller;

use Silex;
use Silex\ControllerProviderInterface;

class User implements ControllerProviderInterface {

    public function connect(Silex\Application $app){
        $controllers = $app['controllers_factory'];

        $controllers->get('create/', 'controller.user.create:doGet')
            ->method('GET')
            ->bind('route.user.create.get');

        $controllers->get('create/', 'controller.user.create:doPost')
            ->method('POST')
            ->bind('route.user.create.post');

        $controllers->get('read/{id}/', 'controller.user.read:readOne')
            ->method('GET')
            ->bind('route.user.read.one');

        $controllers->get('read/', 'controller.user.read:readAll')
            ->method('GET')
            ->bind('route.user.read.all');

        $controllers->get('update/', 'controller.user.update:doGet')
            ->method('GET')
            ->bind('route.user.update.get');

        $controllers->get('update/', 'controller.user.update:doPost')
            ->method('POST')
            ->bind('route.user.update.post');

        $controllers->get('delete/', 'controller.user.delete:doPost')
            ->method('POST')
            ->bind('route.user.delete.post');

        return $controllers;
    }

}

And the relevant mount() call again:

$this->mount('/user', $this["provider.controller.user"]);


Basically for this user system, I have the following routes on the site: /user/create/, /user/read/, /user/update/, /user/delete/. So within Silex's routing management code (I've not looked at this yet), when it encounters /user/[anything] it knows to use the User Controller Provider to do any further route resolution, and then within that provider if the route is /user/create/, and it happens to be a POST request, then the doPost() method in Create controller in the user package.

Pimple

BTW I'm using Pimple here for DI, so the references like controller.user.create are back to the relevant dependency definition in the relevant Service Provider, in this case:

<?php
// Controllers.php
namespace me\adamcameron\controllerprovider\provider\service;

use Silex;
use me\adamcameron\controllerprovider\controller;

class Controllers extends Base {

    public function register(Silex\Application $app) {
        $app["controller.home"] = $app->share(function() {
            return new controller\Home();
        });
        $app["controller.user.create"] = $app->share(function() {
            return new controller\user\Create();
        });
        $app["controller.user.read"] = $app->share(function() {
            return new controller\user\Read();
        });
        $app["controller.user.update"] = $app->share(function() {
            return new controller\user\Update();
        });
        $app["controller.user.delete"] = $app->share(function() {
            return new controller\user\Delete();
        });
    }

}

Similarly for the controller I mention provider.controller.user for the User Controller Provider, that's set in here:

<?php
// ControllerProviders.php
namespace me\adamcameron\controllerprovider\provider\service;

use Silex;
use me\adamcameron\controllerprovider\provider\controller;

class ControllerProviders extends Base {

    public function register(Silex\Application $app) {
        $app["provider.controller.home"] = $app->share(function() {
            return new controller\Home();
        });
        $app["provider.controller.user"] = $app->share(function() {
            return new controller\User();
        });
    }

}

Remember the service providers we mounted in Application:

function registerProviders(){
    $this->register(new Silex\Provider\ServiceControllerServiceProvider());
    $this->register(new Silex\Provider\TwigServiceProvider(), [
        "twig.path" => __DIR__ . '\..\views'
    ]);
    $this->register(new provider\service\Controllers());
    $this->register(new provider\service\ControllerProviders());
}

So when I submit my user-creation form and it POSTs to /user/create/, Silex finds that /user/ requests are handled by the controllers provided for in src/provider/controller/User.php, and that defines that the /create/ post requests are handled by the src/controller/user/Create.php controller. Specifically its doPost() method (the name here is arbitrary).

Controller


Back in the controller, here's the doPost() method:

public static function doPost(Request $request, Application $app){
    return $app['twig']->render('message.html.twig', ['msg'=>"Create::doPost()"]);
}

And message.html.twig just displays that message:
{# message.html.twig #}
<h1>{{ msg }}</h1>

(not a very helpful way to deal with a form submission, I know).

But...

I think this is a poor way of going about things for a coupla reasons:

Route splitting

It splits any given route into the base fragment that needs to be passed into the mount() call:
$this->mount('/user', $this["provider.controller.user"]);

And then the bit that comes after that which is found in the controller provider:

$controllers->get('create/', 'controller.user.create:doPost')
    ->method('POST')
    ->bind('route.user.create.post');

I think that's clunky.

Config as code

Routing is configuration: this route, this method, uses this controller. It should not need programming logic to express that. The YAML approach is much more clear, and dispenses with all the boilerplate:

_user_create_get:
    path:        /user/create
    methods:     GET    
    defaults:    { _controller: "controller.user.create:doGet"}
    
_user_create_post:
    path:        /user/create
    methods:     POST    
    defaults:    { _controller: "controller.user.create:doPost"}

_user_read_one:
    path:        /user/read/{id}
    methods:     GET    
    defaults:    { _controller: "controller.user.read:readOne"}
    
_user_read_all:
    path:        /user/read/
    methods:     POST    
    defaults:    { _controller: "controller.user.read:readAll"}

_user_update_get:
    path:        /user/update
    methods:     GET    
    defaults:    { _controller: "controller.user.update:doGet"}
    
_user_update_post:
    path:        /user/update
    methods:     POST    
    defaults:    { _controller: "controller.user.update:doPost"}
    
_user_delete_post:
    path:        /user/delete
    methods:     POST    
    defaults:    { _controller: "controller.user.delete:doPost"}

Pass this to the YAML route loader, and it does the same thing as all that crap with Controller Providers. It's much clearer in what it does, and properly separates config from code. The code only needs to be written once, rather than repeatedly for every single route->controller method mapping.

Conclusion

This approach - "the Silex way" - is better than the current approach we have in our system which is to have one class for every single route (so that's a lot of classes), and those classes are either just some standard property values (so like the YAML version, except using code), or specific handler methods, like the Controller Provider approach. But if we were to change our approach - I hope we do - I'd skip "the Silex Way", and do it via the YAML approach.

Still: I'm pleased I now know what they're on about, and can make an informed decision as to whether they should be part of any solution I undertake. No.

Oh, it might be beneficial to have a look at all the files for this example in context. There's a working (very simple) example here: php.controllerprovider.local. Grab it, run composer install on it, and it should be browsable. But for the sake of completeness, this is the general file layout:

/src/
  /app/
    Application.php
  /controller/
    Home.php
    /user/
      Create.php
      Delete.php
      Read.php
      Update.php
  /provider/
    /controller/
      Home.php
      User.php
    /service/
      Base.php
      ControllerProviders.php
      Controllers.php
  /views/
    message.html.twig


Righto.

--
Adam