Reinventing a PHP MVC framework, part 3 of 4

#php #mvc #learning #reverse-engineering

Written by Anders Marzi Tornblad

This is part 3 of the Reinventing a PHP MVC framework series. If you haven't read the first part, here it is: Reinventing a PHP MVC framework, part 1 of 4

Let's add some spokes to the wheel

When an HTTP request is handled by ASP.NET MVC, a factory called DefaultControllerFactory goes to work. Its CreateController method looks through the web app's controller classes using reflection, finds the right one and creates an instance. Then the infrastructure takes a look at which method to call, again using reflection, and the method gets called. The result, some ActionResult subclass, then produces the correct output.

The mechanism relies heavily on reflection which is really difficult to unit-test, because you would have to mock the entire class system and file system. So this part is not developed using TDD, but write-and-debug. I'm sorry!

PHP doesn't have precompiled assemblies that the infrastructure can search through to find the correct class. Instead we need to take a convention over configuration approach, and pick some standards to enforce. Using PHP's autoload capabilities, we can write an autoloader specifically for controllers. The "controller factory" then simply turns into a call to class_exists. The autoloader is detailed in a future article.

Target in sight

The next piece of the puzzle is what I would like to call the MVC framework itself - a class that binds request, routing and response together. The request and response are mocked, and the workflow looks a little like this:

The routing bit is already in place, albeit not complete for non-trivial production purposes. The next step is creating an input model builder, and here are the first set of tests for it:

// More unit tests

class InputModelBuilderTests {
    public function ReturnsNullForMethodWithoutParameters() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('NoParametersMethod');
        
        // No expected calls to request or route!
        Expect($route);
        Expect($request);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        The($result)->ShouldEqual([]);
    }
    
    public function ReturnsRouteParameterForSimpleParameterMethod() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('SimpleParameterMethod');
        
        // No expected calls to request!
        Expect($route)->toGet('parameter', 'abc.123');
        Expect($request);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        $route->checkAll();
        $request->checkAll();
        
        The($result)->ShouldEqual(['abc.123']);
    }
    
    public function ReturnsPostIdForSimpleParameterMethod() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('SimpleParameterMethod');
        
        Expect($route)->toGet('parameter', null);
        Expect($request)->toGet('post', ['id' => 'abc.123']);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        $route->checkAll();
        $request->checkAll();
        
        The($result)->ShouldEqual(['abc.123']);
    }
    
    public function ReturnsPostDataForTwoSimpleParametersMethod() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('TwoSimpleParametersMethod');
        
        // No expected calls to route!
        Expect($route);
        Expect($request)->toGet('post', ['bar' => 'QWER', 'foo' => 'ASDF']);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        $route->checkAll();
        $request->checkAll();
        
        The($result)->ShouldEqual(['ASDF', 'QWER']);
    }
    
    public function ReturnsNonprefixedPostDataForComplexParameterMethod() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('SmallInputModelMethod');
        
        // No expected calls to route!
        Expect($route);
        Expect($request)->toGet('post', ['bar' => 'QWER', 'foo' => 'ASDF']);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        $route->checkAll();
        $request->checkAll();
        
        The(count($result))->ShouldBeExactly(1);
        The($result[0])->ShouldBeInstanceOf(\ImbtSmallInputModel::class);
        The($result[0]->foo)->ShouldEqual('ASDF');
        The($result[0]->bar)->ShouldEqual('QWER');
    }
    
    public function ReturnsPrefixedPostDataForComplexParameterMethod() {
        $class = new \ReflectionClass(ImbtControllerDummy::class);
        $method = $class->getMethod('SmallInputModelMethod');
        
        // No expected calls to route!
        Expect($route);
        Expect($request)->toGet('post', ['model-bar' => 'QWER', 'model-foo' => 'ASDF']);
        
        $builder = new InputModelBuilder();
        $result = $builder->buildInputModel($method, $request, $route);
        
        $route->checkAll();
        $request->checkAll();
        
        The(count($result))->ShouldBeExactly(1);
        The($result[0])->ShouldBeInstanceOf(\ImbtSmallInputModel::class);
        The($result[0]->foo)->ShouldEqual('ASDF');
        The($result[0]->bar)->ShouldEqual('QWER');
    }
}

class ImbtControllerDummy {
    public function NoParametersMethod() {
    }
    
    public function SimpleParameterMethod($id) {
    }
    
    public function TwoSimpleParametersMethod($foo, $bar) {
    }
    
    public function SmallInputModelMethod(ImbtSmallInputModel $model) {
    }
}

class ImbtSmallInputModel {
    public $foo;
    public $bar;
}

Making these tests pass, requires less code that you would think, but I have to admit that we have strayed a little from the ASP.NET path now. This is intentional. I want the ease-of-use of ASP.NET, but also want to add some ideas of my own.

// InputModelBuilder implementation

class InputModelBuilder {
    public function buildInputModel(ReflectionMethod $method, $request, $route) {
        $parameters = $method->getParameters();
        $parameterCount = count($parameters);
        
        if ($parameterCount === 0) {
            return [];
        }
        
        $result = [];
        
        foreach ($parameters as $index => $parameter) {
            $typeHint = $parameter->getClass();
            $name = $parameter->getName();
            
            // Trivial single-value input model named $id:
            if ($name == 'id' && !isset($typeHint) && $parameterCount === 1) {
                // This is the only time the $route->parameter is used!
                $value = @$route->parameter;
                
                if (isset($value)) {
                    return[$value];
                }
            }
            
            if (!isset($postData)) $postData = $request->post;
            
            if (!isset($typeHint)) {
                // Trivial single value from post
                if (isset($postData[$name])) {
                    $result[] = $postData[$name];
                }
            } else {
                // Type-hinted value
                $result[] = $this->buildTypeHintedObject($name, $typeHint, $postData);
            }
        }
        
        return $result;
    }
    
    private function buildTypeHintedObject($optionalPrefix, ReflectionClass $typeHint, array $postData) {
        $className = $typeHint->getName();
        $result = new $className;
        
        $properties = $typeHint->getProperties();
        foreach ($properties as $property) {
            $name = $property->getName();
            
            $prefixedName = "$optionalPrefix-$name";
            
            if (isset($postData[$prefixedName])) {
                $property->setValue($result, $postData[$prefixedName]);
            } else if (isset($postData[$name])) {
                $property->setValue($result, $postData[$name]);
            }
        }
        
        return $result;
    }
}

The bits and pieces are starting to fall into place, but they all need to be put together. Come back here in a while for more on that.

You'll find the code from this article in the related release on GitHub. The latest version of the code is always available in the GitHub repository.

Articles in this series: