Summary

The Mvc5 Framework provides a fresh approach to PHP programming. It is written as a functional object-oriented system with immutable components. At its core is a next generation dependency injection plugin system.

Web Application

The mvc5-application demonstrates its usage as a web application.

include __DIR__ . '/../vendor/autoload.php';
return [
    'cache' => __DIR__ . '/../tmp',
    'cookie' => include __DIR__ . '/cookie.php',
    'events' => include __DIR__ . '/event.php',
    'middleware' => include __DIR__ . '/middleware.php',
    'routes' => include __DIR__ . '/route.php',
    'services' => include __DIR__ . '/service.php',
    'session' => include __DIR__ . '/session.php',
    'templates' => include __DIR__ . '/template.php',
    'view' => __DIR__ . '/../view',
    'web' => include __DIR__ . '/web.php'
];
(new App(include __DIR__ . '/../config/config.php', null, true))(
    new Expect(new Call('web'), new Call('exception\response'), true)
);

A default configuration is provided with the minimum configuration required to run a web application. It contains configurations for PSR-7 compatible request and response classes, templates and routes. The third parameter of the application class binds the scope of anonymous functions within the service configuration to the application class; this allows the anonymous functions to be used as service factory methods. The application is invoked with an expect plugin that calls the web function. If an exception is thrown, it is caught and passed to the second plugin to resolve.

Console Application

A console application can be created by passing command line arguments to the service call method.

./app.php 'Console\Example' Monday January
(new App('./config/config.php'))->call($argv[1], array_slice($argv, 2));

The first argument is the name of the function to call and the remaining arguments are its parameters, e.g Console\Example.

namespace Console;

use Home\ViewModel;

class Example
{
    protected ViewModel $model;

    function __construct(ViewModel $model)
    {
        $this->model = $model;
    }

    function __invoke($day, $month)
    {
        echo $this->model->message . ': ' . $day . ' ' . $month . "\n";
    }
}

An application can also work without a configuration.

(new App)->call($argv[1], array_slice($argv, 2));

Read more about dependency injection and autowiring.

Environment Aware

Each configuration file returns an array of values that can be merged together. For example, the development environment configuration file config/dev/config.php can include the production configuration file config/config.php and override the name of the database.

return array_merge(include __DIR__ . '/../config.php', ['db_name' => 'dev']);

Models and ArrayAccess

Value objects use a model interface to provide a common set of access methods.

namespace Mvc5\Config;

interface Model
    extends \ArrayAccess, \Countable, \Iterator
{
    /**
     * @param array|string $name
     */
    function get($name);

    /**
     * @param array|string $name
     */
    function has($name) : bool;

    /**
     * @param array|string $name
     * @param mixed $value
     */
    function with($name, $value = null);

    /**
     * @param array|string $name
     */
    function without($name);
}

The model interface is then extended to provide a configuration interface containing the set and remove methods.

namespace Mvc5\Config;

interface Configuration
    extends Model
{
    /**
     * @param array|string $name
     */
    function remove($name) : void;
    
    /**
     * @param array|string $name
     * @param mixed $value
     */
    function set($name, $value = null);
}

Set and Remove

Multiple values can be set or removed by using an array.

$config->set('foo', 'bar');
$config->set(['foo' => 'bar', 'baz' => 'bat']);
$config->remove('foo');
$config->remove(['foo', 'baz']);

Immutable

By protecting access to the mutable ArrayAccess methods and object magic methods an immutable interface can be implemented.

namespace Mvc5\Config;

trait ReadOnly
{
    use Config {
        remove as protected;
        set as protected;
    }

    function offsetSet($name, $value)
    {
        throw new \Exception;
    }

    function offsetUnset($name) : void
    {
        throw new \Exception;
    }

    function __set($name, $value)
    {
        throw new \Exception;
    }

    function __unset($name) : void
    {
        throw new \Exception;
    }
}

Implementing the model interface allows a component to specify only its immutable methods.

interface Route
    extends Model
{
    function controller();
    function path();
}

With and Without

A copy of the model can be created with and without specific values. It also accepts an array of key values so that only one clone operation is performed.

$this->with(['foo' => 'bar', 'baz' => 'bat']);
$this->without(['foo', 'baz']);

ArrayAccess

Models support the ArrayAccess interface. This, for example, enables the service manager to retrieve composite values. E.g.

new Param('templates.error');

Resolves to

$config['templates']['error'];

This makes it possible to use an array or a configuration class when a reference is required.

Polymorphism

Occasionally, a single instance of a model is necessary within an immutable system. For example, when rendering a view template that modifies a shared layout model in order to set the title of the web page. In this case, a polymorphic model can be used to assign values directly to itself, instead of assigning them to a copy.

class SharedLayout
    extends Overload
    implements ViewLayout
{
    /**
     * @param array|string $name
     * @param mixed $value
     * @return ViewLayout
     */
    function with($name, $value = null) : ViewLayout
    {
        $this->set($name, $value);
        return $this;
    }
}

Routes

A collection of routes are used to match a request using middleware route components. Each aspect of matching a route is a separate function. For example, scheme, host, path, method, CSRF Token, wildcard or any other function can be configured.

return [
    'home' => [
        'path' => '/{$}'
        'regex' => '/$'
        'controller' => 'Home\Controller'
    ],
    'dashboard' => [
        'path' => '/dashboard/{user}',
        'controller' => 'dashboard->controller.test',
    ]
]

Routes can be configured with a regular expression or a path. If the route is only used to match the request path, then only a regular expression is required. If the route is also used to create a url, then a path configuration is required. If a route does not contain a regular expression, it will be created from the path configuration before being matched.

When a route is matched, the route’s controller configuration is assigned to the request. However, a route does not always require a controller configuration, it can also have a middleware configuration and an automatic route configuration.

Custom routes can also be configured by adding a class name to the array, or the configuration can be a route object containing a regular expression (and optionally a path configuration).

Regular Expressions

The regular expression for a route can use group names that are assigned as parameters to the request when the route is matched. Parameter names must be alphanumeric and the path configuration provides a simpler format for specifying a regular expression and group names. Aliases can be assigned to a regular expression and be used in a path configuration by prefixing them with a single colon or two colons when assigned to a parameter name. Below are the default aliases available to use.

[
    'a' => '[a-zA-Z0-9]++',
    'i' => '[0-9]++',
    'n' => '[a-zA-Z][a-zA-Z0-9]++',
    's' => '[a-zA-Z0-9_-]++',
    '*' => '.++',
    '*$' => '[a-zA-Z0-9/]+[a-zA-Z0-9]$'
]

An alias or regular expression for a path configuration can also be specified as a constraint.

return [
    'app' => [
        'path' => '/{controller}',
        'constraints' => [
            'controller' => '[a-zA-Z0-9/]+[a-zA-Z0-9]$'
        ]
    ],
]

Optional parameters are enclosed with square brackets []. The syntax of the path configuration is based on the FastRoute library and the aliases are based on Klien.php. However, the route component is mainly based on the Dash library.

Automatic Routes

Controllers can be automatically matched to a url with the controller match function using a single route configuration.

return [
    'app' => [
        'defaults' => ['controller' => 'home'],
        'options' => [
            'prefix' => 'App',
            'suffix' => '\Controller',
            'strict' => false,
        ],
        'path' => '/[{controller::*$}]'
    ]
];

The controller match function will change the first letter of each word separated by a forward slash in the url into an uppercase letter. It then replaces the forward slash with a back slash to create a fully qualified class name and will try to load a matching controller. In order to ensure that it is a valid controller, the configuration should prefix a namespace and append a suffix.

Strict mode does not change the case sensitivity of the controller name. However, most urls are lower case and file names and directories typically begin with an uppercase. This prevents controllers from being auto-loaded. This can be resolved by using a service loader and having a service configuration with a matching lower case name. The service configuration will then specify the name of the class to use, e.g 'home\controller' => Home\Controller::class.

When a controller is specified, it must have a service configuration value (or a real value) that resolves to a callable type. If it does not have a service configuration and its class exists, a new instance will be created and autowired.

For example, if the matched url is /about, the value about is assigned as the controller request parameter and the controller match function then loads the App\About\Controller and assigns it to the request.

Url Generator

The url plugin can generate urls with or without a route configuration and are RFC 3986 encoded.

'dashboard' => [
    'path' => '/dashboard/{user}',
    'controller' => 'dashboard',
    'children' => [
        'add' => [
            'path' => '/add[/{wildcard::*$}]',
            'controller' => 'dashboard\add',
            'wildcard' => true,
        ]
    ]
]
echo $this->url(['dashboard', 'user' => 'phpdev']);

Route configurations must be named and child routes of the current parent route can automatically use their parent route parameters, e.g /dashboard/phpdev/add.

echo $this->url('dashboard/add');

Wild card parameters can be added by using the {wildcard::*$} route expression and enabling it with 'wildcard' => true. The parameters are then appended to the path, e.g /dashboard/phpdev/add/type/tasks and are assigned as parameters to the request when the route is matched.

echo $this->url(['dashboard/add', 'type' => 'tasks']);

Urls can also be generated without a route configuration by prefixing the path with a forward slash.

echo $this->url('/dashboard/phpdev/list', ['order' => 'desc'], '', ['absolute' => true]);

The second parameter of the url plugin function is for query string arguments, e.g /dashboard/phpdev/list?order=desc. The third parameter is for the fragment and the fourth parameter can be used to generate an absolute url; the current scheme, host and port will be used if not provided. The url plugin class can also be configured to always generate an absolute url.

Route Authentication

Routes that should only be available to logged in users can be protected by setting the authenticate route attribute to true. Child routes are automatically protected and can override the parent value.

'dashboard' => [
    'path' => '/dashboard',
    'authenticate' => true,
    'children' => [
        'add' => [
            'path' => '/add'
        ]
    ]
]

If the user is not logged in, and it is a GET request and not a json request, the current URL is stored in the session and the user is redirected to the login page. Once the user has logged in, they are redirected back to the URL that is stored in the session. The default login URL is /login, and it can be changed by setting the URL in the login\redirect\response service configuration.

'login\redirect\response' => [Mvc5\Http\HttpRedirect::class, '/login']

CSRF Token

A CSRF token is used to protect routes against CSRF attacks. A new token is generated every time a new PHP session is created for the user. The token is then added to a POST form using a hidden HTML input element. The csrf_token helper function can be used to retrieve the current token.

<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($this->csrf_token()); ?>">

The HTTP methods GET, HEAD, OPTIONS and TRACE, are considered “safe” and do not require a CSRF token. Safe HTTP methods should not be used to change the state of the application. Any other HTTP method is considered “unsafe” and requires a CSRF token to be sent with the request, either as a POST parameter, or using the X-CSRF-Token HTTP header. A 403 Forbidden HTTP Error is returned when the token is not valid.

new Request([
    'method' => 'POST', 
    'data' => ['csrf_token' => '882023fdc5f837855a...'],
    'headers' => ['X-CSRF-Token' => '882023fdc5f837855a...'],
]);

Routes can be configured not to verify the CSRF token by setting the csrf_token route attribute to false. Child routes inherit the csrf_token value of a parent route.

'api' => [
    'path' => '/api',
    'controller' => Api\Controller::class,
    'csrf_token' => false,
],

JSON API

Web services and APIs can be built using JSON requests and responses. A request with the application/json content type header is decoded automatically (JIT) into an associative array and assigned to the request as the data attribute. If the route match middleware function returns a HTTP Error, e.g 404 Not Found, it is assigned to the request by the router. The error controller will then return the HTTP Error as a JSON Response when the request accept header is application/json. Similarly, when an exception is thrown, the response\exception function will return a JSON Response containing an empty exception message. Alternatively, in development, the debug configuration parameter can be set to true to include the details of the exception.

REST API Methods

Routes can be configured with actions for specific HTTP methods. The default action is specified by the controller configuration. HEAD requests are automatically matched to the route GET method and action.

'resource' => [
    'path' => '/resource',
    'method' => ['GET', 'POST'],
    'controller' => 'Resource\Controller',
    'csrf_token' => false,
    'action' => [
        'POST' => fn(Url $url) => new Response\RedirectResponse($url(), 201)
    ]
]

Action Controller

The action controller is used to control the invocation of the controller specified by the request.

function __invoke($controller = null, array $argv = [])
{
    return $controller ? $this->call($controller, $argv) : null;
}

A controller is a function, it can also be middleware, or an event, or a plugin that resolves to a callable function. If the value returned from the controller is not null and is not a Http\Response, it will be set as the body of the response for the remaining components to transform into a value that can be sent to the client by the web function.

'web' => [
    'route\dispatch',
    'request\error',
    'request\service',
    'controller\action',
    'view\layout',
    'view\render',
    'response\status',
    'response\version',
    'response\send'
],

Middleware

The Mvc5 middleware handler is a variadic function that is not limited to just HTTP requests and responses.

function __invoke(...$args)
{
    return $this->call($this->rewind(), $args);
}

It is configured with a list of middleware functions that are chained together by each function calling a delegate function that is appended to the list of arguments passed to each middleware function.

function call($middleware, array $args = [])
{
    return $middleware ? $this->service->call($middleware, $this->params($args)) : $this->end($args);
}

Unless the middleware function returns early, the last argument passed to the last delegate function is returned as the result; otherwise null is returned. This allows middleware functions to be easily created and to vary the arguments passed to the next function.

function __invoke(Route $route, Request $request, callable $next)
{
    return $next($route, $request);
}

Middleware configurations must resolve to a callable type and can include plugins and anonymous functions.

HTTP Middleware

The signature of the HttpMiddleware function is for handling requests and responses.

function __invoke(Request $request, Response $response)
{
    return $this->call($this->rewind(), [$request, $response]);
}

Below is the default middleware configuration for a HTTP middleware handler.

'web' => [
    'web\route',
    'web\error',
    'web\service',
    'web\controller',
    'web\layout',
    'web\render',
    'web\status',
    'web\version',
    'web\send',
],

The web\controller calls the controller and if the returned value is not a Http\Response and not null, it will be set as the value of the response body for the remaining components to transform into a value that can be sent to the client.

function __invoke(Request $request, Response $response, callable $next)
{
    return $next($request, $this->send($response));
}

The PSR-7 Middleware demo can be enabled by uncommenting the web configuration in the web application service config file.

'web' => 'http\middleware'

Pipelines

Routes can be configured with middleware pipelines and (optionally) a controller. During the route match process, the child stack is appended to the parent stack. When the route is matched, the controller is appended to the stack. A controller placeholder can also be used to indicate where to insert the controller. The middleware stack then becomes the controller for the request.

'explore' => [
    'path' => '/explore',
    'middleware' => ['web\authenticate'],
    'defaults' => [
        'controller' => 'explore'
    ],
    'children' => [
        'more' => [
            'path' => '/more',
            'middleware' => ['controller', 'web\log'],
            'defaults' => [
                'controller' => 'more'
            ]
        ]
    ]
]

View Models

Controllers can return a view model that is rendered using a specified template. For convenience, controllers can use a view model trait that contains two methods for returning a view model with assigned variables, model and view. Either can be used depending on whether a template name is provided. If a view model is injected, a copy of it is returned with the assigned variables; otherwise a default view model is created.

use Mvc5\View\Model;
use Mvc5\View\ViewModel;

class Controller
{
    use Model;
    
    function __invoke() : ViewModel
    {
        return $this->model(['message' => 'Hello World']);
        // or
        return $this->view('home', ['message' => 'Hello World']);
    }
}

Rendering View Models

View models specify the name of the template to be rendered with. Template names can also have a template configuration that provides the full path to the template file. If the template name contains a dot, it is considered to be the full path to the template file. Otherwise it is a file path relative to the application view directory without the .phtml file extension.

function __invoke($model, array $vars = []) : string
{
    return $this->view->render($model, $vars);
}

The renderer function accepts two arguments. The first argument is the name of the view model or the name of the relative template path. The second argument is the array of variables to assign to the view model and template.

echo $this->render('/home/index', ['request' => $request]);

By prefixing the template name with the / directory separator, the view renderer will not use the service locator to find an associated view model and instead will create a default view model with the assigned variables.

View Engine

The default view engine will bind the view model to a closure and extract its variables before including the template. The scope of the template is the view model itself.

function render(TemplateModel $template) : string
{
    return (function() {
        /** @var TemplateModel $this */

        extract($this->vars(), EXTR_SKIP);

        ob_start();

        try {

            include $this->template();

            return ob_get_clean();

        } catch(\Throwable $exception) {
            throw $exception;
        }
    })->call($template);
}

Template Layouts

If a layout is required, the view model will be assigned to it as part of the web function.

function layout(TemplateLayout $layout, $model)
{
    return !$model instanceof TemplateModel || $model instanceof TemplateLayout ? $model : $layout->withModel($model);
}

View Plugins

The default view model supports service configurations and plugins that must resolve to a callable type. This requires a service locator to be injected prior to being rendered. However, because a view model can be created by a controller, this may not of happened. To overcome this, the current service locator will be injected into the view model if it does not already have one. Below is an example of the url generator.

<a href="<?= $this->url(['dashboard', 'user' => 'phpdev']) ?>">Dashboard</a>

Events

An event is a function. However, instead of being implemented as a single function, it is implemented across multiple functions and can be easily extended via its configuration. Events can control the parameters that are provided to its functions and the outcome of each function.

function __invoke(callable $callable, array $args = [], callable $callback = null)
{
    $model = $this->signal($callable, $this->args() + $args, $callback);

    null !== $model
        && $this->model = $model;

    return $model;
}

For example, the dashboard:remove event uses three functions to create a model and to return a layout object. It does not have its own event class, so an instance of the default event model is used.

'dashboard:remove' => [
    fn() => $model = '<h1>Validate</h1>',
    fn($model) => $model . '<h1>Remove</h1>',
    function(TemplateLayout $layout, $model = null) {
        $model .= '<h1>Respond</h1>';

        return $layout->withModel($model);
    }
]

The default event model will store the result of the first function, if it is not null, and pass it as the value of the model parameter of the second function. If the first function had required the model parameter, its value would of been null; because no value was given as an argument to the event and no plugin exists for the name model. In this example, the model parameter is a string, so the second function appends to it and returns it as the value of the model parameter of the third function. When the third function is invoked, the signal method recognizes that the layout parameter is missing and uses the callback function to create it. As the model parameter is an argument of the default event model, it can also be used as an optional parameter.

$app->call('dashboard:remove');

The call function can also generate an event. However, sometimes it maybe preferable to pass event parameters directly to its constructor, in which case the trigger method can be used.

$app->trigger(['response\dispatch', 'event' => 'web', 'request' => $request, 'response' => $response]);

Event Configuration

Events are configurable and can be an array or an iterator. Each item returned must resolve to a callable type.

'web' => [
    'route\dispatch',
    'request\error',
    'request\service',
    'controller\action',
    function($response) { //named args
        var_dump(__FILE__, $response);
    },
    'view\layout',
    new Plugin('view\render'),
    [new Mvc5\Response\Status, '__invoke'],
    new Mvc5\Response\Version,
    Mvc5\Response\Send::class
],

Dependency Injection

A service configuration can either be a string, an array, an anonymous function, a plugin or a real value. The service name can either be a short name or a class or interface name. If a service name does not have a service configuration and it is a fully qualified class name, the class will be created and autowired by default.

[
    'home' => Home\Controller::class,
    Home\Controller::class => Home\Controller::class,
    'request' => Mvc5\Request\HttpRequest::class,
    'response' => Mvc5\Response\HttpResponse::class,
    'url' => new Shared('url\plugin'),
    'url\generator' => [Mvc5\Url\Generator::class, new Param('routes')],
    'url\plugin' => [Mvc5\Url\Plugin::class, new Shared('request'), new Plugin('url\generator')],
    'web' => new Response('web')
];

A string configuration can be a class name, or the name of another configuration. When it is a class name, the class either has no dependencies or it can be autowired. An array configuration is used when there are required dependencies. The first value of the array is the name of the class (or another configuration) and the remaining values are the arguments that cannot be autowired and can override a parent configuration value by name. An anonymous function can be used when the class instantiation requires custom logic. However, anonymous functions cannot be serialized and a plugin can be used instead. Plugins provide inversion of control and are a domain-specific language. Each plugin must implement a resolvable interface so the system can distinguish them from other objects and invoke their associated function. Plugins are only required when an explicit configuration is necessary.

Autowiring

Autowiring occurs when a class is constructed and only some or none of its required parameters have been provided. A callback function is used to create the missing required parameters (and their required parameters). The callback function will use the type hint (class or interface) name, or the parameter name, as the dependency to create. If the result is null, an exception will be thrown. A service manager can be used as the callback function for dependency injection.

Service Container

Service configurations and plugins can be combined and accessed using the ArrayAccess interface or the arrow notation, e.g. dashboard->home.

use Mvc5\App;
use Mvc5\Plugin\Plugins;
use Mvc5\Plugin\Value;

$app = new App([
    'services' => [
        'dashboard' => new Plugins(
            [
                'home' => fn($template) => fn($view, $form) => 
                    $this->call($view, [$template, $form + ['message' => 'Demo Page']]),
                'template' => new Value('dashboard/index')
            ],
            new Link, //reference to parent container
            true //use current container as the scope for anonymous functions
        ),
        'view' => fn() => function($template, $var) {
                include $template;
        },
    ]
]);

//$app['dashboard']['home'];
//$app['dashboard->home'];

$app->call('dashboard->home', ['form' => []]);

A container can contain any type of value, except for null; in which case the null value plugin can be used. A parent container can also pass itself to a child container as the service provider to use when the child container cannot retrieve or resolve a particular value. The parent container can also configure the scope of an anonymous function within the child container.

Service Providers

Custom plugins can implement the resolvable interface and extend an existing plugin or have their own service provider. A service provider is a callable function that is invoked when a plugin cannot be resolved by default.

use Mvc5\Plugin\Config;
use Plugin\Controller;
use Service\Provider;

return [
    'Home\Controller' => new Controller(Home\Controller::class),
    'service\provider' => [Service\Provider::class, new Config],
];

For example, the home controller uses a custom controller plugin with a service provider for the service resolver event.

function resolve($config, array $args = [])
{
    return $this->resolvable($config, $args, function($config) {
        if ($config instanceof Controller) {
            return $this->make($config->config());
        }

        return $config;
    });
}

The service resolver event is used to call the service provider and an exception is thrown if the plugin cannot be resolved.

'service\resolver' => [
    'service\provider',
    'resolver\exception'
],

Named Arguments

The call method can invoke a function with named arguments when they are named or none are provided.

$this->call(Arg::VIEW_RENDER, [Arg::MODEL => $model] + $args);

This allows a function to be called without having to provide all of its required parameters. Typically an exception would be thrown, but before it occurs, a callback function can be used to provide the missing arguments by using the parameter name or its type hint (class or interface) name. Consequently, a service manager can provide itself as the callback function to use.

$this->signal($config, $args, $callback ?? $this);

The signal method reserves the argument $argv to contain the remaining named arguments similar to a variadic trailing argument.

function($controller = null, array $argv = [])

When argv is used as a variadic trailing argument, the remaining named arguments are stored in a SignalArgs class that the function can use to retrieve the remaining named arguments.