Engine
A tiny little PHP web framework written over a Bank Holiday weekend.
Current version: Unreleased
Supported PHP versions: 7.1, 7.2
Installation
$ composer create-project mudge/engine-skeleton:dev-master my-project
$ cd my-project
$ php -S localhost:8080 -t public
Design
It is tempting to think of the HTTP request response cycle as a pure function f
that accepts some request (perhaps as a raw HTTP string or as some wrapper object) and produces a single response (again, perhaps as a raw string or a wrapper object):
+-------------------+ +---+ +----------------------------------------+
| GET / HTTP/2 | --> | f | --> | HTTP/2 200 |
| Host: example.com | +---+ | Content-Type: text/html; charset=utf-8 |
+-------------------+ | |
Request | <!DOCTYPE html>... |
+----------------------------------------+
Response
However, this mental model isn't quite right as it's not true that all requests produce a single response all in one go. For example, it is possible for a response to be sent in chunks: perhaps a request immediately receives some headers in response before the body is returned in parts. It's also possible for a response to never end by continuously streaming (e.g. think of the Twitter streaming APIs).
Therefore, Engine takes an alternative approach: modelling the typical request response cycle as a function that takes both a request and a response object, the latter of which is a sort of open file handle allowing the user to send headers, etc. at any point during processing. This means that responses are created purely through side-effects which is a messier way of thinking about it but maps more closely to reality.
+-------------------+ +---+
| GET / HTTP/2 | --> | |
| Host: example.com | | |
+-------------------+ | |
Request | f |
| |
+-------------------+ | |
| | --> | |
+-------------------+ +---+
Response
Engine uses the Model-view-controller pattern but controllers are objects that are initialized with a Request
, Response
and a logger.
The Request
is a value object offering access to the request URI, method, any GET
or POST
parameters and any cookies or session data.
The Response
object allows you to send data in response to the request via various methods:
redirect(string $location): void
: send a redirect header;header(string $header): void
: send any arbitrary header;render(string $template, array $variables = []): void
: render a Twig template with the given variables;notFound(): void
: return a404 Not Found
response with a template called404.html
;forbidden(): void
: return a403 Forbidden
response with a template called403.html
;methodNotAllowed(): void
: return a405 Method Not Allowed
response with a template called405.html
.
Note that all methods return void
as they work purely through side-effects: namely, sending data to the client.
Controller actions are therefore typical methods on the controller instance with access to the request
, response
and logger
(see Usage for an example). As these are plain PHP objects, all the usual techniques such as composition and inheritance are available for sharing behaviour between controllers.
As for how controllers are instantiated and actions called: the Router
is responsible for this and contains a map of HTTP methods and paths to controller classes and actions, e.g.
+-------------+ +----------------------+
| GET / | --> | HomeController#index |
+-------------+ +----------------------+
+-------------+ +---------------------------+
| POST /login | --> | SessionsController#create |
+-------------+ +---------------------------+
The Engine Skeleton project will create a public/index.php
which sets up a new Router
, populates it with a default route, creates a Request
and empty Response
and routes it accordingly.
+-------------------+ +-----------------------------+ +------------------+
| GET / HTTP/2 | --> | GET / -> HomepageController | --> | |
__O__ --> | Host: example.com | | index | | index |
| +-------------------+ +-----------------------------+ | |
| Request Router | |
| | |
| +--------+ | |
| <------------------------------------------------- | | --> | |
/ \ | | <-- | |
Client +--------+ +------------------+
Response HomepageController
Usage
Use Engine Skeleton to create a new Engine web application:
$ composer create-project mudge/engine-skeleton:dev-master my-project
This will generate a project with the following layout in my-project
(contents of vendor
directory not shown):
.
├── README.md
├── composer.json
├── composer.lock
├── log
├── public
│ ├── css
│ │ └── app.css
│ └── index.php
├── src
│ └── HomepageController.php
├── templates
│ ├── 404.html
│ ├── base.html
│ └── index.html
├── tests
│ └── HomepageControllerTest.php
├── tmp
└── vendor
You can then start the development server:
$ cd my-project
$ php -S localhost:8080 -t public
You can then go to http://localhost:8080 in your web browser and see a welcome page from Engine.
You can run the automated tests:
$ ./vendor/bin/phpunit
Controllers
By default, your project will expect everything from your src
directory to be in the App
namespace. You can implement your own controllers by inheriting from Engine\Controller
:
<?php
declare(strict_types=1);
namespace App;
use Engine\Controller;
/* Controllers are just plain classes that will be instantiated with a Request, Response and logger. */
final class HomepageController extends Controller
{
public function index(): void
{
/* Prevent CSRF attacks using tokens in form submissions. */
$this->verifyCsrfToken();
/* Query arguments or post data can be accessed through Parameters. */
$this->params->fetch('name');
/* The response object can be used to render templates and send them to the user. */
$this->response->render('index.html', ['csrf_token' => $this->session->crsfToken()]);
/* Or, use the convenience methods on the controller itself. */
$this->renderForm('index.html');
/* Use convenience methods for common responses. */
$this->response->notFound();
$this->response->forbidden();
$this->response->redirect('http://www.example.com');
}
}
Routing
The main entrypoint into your web application is public/index.php
which is executed on every request. This will have been generated for you by the project skeleton but can be edited as you see fit.
You will almost certainly want to edit your routes to route requests to your own controller actions but you can also configure logging and template caching here too.
<?php
declare(strict_types=1);
/**
* Ensure all encoding is in UTF-8.
*/
mb_internal_encoding('UTF-8');
mb_http_output('UTF-8');
require_once __DIR__ . '/../vendor/autoload.php';
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Engine\{Router, Request, Response};
/**
* Set up logging.
*
* By default, this will log INFO-level messages to ../log/app.log
*/
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../log/app.log', Logger::INFO));
/**
* Set up templating.
*
* By default, this will load templates from ../templates and cache them
* to ../tmp (you may want to disable the cache during development)
*/
$loader = new \Twig_Loader_Filesystem(__DIR__ . '/../templates');
$twig = new \Twig_Environment($loader, ['cache' => __DIR__ . '/../tmp']);
/**
* Set up the request and response.
*/
$request = Request::fromGlobals();
$response = new Response($twig, $logger);
/**
* Set up the router.
*
* Add any routes of your own here, e.g.
*
* $router->get('/login', 'App\SessionsController', 'new');
* $router->post('/login', 'App\SessionsController', 'create');
*/
$router = new Router($logger);
$router->root('App\HomepageController', 'index');
/**
* Serve the request with the response.
*/
$router->route($request, $response);
Why "Engine?"
Because the various blogging systems I wrote over 15 years ago were invariably called "Engine" too.
License
Copyright © 2017 Paul Mucur
Distributed under the MIT License.