Slim Framework: routes and controllers

12,088

I guess I can share what I did with you guys. I noticed that every route method in Slim\Slim at some point called the method mapRoute

(I changed the indentation of the official source code for clarity)

Slim.php

 protected function mapRoute($args)
    {
        $pattern = array_shift($args);
        $callable = array_pop($args);

        $route = new \Slim\Route(
              $pattern, 
              $callable, 
              $this->settings['routes.case_sensitive']
        );
        $this->router->map($route);
        if (count($args) > 0) {
            $route->setMiddleware($args);
        }

        return $route;
    }

In turn, the Slim\Route constructor called setCallable

Route.php

public function setCallable($callable)
{
    $matches = [];
    $app = $this->app;
    if (
         is_string($callable) && 
         preg_match(
           '!^([^\:]+)\:([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)$!', 
           $callable, 
           $matches
         )
       ) {
            $class = $matches[1];
            $method = $matches[2];
            $callable = function () use ($class, $method) {
                static $obj = null;
                if ($obj === null) {
                    $obj = new $class;
                }
                return call_user_func_array([$obj, $method], func_get_args());
            };
        }

        if (!is_callable($callable)) {
            throw new \InvalidArgumentException('Route callable must be callable');
        }

        $this->callable = $callable;
    }

Which is basically

  • If $callable is a string and (mind the single colon) has the format ClassName:method then it's non static, so Slim will instantiate the class and then call the method on it.
  • If it's not callable, then throw an exception (reasonable enough)
  • Otherwise, whatever it is (ClassName::staticMethod, closure, function name) it will be used as-is.

ClassName should be the FQCN, so it's more like \MyProject\Controllers\ClassName.

The point where the controller (or whatever) is instantiated was a good opportunity to inject the App instance. So, for starters, I overrode mapRoute to inject the app instance to it:

\Util\MySlim

 protected function mapRoute($args)
    {
        $pattern = array_shift($args);
        $callable = array_pop($args);

        $route = new \Util\MyRoute(
            $this, // <-- now my routes have a reference to the App
            $pattern, 
            $callable, 
            $this->settings['routes.case_sensitive']
        );
        $this->router->map($route);
        if (count($args) > 0) {
            $route->setMiddleware($args);
        }

        return $route;
    }

So basically \Util\MyRoute is \Slim\Route with an extra parameter in its constructor that I store as $this->app

At this point, getCallable can inject the app into every controller that needs to be instantiated

\Util\MyRoute.php

public function setCallable($callable)
{
    $matches = [];
    $app = $this->app;
    if (
       is_string($callable) && 
       preg_match(
          '!^([^\:]+)\:([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)$!', 
          $callable, 
          $matches
          )
       ) {
        $class = $matches[1];
        $method = $matches[2];

        $callable = function () use ($app, $class, $method) {
            static $obj = null;
            if ($obj === null) {
                $obj = new $class($app); // <--- now they have the App too!!
            }
            return call_user_func_array([$obj, $method], func_get_args());
        };
    }

    if (!is_callable($callable)) {
        throw new \InvalidArgumentException('Route callable must be callable');
    }

    $this->callable = $callable;
}

So there it is. Using this two classes I can have $app injected into whatever Controller I declare on the route, as long as I use a single colon to separate controller from method. Using paamayim nekudotayim will call the method as static and therefore will throw an error if I try to access $this->app inside it.

I ran tests using blackfire.io and... the performance gain is negligible.

Pros:

  • this saves me the pain of calling $app = \Slim\Slim::getInstance() on every static method call accounting for about 100 lines of text overall.
  • it opens the way for further optimization by making every controller inherit from an abstract controller class, which in turn wraps the app methods into convenience methods.
  • it made me understand Slim's request and response lifecycle a little better.

Cons:

  • performance gains are negligible
  • you have to convert all your routes to use a single colon instead of paamayin, and all your controller methods from static to dynamic.
  • inheritance from Slim base classes might break when they roll out v 3.0.0

Epilogue: (4 years later)

In Slim v3 they removed the static accessor. In turn, the controllers are instantiated with the app's container, if you use the same convention FQCN\ClassName:method. Also, the method receives the request, response and $args from the route. Such DI, much IoC. I like it a lot.

Looking back on my approach for Slim 2, it broke the most basic principle of drop in replacement (Liskov Substitution).

class Route extends \Slim\Route
{
  protected $app;
  public function __construct($app, $pattern, $callable, $caseSensitive = true) {
   ...
   }
}

It should have been

class Route extends \Slim\Route
{
  protected $app;
  public function __construct($pattern, $callable, $caseSensitive = true, $app = null) {
   ...
   }
}

So it wouldn't break the contract and could be used transparently.

Share:
12,088
ffflabs
Author by

ffflabs

Chilean entrepreneur, helping companies embrace change and digital transformation. Devoted dad and husband, dog lover, and former world champion of the bottle dance. NPM packages Composer Packages Github Profile CodersRank Profile Linkedin Profile I love developing and contributing to open source libraries. Take a look at my open source organization projects at HuasoFoundries

Updated on June 27, 2022

Comments

  • ffflabs
    ffflabs almost 2 years

    Originally, my Slim Framework app had the classic structure

    (index.php)

    <?php
    $app = new \Slim\Slim();
    $app->get('/hello/:name', function ($name) {
        echo "Hello, $name";
    });
    $app->run();
    

    But as I added more routes and groups of routes, I moved to a controller based approach:

    index.php

    <?php
    $app = new \Slim\Slim();
    $app->get('/hello/:name', 'HelloController::hello');
    $app->run();
    

    HelloController.php

    <?php
    class HelloController {
        public static function hello($name) {
            echo "Hello, $name";
        }
    }
    

    This works, and it had been helpful to organize my app structure, while at the same time lets me build unit tests for each controler method.

    However, I'm not sure this is the right way. I feel like I'm mocking Silex's mount method on a sui generis basis, and that can't be good. Using the $app context inside each Controller method requires me to use \Slim\Slim::getInstance(), which seems less efficient than just using $app like a closure can.

    So... is there a solution allowing for both efficiency and order, or does efficiency come at the cost of route/closure nightmare?