DownloadOwn PHP Framework based on Simfony components
-
Introduction
-
HttpFoundation Component
1. Going OOP with the HttpFoundation Component
-
Front Controller
-
Routing Component
-
Templating
-
HttpKernel Component: Controller Resolver
-
Separation of Concerns
-
Unit Testing
-
EventDispatcher Component
-
HttpKernel Component: HttpKernelInterface
-
HttpKernel Component: The HttpKernel Class
-
DependencyInjection Component
1\. Introduction
See https://symfony.com/doc/current/create\_framework/http\_foundation.html
This poage URL is eg. : http://dev1:8083/fwphp/z\_test/simf\_ownfw/index.php?name=aaa
When creating a framework, following the MVC pattern is not the right goal.
The main goal should be the Separation of Concerns; this is probably the only design pattern that you should really care about.
The fundamental principles of the Symfony Components are focused on the HTTP specification. As such, the framework that you are going to create should be more accurately labelled as a HTTP framework or Request/Response framework.
Writing web code is about interacting with HTTP.
In PHP, the request is represented by global variables (\\$\_GET, \\$\_POST, \\$\_FILE, \\$\_COOKIE, \\$\_SESSION...)
and the response is generated by functions (echo, header, setcookie, ...).
Security and testability (PHPUnit unit tests)
First version : $name = $_GET['name'] ?? 'World'; //if name query parameter is not defined in the URL query string
header('Content-Type: text/html; charset=utf-8');
//XSS (Cross-Site Scripting) - code more secure :
printf('Hello %s', htmlspecialchars($name, ENT_QUOTES, 'UTF-8'));
The first step towards better code is probably to use an Object-Oriented approach; that is the main goal of the Symfony HttpFoundation component:
1\. replacing the default PHP global variables and functions by an Object-Oriented layer
To use this component, add it as a dependency of the project:
composer require symfony/http-foundation
... Outputs :
1 package suggestions were added by new dependencies, use \composer suggest\ to see details.
Generating autoload files
3 packages you are using are looking for funding. Use \composer fund\ command to find out more!
Running this command will also automatically download the Symfony HttpFoundation component and install it under the vendor/ directory. A composer.json and a composer.lock file will be generated as well, containing the new requirement.
When installing a new dependency, Composer also generates a vendor/autoload.php file that allows any class to be autoloaded. Without autoloading, you would need to require the file where a class is defined before being able to use it. But thanks to PSR-4, we can just let Composer and PHP do the hard work for us.
2\. HttpFoundation Component
Going OOP with the HttpFoundation Component
Using framework is good idea, even for the simplest snippet of code and creating your framework on top of Symfony components is better than creating a framework from scratch.
We won't talk about the traditional benefits of using a framework when working on big applications with more than a few developers; the Internet already has plenty of good resources on that topic.
As you might have noticed, securing your code with htmlspecialchars is tedious and error prone. That's one of the reasons why using a template engine like Twig, where auto-escaping is enabled by default, might be a good idea (and explicit escaping is also less painful with the usage of a simple e filter).
Beyond security, this code can be complex to test. Even if there is not much to test, it strikes me that writing unit tests for the simplest possible snippet of PHP code is not natural and feels ugly. Here is a tentative PHPUnit unit test for the above code: // framework/test.php
use PHPUnit\\Framework\\TestCase;
class IndexTest extends TestCase
{
public function testHello()
{
$\_GET\['name'\] = 'Fabien';
ob\_start();
include 'index.php';
$content = ob\_get\_clean();
$this->assertEquals('Hello Fabien', $content);
}
}
If our application were just slightly bigger, we would have been able to find even more problems. If you are curious about them, read
Symfony versus Flat PHP (A Basic Blog) chapter of the book.
// framework/index.php
require\_once \_\_DIR\_\_.'/vendor/autoload.php';
use Symfony\\Component\\HttpFoundation\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
//creates a Request object based on the current PHP global variables :
$request = Request::createFromGlobals();
$name = $request->query->get('name', 'World');
$response = new Response(sprintf('Hello %s', htmlspecialchars($name, ENT\_QUOTES, 'UTF-8')));
//sends Response object back to client (first outputs HTTP headers followed by content) :
$response->send();
Before the send() call, we should have added a call to the prepare() method ($response->prepare($request);) to ensure that our Response were compliant with the HTTP specification. For instance, if we were to call the page with the HEAD method, it would remove the content of the Response.
The main difference with the previous code is that you have total control of HTTP messages. You can create whatever request you want and you are in charge of sending the response whenever you see fit.
We haven't explicitly set the Content-Type header in the rewritten code as the charset of the Response object defaults to UTF-8.
With the Request class, you have all the request information at your fingertips thanks to a nice and simple API: // the URI being requested (e.g. /about) minus any query parameters
$request->getPathInfo();
// retrieve GET and POST variables respectively
$request->query->get('foo');
$request->request->get('bar', 'default value if bar does not exist');
// retrieve SERVER variables
$request->server->get('HTTP\_HOST');
// retrieves an instance of UploadedFile identified by foo
$request->files->get('foo');
// retrieve a COOKIE value
$request->cookies->get('PHPSESSID');
// retrieve an HTTP request header, with normalized, lowercase keys
$request->headers->get('host');
$request->headers->get('content-type');
$request->getMethod(); // GET, POST, PUT, DELETE, HEAD
$request->getLanguages(); // an array of languages the client accepts
You can also simulate a request:
$request = Request::create('/index.php?name=Fabien');
With the Response class, you can tweak the response:
$response = new Response();
$response->setContent('Hello world!');
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/html');
// configure the HTTP cache headers
$response->setMaxAge(10);
To debug a response, cast it to a string; it will return the HTTP representation of the response (headers and content).
Last but not least, these classes, like every other class in the Symfony code, have been audited for security issues by an independent company. And being an Open-Source project also means that many other developers around the world have read the code and have already fixed potential security problems. When was the last time you ordered a professional security audit for your home-made framework?
Even something as simple as getting the client IP address can be insecure: if ($myIp === $\_SERVER\['REMOTE\_ADDR'\]) {
// the client is a known one, so give it some more privilege
}
It works perfectly fine until you add a reverse proxy in front of the production servers; at this point, you will have to change your code to make it work on both your development machine (where you don't have a proxy) and your servers: if ($myIp === $\_SERVER\['HTTP\_X\_FORWARDED\_FOR'\] || $myIp === $\_SERVER\['REMOTE\_ADDR'\]) {
// the client is a known one, so give it some more privilege
}
Using the Request::getClientIp() method would have given you the right behavior from day one (and it would have covered the case where you have chained proxies):
$request = Request::createFromGlobals();
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
}
And there is an added benefit: it is secure by default. What does it mean? The $\_SERVER\['HTTP\_X\_FORWARDED\_FOR'\] value cannot be trusted as it can be manipulated by the end user when there is no proxy. So, if you are using this code in production without a proxy, it becomes trivially easy to abuse your system. That's not the case with the getClientIp() method as you must explicitly trust your reverse proxies by calling setTrustedProxies():
Request::setTrustedProxies(\['10.0.0.1'\]);
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
}
So, the getClientIp() method works securely in all circumstances. You can use it in all your projects, whatever the configuration is, it will behave correctly and safely. That's one of the goals of using a framework. If you were to write a framework from scratch, you would have to think about all these cases by yourself. Why not use a technology that already works?
If you want to learn more about the HttpFoundation component, you can have a look at the Symfony\\Component\\HttpFoundation API or read its dedicated documentation.
Believe it or not but we have our first framework. You can stop now if you want. Using just the Symfony HttpFoundation component already allows you to write better and more testable code. It also allows you to write code faster as many day-to-day problems have already been solved for you.
As a matter of fact, projects like Drupal have adopted the HttpFoundation component; if it works for them, it will probably work for you. Don't reinvent the wheel.
I've almost forgotten to talk about one added benefit: using the HttpFoundation component is the start of better interoperability between all frameworks and applications using it (like Symfony, Drupal 8, phpBB 3, Laravel and ezPublish 5, and more).
3\. Front Controller design pattern
-----------------------------------
Add another page that says goodbye:lverInterface $argumentResolver) // framework/bye.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response('Goodbye!');
$response->send();
As you can see for yourself, much of the code is exactly the same as the one we have written for the first page. Let's extract the common code that we can share between all our pages. Code sharing sounds like a good plan to create our first "real" framework!
The PHP way of doing the refactoring would probably be the creation of an include file:
// framework/init.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response();
Let's see it in action:
Copy
// framework/index.php
require_once __DIR__.'/init.php';
$name = $request->query->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($name, ENT_QUOTES, 'UTF-8')));
$response->send();
And for the "Goodbye" page:
Copy
// framework/bye.php
require_once __DIR__.'/init.php';
$response->setContent('Goodbye!');
$response->send();
We have indeed moved most of the shared code into a central place, but it does not feel like a good abstraction, does it? We still have the send() method for all pages, our pages do not look like templates and we are still not able to test this code properly.
Moreover, adding a new page means that we need to create a new PHP script, the name of which is exposed to the end user via the URL (http://127.0.0.1:4321/bye.php). There is a direct mapping between the PHP script name and the client URL. This is because the dispatching of the request is done by the web server directly. It might be a good idea to move this dispatching to our code for better flexibility. This can be achieved by routing all client requests to a single PHP script.
Exposing a single PHP script to the end user is a design pattern called "front controller".
Such a script might look like the following:
// framework/front.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response();
$map = [
'/hello' => __DIR__.'/hello.php',
'/bye' => __DIR__.'/bye.php',
];
$path = $request->getPathInfo();
if (isset($map[$path])) {
require $map[$path];
} else {
$response->setStatusCode(404);
$response->setContent('Not Found');
}
$response->send();
And here is for instance the new hello.php script: // framework/hello.php
$name = $request->query->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($name, ENT\_QUOTES, 'UTF-8')));
In the front.php script, $map associates URL paths with their corresponding PHP script paths.
As a bonus, if the client asks for a path that is not defined in the URL map, we return a custom 404 page. You are now in control of your website.
To access a page, you must now use the front.php script: http://127.0.0.1:4321/front.php/hello?name=Fabien
http://127.0.0.1:4321/front.php/bye
/hello and /bye are the page paths.
Most web servers like Apache or nginx are able to rewrite the incoming URLs and remove the front controller script so that your users will be able to type http://127.0.0.1:4321/hello?name=Fabien, which looks much better.
The trick is the usage of the Request::getPathInfo() method which returns the path of the Request by removing the front controller script name including its sub-directories (only if needed -- see above tip).
You don't even need to set up a web server to test the code. Instead, replace
$request = Request::createFromGlobals(); call to something like
$request = Request::create('/hello?name=Fabien');
where the argument is the URL path you want to simulate.
Now that the web server always accesses the same script (front.php) for all pages, we can secure the code further by moving all other PHP files outside of the web root directory: example.com
??? composer.json
??? composer.lock
??? src
? ??? pages
? ??? hello.php
? ??? bye.php
??? vendor
? ??? autoload.php
??? web
??? front.php
Put another way: your code dispatches an event to the dispatcher, the dispatcher
notifies all registered listeners for the event, and each listener does whatever
it wants with the event.
As an example, let's create a listener that
transparently adds the Google Analytics code to all responses.
To make it
work, the framework must dispatch an event just before returning the Response
instance:
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\\Component\\EventDispatcher\\EventDispatcher;
use
Symfony\\Component\\HttpFoundation\\Request;
use
Symfony\\Component\\HttpFoundation\\Response;
use
Symfony\\Component\\HttpKernel\\Controller\\ArgumentResolverInterface;
use
Symfony\\Component\\HttpKernel\\Controller\\ControllerResolverInterface;
use
Symfony\\Component\\Routing\\Exception\\ResourceNotFoundException;
use
Symfony\\Component\\Routing\\Matcher\\UrlMatcherInterface;
class Framework
{
private $dispatcher;
private $matcher;
private $controllerResolver;
private $argumentResolver;
public function \_\_construct(EventDispatcher
$dispatcher, UrlMatcherInterface $matcher, ControllerResolverInterface
$controllerResolver, ArgumentResolverInterface $argumentResolver)
{
$this->dispatcher = $dispatcher;
$this->matcher = $matcher;
$this->controllerResolver = $controllerResolver;
$this->argumentResolver =
$argumentResolver;
}
public function handle(Request $request)
{
$this->matcher->getContext()->fromRequest($request);
try {
$request->attributes->add($this->matcher->match($request->getPathInfo()));
$controller = $this->controllerResolver->getController($request);
$arguments = $this->argumentResolver->getArguments($request, $controller);
$response = call\_user\_func\_array($controller, $arguments);
} catch
(ResourceNotFoundException $exception) {
$response = new Response('Not
Found', 404);
} catch (\\Exception $exception) {
$response = new
Response('An error occurred', 500);
}
// dispatch a response event
$this->dispatcher->dispatch(new ResponseEvent($response, $request), 'response');
return $response;
}
}
Each time the framework handles a Request,
a ResponseEvent event is now dispatched:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// example.com/src/Simplex/ResponseEvent.php
namespace Simplex;
use Symfony\\Component\\HttpFoundation\\Request;
use
Symfony\\Component\\HttpFoundation\\Response;
use
Symfony\\Contracts\\EventDispatcher\\Event;
class ResponseEvent extends
Event
{
private $request;
private $response;
public function
\_\_construct(Response $response, Request $request)
{
$this->response =
$response;
$this->request = $request;
}
public function
getResponse()
{
return $this->response;
}
public function
getRequest()
{
return $this->request;
}
}
The last step is the
creation of the dispatcher in the front controller and the registration of a
listener for the response event:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// example.com/web/front.php
require\_once
\_\_DIR\_\_.'/../vendor/autoload.php';
// ...
use
Symfony\\Component\\EventDispatcher\\EventDispatcher;
$dispatcher = new
EventDispatcher();
$dispatcher->addListener('response', function
(Simplex\\ResponseEvent $event) {
$response = $event->getResponse();
if
($response->isRedirection()
|| ($response->headers->has('Content-Type') &&
false === strpos($response->headers->get('Content-Type'), 'html'))
|| 'html'
!== $event->getRequest()->getRequestFormat()
) {
return;
}
$response->setContent($response->getContent().'GA CODE');
});
$controllerResolver = new ControllerResolver();
$argumentResolver = new
ArgumentResolver();
$framework = new Simplex\\Framework($dispatcher,
$matcher, $controllerResolver, $argumentResolver);
$response =
$framework->handle($request);
$response->send();
The listener is just
a proof of concept and you should add the Google Analytics code just before the
body tag.
As you can see, addListener() associates a valid PHP callback to a
named event (response); the event name must be the same as the one used in
the dispatch() call.
In the listener, we add the Google Analytics code only
if the response is not a redirection, if the requested format is HTML and if the
response content type is HTML (these conditions demonstrate the ease of
manipulating the Request and Response data from your code).
So far so good,
but let's add another listener on the same event. Let's say that we want to set
the Content-Length of the Response if it is not already set:
1
2
3
4
5
6
7
8
$dispatcher->addListener('response', function
(Simplex\\ResponseEvent $event) {
$response = $event->getResponse();
$headers = $response->headers;
if (!$headers->has('Content-Length') &&
!$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length',
strlen($response->getContent()));
}
});
Depending on whether you have
added this piece of code before the previous listener registration or after it,
you will have the wrong or the right value for the Content-Length header.
Sometimes, the order of the listeners matter but by default, all listeners are
registered with the same priority, 0. To tell the dispatcher to run a listener
early, change the priority to a positive number; negative numbers can be used
for low priority listeners. Here, we want the Content-Length listener to be
executed last, so change the priority to -255:
1
2
3
4
5
6
7
8
$dispatcher->addListener('response', function (Simplex\\ResponseEvent
$event) {
$response = $event->getResponse();
$headers =
$response->headers;
if (!$headers->has('Content-Length') &&
!$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length',
strlen($response->getContent()));
}
}, -255);
When creating your
framework, think about priorities (reserve some numbers for internal listeners
for instance) and document them thoroughly.
Let's refactor the code a bit by
moving the Google listener to its own class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//
example.com/src/Simplex/GoogleListener.php
namespace Simplex;
class
GoogleListener
{
public function onResponse(ResponseEvent $event)
{
$response = $event->getResponse();
if ($response->isRedirection()
||
($response->headers->has('Content-Type') && false ===
strpos($response->headers->get('Content-Type'), 'html'))
|| 'html' !==
$event->getRequest()->getRequestFormat()
) {
return;
}
$response->setContent($response->getContent().'GA CODE');
}
}
And do
the same with the other listener:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//
example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
class ContentLengthListener
{
public function onResponse(ResponseEvent
$event)
{
$response = $event->getResponse();
$headers =
$response->headers;
if (!$headers->has('Content-Length') &&
!$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length',
strlen($response->getContent()));
}
}
}
Our front controller should
now look like the following:
1
2
3
$dispatcher = new
EventDispatcher();
$dispatcher->addListener('response', \[new
Simplex\\ContentLengthListener(), 'onResponse'\], -255);
$dispatcher->addListener('response', \[new Simplex\\GoogleListener(),
'onResponse'\]);
Even if the code is now nicely wrapped in classes, there is
still a slight issue: the knowledge of the priorities is "hardcoded" in the
front controller, instead of being in the listeners themselves. For each
application, you have to remember to set the appropriate priorities. Moreover,
the listener method names are also exposed here, which means that refactoring
our listeners would mean changing all the applications that rely on those
listeners. The solution to this dilemma is to use subscribers instead of
listeners:
1
2
3
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new Simplex\\ContentLengthListener());
$dispatcher->addSubscriber(new Simplex\\GoogleListener());
A subscriber knows
about all the events it is interested in and pass this information to the
dispatcher via the getSubscribedEvents() method. Have a look at the new version
of the GoogleListener:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/GoogleListener.php
namespace
Simplex;
use Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;
class GoogleListener implements EventSubscriberInterface
{
// ...
public static function getSubscribedEvents()
{
return \['response' =>
'onResponse'\];
}
}
And here is the new version
of ContentLengthListener:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
use
Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;
class
ContentLengthListener implements EventSubscriberInterface
{
// ...
public static function getSubscribedEvents()
{
return \['response' =>
\['onResponse', -255\]\];
}
}
A single subscriber can host as many
listeners as you want on as many events as needed.
To make your framework
truly flexible, don't hesitate to add more events; and to make it more awesome
out of the box, add more listeners. Again, this book is not about creating a
generic framework, but one that is tailored to your needs. Stop whenever you see
fit, and further evolve the code from there.
10\. HttpKernel Component:
HttpKernelInterface
-----------------------------------------------
In the conclusion of the second chapter of this book, I've talked about one
great benefit of using the Symfony components: the interoperability between all
frameworks and applications using them. Let's do a big step towards this goal by
making our framework implement HttpKernelInterface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace Symfony\\Component\\HttpKernel;
//
...
interface HttpKernelInterface
{
/\\
\* @return Response A
Response instance
\*/
public function handle(
Request $request,
$type
= self::MAIN\_REQUEST,
$catch = true
);
}
HttpKernelInterface is
probably the most important piece of code in the HttpKernel component, no
kidding. Frameworks and applications that implement this interface are fully
interoperable. Moreover, a lot of great features will come with it for free.
Update your framework so that it implements this interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//
example.com/src/Framework.php
// ...
use
Symfony\\Component\\HttpKernel\\HttpKernelInterface;
class Framework
implements HttpKernelInterface
{
// ...
public function handle(
Request $request,
$type = HttpKernelInterface::MAIN\_REQUEST,
$catch = true
) {
// ...
}
}
With this change, a little goes a long way! Let's
talk about one of the most impressive upsides: transparent [HTTP
caching](https://symfony.com/doc/current/http_cache.html) support.
The HttpCache class implements a fully-featured reverse
proxy, written in PHP; it implements HttpKernelInterface and wraps
another HttpKernelInterface instance:
1
2
3
4
5
6
7
8
9
10
11
12
13
// example.com/web/front.php
// ...
use
Symfony\\Component\\HttpKernel;
$framework = new
Simplex\\Framework($dispatcher, $matcher, $controllerResolver,
$argumentResolver);
$framework = new HttpKernel\\HttpCache\\HttpCache(
$framework,
new HttpKernel\\HttpCache\\Store(\_\_DIR\_\_.'/../cache')
);
$response = $framework->handle($request);
$response->send();
That's all it
takes to add HTTP caching support to our framework. Isn't it amazing?
Configuring the cache needs to be done via HTTP cache headers. For instance, to
cache a response for 10 seconds, use the Response::setTtl() method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//
example.com/src/Calendar/Controller/LeapYearController.php
// ...
public function index(Request $request, $year)
{
$leapYear = new
LeapYear();
if ($leapYear->isLeapYear($year)) {
$response = new
Response('Yep, this is a leap year!');
} else {
$response = new
Response('Nope, this is not a leap year.');
}
$response->setTtl(10);
return $response;
}
If you are running your framework from the command
line by simulating requests (Request::create('/is\_leap\_year/2012')), you can
debug Response instances by dumping their string representation (echo
$response;) as it displays all headers as well as the response content.
To
validate that it works correctly, add a random number to the response content
and check that the number only changes every 10 seconds:
$response = new
Response('Yep, this is a leap year! '.rand());
When deploying to your
production environment, keep using the Symfony reverse proxy (great for shared
hosting) or even better, switch to a more efficient reverse proxy like Varnish.
Using HTTP cache headers to manage your application cache is very powerful and
allows you to tune finely your caching strategy as you can use both the
expiration and the validation models of the HTTP specification. If you are not
comfortable with these concepts, read the [HTTP
caching](https://symfony.com/doc/current/http_cache.html) chapter of the Symfony documentation.
The Response class contains
methods that let you configure the HTTP cache. One of the most powerful
is setCache() as it abstracts the most frequently used caching strategies into a
single array:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$response->setCache(\[
'must\_revalidate' => false,
'no\_cache' => false,
'no\_store' => false,
'no\_transform' => false,
'public' => true,
'private' => false,
'proxy\_revalidate' => false,
'max\_age' => 600,
's\_maxage' => 600,
'immutable' => true,
'last\_modified' => new
\\DateTime(),
'etag' => 'abcdef'
\]);
// it is equivalent to the
following code
$response->setPublic();
$response->setMaxAge(600);
$response->setSharedMaxAge(600);
$response->setImmutable();
$response->setLastModified(new \\DateTime());
$response->setEtag('abcde');
When using the validation model, the isNotModified() method allows you to cut on
the response time by short-circuiting the response generation as early as
possible:
1
2
3
4
5
6
7
8
9
$response->setETag('whatever\_you\_compute\_as\_an\_etag');
if
($response->isNotModified($request)) {
return $response;
}
$response->setContent('The computed content of the response');
return
$response;
Using HTTP caching is great, but what if you cannot cache the
whole page? What if you can cache everything but some sidebar that is more
dynamic that the rest of the content? Edge Side Includes (ESI)
to the rescue! Instead of generating the whole content in one go, ESI allows you
to mark a region of a page as being the content of a sub-request call:
1
2
3
4
5
This is the content of your page
Is 2012 a leap year?
Some other content
For ESI tags to be supported by HttpCache, you need to pass it an instance of
the ESI class. The ESI class automatically parses ESI tags and makes
sub-requests to convert them to their proper content:
1
2
3
4
5
$framework = new HttpKernel\\HttpCache\\HttpCache(
$framework,
new
HttpKernel\\HttpCache\\Store(\_\_DIR\_\_.'/../cache'),
new
HttpKernel\\HttpCache\\Esi()
);
For ESI to work, you need to use a reverse
proxy that supports it like the Symfony implementation. Varnish is
the best alternative and it is Open-Source.
When using complex HTTP caching
strategies and/or many ESI include tags, it can be hard to understand why and
when a resource should be cached or not. To ease debugging, you can enable the
debug mode:
1
2
3
4
5
6
$framework = new
HttpKernel\\HttpCache\\HttpCache(
$framework,
new
HttpKernel\\HttpCache\\Store(\_\_DIR\_\_.'/../cache'),
new
HttpKernel\\HttpCache\\Esi(),
\['debug' => true\]
);
The debug mode adds
a X-Symfony-Cache header to each response that describes what the cache layer
did:
1
2
3
X-Symfony-Cache: GET /is\_leap\_year/2012: stale, invalid,
store
X-Symfony-Cache: GET /is\_leap\_year/2012: fresh
HttpCache has
many features like support for
the stale-while-revalidate and stale-if-error HTTP Cache-Control extensions as
defined in RFC 5861.
With the addition of a single interface, our framework
can now benefit from the many features built into the HttpKernel component; HTTP
caching being just one of them but an important one as it can make your
applications fly!
11\. HttpKernel Component: The HttpKernel Class
-----------------------------------------------
If you were to use our framework right now, you would probably have to add
support for custom error messages. We do have 404 and 500 error support but the
responses are hardcoded in the framework itself. Making them customizable is
straightforward though: dispatch a new event and listen to it. Doing it right
means that the listener has to call a regular controller. But what if the error
controller throws an exception? You will end up in an infinite loop. There
should be an easier way, right?
Enter the HttpKernel class. Instead of
solving the same problem over and over again and instead of reinventing the
wheel each time, the HttpKernel class is a generic, extensible and flexible
implementation of HttpKernelInterface.
This class is very similar to the
framework class we have written so far: it dispatches events at some strategic
points during the handling of the request, it uses a controller resolver to
choose the controller to dispatch the request to, and as an added bonus, it
takes care of edge cases and provides great feedback when a problem arises.
Here is the new framework code:
1
2
3
4
5
6
7
8
// example.com/src/Simplex/Framework.php
namespace Simplex;
use
Symfony\\Component\\HttpKernel\\HttpKernel;
class Framework extends
HttpKernel
{
}
And the new front controller:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// example.com/web/front.php
require\_once \_\_DIR\_\_.'/../vendor/autoload.php';
use
Symfony\\Component\\EventDispatcher\\EventDispatcher;
use
Symfony\\Component\\HttpFoundation\\Request;
use
Symfony\\Component\\HttpFoundation\\RequestStack;
use
Symfony\\Component\\HttpFoundation\\Response;
use Symfony\\Component\\HttpKernel;
use Symfony\\Component\\Routing;
$request = Request::createFromGlobals();
$requestStack = new RequestStack();
$routes = include
\_\_DIR\_\_.'/../src/app.php';
$context = new Routing\\RequestContext();
$matcher = new Routing\\Matcher\\UrlMatcher($routes, $context);
$controllerResolver = new HttpKernel\\Controller\\ControllerResolver();
$argumentResolver = new HttpKernel\\Controller\\ArgumentResolver();
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\RouterListener($matcher, $requestStack));
$framework = new Simplex\\Framework($dispatcher, $controllerResolver,
$requestStack, $argumentResolver);
$response =
$framework->handle($request);
$response->send();
RouterListener is an
implementation of the same logic we had in our framework: it matches the
incoming request and populates the request attributes with route parameters.
Our code is now much more concise and surprisingly more robust and more powerful
than ever. For instance, use the built-in ErrorListener to make your error
management configurable:
1
2
3
4
5
6
$errorHandler =
function (Symfony\\Component\\ErrorHandler\\Exception\\FlattenException $exception)
{
$msg = 'Something went wrong! ('.$exception->getMessage().')';
return new Response($msg, $exception->getStatusCode());
};
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\ErrorListener($errorHandler));
ErrorListener gives
you a FlattenException instance instead of the
thrown Exception or Error instance to ease exception manipulation and display.
It can take any valid controller as an exception handler, so you can create an
ErrorController class instead of using a Closure:
1
2
3
4
$listener = new HttpKernel\\EventListener\\ErrorListener(
'Calendar\\Controller\\ErrorController::exception'
);
$dispatcher->addSubscriber($listener);
The error controller reads as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// example.com/src/Calendar/Controller/ErrorController.php
namespace
Calendar\\Controller;
use
Symfony\\Component\\ErrorHandler\\Exception\\FlattenException;
use
Symfony\\Component\\HttpFoundation\\Response;
class ErrorController
{
public function exception(FlattenException $exception)
{
$msg = 'Something
went wrong! ('.$exception->getMessage().')';
return new Response($msg,
$exception->getStatusCode());
}
}
Voilà! Clean and customizable error
management without efforts. And if your ErrorController throws an exception,
HttpKernel will handle it nicely.
In chapter two, we talked about
the Response::prepare() method, which ensures that a Response is compliant with
the HTTP specification. It is probably a good idea to always call it just before
sending the Response to the client; that's what the ResponseListener does:
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\ResponseListener('UTF-8'));
If you want out of the
box support for streamed responses, subscribe to StreamedResponseListener:
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\StreamedResponseListener());
And in your controller,
return a StreamedResponse instance instead of a Response instance.
Read the [Built-in
Symfony Events](https://symfony.com/doc/current/reference/events.html) reference to learn more about the events dispatched by
HttpKernel and how they allow you to change the flow of a request.
Now, let's
create a listener, one that allows a controller to return a string instead of a
full Response object:
1
2
3
4
5
6
7
8
9
10
11
12
class LeapYearController
{
public function index($year)
{
$leapYear = new LeapYear();
if ($leapYear->isLeapYear($year)) {
return
'Yep, this is a leap year! ';
}
return 'Nope, this is not a leap
year.';
}
}
To implement this feature, we are going to listen to
the kernel.view event, which is triggered just after the controller has been
called. Its goal is to convert the controller return value to a proper Response
instance, but only if needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//
example.com/src/Simplex/StringResponseListener.php
namespace Simplex;
use Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;
use
Symfony\\Component\\HttpFoundation\\Response;
use
Symfony\\Component\\HttpKernel\\Event\\ViewEvent;
class
StringResponseListener implements EventSubscriberInterface
{
public
function onView(ViewEvent $event)
{
$response =
$event->getControllerResult();
if (is\_string($response)) {
$event->setResponse(new Response($response));
}
}
public static
function getSubscribedEvents()
{
return \['kernel.view' => 'onView'\];
}
}
The code is simple because the kernel.view event is only triggered when the
controller return value is not a Response and because setting the response on
the event stops the event propagation (our listener cannot interfere with other
view listeners).
Don't forget to register it in the front controller:
$dispatcher->addSubscriber(new Simplex\\StringResponseListener());
If you
forget to register the subscriber, HttpKernel will throw an exception with a
nice message: The controller must return a response (Nope, this is not a leap
year. given)..
At this point, our whole framework code is as compact as
possible and it is mainly composed of an assembly of existing libraries.
Extending is a matter of registering event listeners/subscribers.
Hopefully,
you now have a better understanding of why the simple
looking HttpKernelInterface is so powerful. Its default
implementation, HttpKernel, gives you access to a lot of cool features, ready to
be used out of the box, with no efforts. And because HttpKernel is actually the
code that powers the Symfony framework, you have the best of both worlds: a
custom framework, tailored to your needs, but based on a rock-solid and well
maintained low-level architecture that has been proven to work for many
websites; a code that has been audited for security issues and that has proven
to scale well.
12\. DependencyInjection Component
----------------------------------
In the previous chapter, we emptied the Simplex\\Framework class by extending
the HttpKernel class from the eponymous component. Seeing this empty class, you
might be tempted to move some code from the front controller to it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//
example.com/src/Simplex/Framework.php
namespace Simplex;
use
Symfony\\Component\\EventDispatcher\\EventDispatcher;
use
Symfony\\Component\\HttpFoundation;
use
Symfony\\Component\\HttpFoundation\\RequestStack;
use
Symfony\\Component\\HttpKernel;
use Symfony\\Component\\Routing;
class
Framework extends HttpKernel\\HttpKernel
{
public function
\_\_construct($routes)
{
$context = new Routing\\RequestContext();
$matcher = new Routing\\Matcher\\UrlMatcher($routes, $context);
$requestStack =
new RequestStack();
$controllerResolver = new
HttpKernel\\Controller\\ControllerResolver();
$argumentResolver = new
HttpKernel\\Controller\\ArgumentResolver();
$dispatcher = new
EventDispatcher();
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\ErrorListener(
'Calendar\\Controller\\ErrorController::exception'
));
$dispatcher->addSubscriber(new HttpKernel\\EventListener\\RouterListener($matcher,
$requestStack));
$dispatcher->addSubscriber(new
HttpKernel\\EventListener\\ResponseListener('UTF-8'));
$dispatcher->addSubscriber(new StringResponseListener());
parent::\_\_construct($dispatcher, $controllerResolver, $requestStack,
$argumentResolver);
}
}
The front controller code would become more
concise:
1
2
3
4
5
6
7
8
9
10
11
//
example.com/web/front.php
require\_once \_\_DIR\_\_.'/../vendor/autoload.php';
use Symfony\\Component\\HttpFoundation\\Request;
$request =
Request::createFromGlobals();
$routes = include \_\_DIR\_\_.'/../src/app.php';
$framework = new Simplex\\Framework($routes);
$framework->handle($request)->send();
Having a concise front controller
allows you to have several front controllers for a single application. Why would
it be useful? To allow having different configuration for the development
environment and the production one for instance. In the development environment,
you might want to have error reporting turned on and errors displayed in the
browser to ease debugging:
ini\_set('display\_errors', 1);
error\_reporting(-1);
... but you certainly won't want that same configuration
on the production environment. Having two different front controllers gives you
the opportunity to have a slightly different configuration for each of them.
So, moving code from the front controller to the framework class makes our
framework more configurable, but at the same time, it introduces a lot of
issues:
We are not able to register custom listeners anymore as the
dispatcher is not available outside the Framework class (a workaround could be
the adding of a Framework::getEventDispatcher() method);
We have lost the
flexibility we had before; you cannot change the implementation of
the UrlMatcher or of the ControllerResolver anymore;
Related to the previous
point, we cannot test our framework without much effort anymore as it's
impossible to mock internal objects;
We cannot change the charset passed
to ResponseListener anymore (a workaround could be to pass it as a constructor
argument).
The previous code did not exhibit the same issues because we used
dependency injection; all dependencies of our objects were injected into their
constructors (for instance, the event dispatchers were injected into the
framework so that we had total control of its creation and configuration).
Does it mean that we have to make a choice between flexibility, customization,
ease of testing and not to copy and paste the same code into each application
front controller? As you might expect, there is a solution. We can solve all
these issues and some more by using the Symfony dependency injection container:
$ composer require symfony/dependency-injection
Create a new file to host the
dependency injection container configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// example.com/src/container.php
use Simplex\\Framework;
use Symfony\\Component\\DependencyInjection;
use
Symfony\\Component\\DependencyInjection\\Reference;
use
Symfony\\Component\\EventDispatcher;
use Symfony\\Component\\HttpFoundation;
use Symfony\\Component\\HttpKernel;
use Symfony\\Component\\Routing;
$containerBuilder = new DependencyInjection\\ContainerBuilder();
$containerBuilder->register('context', Routing\\RequestContext::class);
$containerBuilder->register('matcher', Routing\\Matcher\\UrlMatcher::class)
->setArguments(\[$routes, new Reference('context')\])
;
$containerBuilder->register('request\_stack',
HttpFoundation\\RequestStack::class);
$containerBuilder->register('controller\_resolver',
HttpKernel\\Controller\\ControllerResolver::class);
$containerBuilder->register('argument\_resolver',
HttpKernel\\Controller\\ArgumentResolver::class);
$containerBuilder->register('listener.router',
HttpKernel\\EventListener\\RouterListener::class)
\->setArguments(\[new
Reference('matcher'), new Reference('request\_stack')\])
;
$containerBuilder->register('listener.response',
HttpKernel\\EventListener\\ResponseListener::class)
\->setArguments(\['UTF-8'\])
;
$containerBuilder->register('listener.exception',
HttpKernel\\EventListener\\ErrorListener::class)
->setArguments(\['Calendar\\Controller\\ErrorController::exception'\])
;
$containerBuilder->register('dispatcher',
EventDispatcher\\EventDispatcher::class)
\->addMethodCall('addSubscriber', \[new
Reference('listener.router')\])
\->addMethodCall('addSubscriber', \[new
Reference('listener.response')\])
\->addMethodCall('addSubscriber', \[new
Reference('listener.exception')\])
;
$containerBuilder->register('framework', Framework::class)
\->setArguments(\[
new Reference('dispatcher'),
new Reference('controller\_resolver'),
new
Reference('request\_stack'),
new Reference('argument\_resolver'),
\])
;
return $containerBuilder;
The goal of this file is to configure your
objects and their dependencies. Nothing is instantiated during this
configuration step. This is purely a static description of the objects you need
to manipulate and how to create them. Objects will be created on-demand when you
access them from the container or when the container needs them to create other
objects.
For instance, to create the router listener, we tell Symfony that
its class name is Symfony\\Component\\HttpKernel\\EventListener\\RouterListener and
that its constructor takes a matcher object (new Reference('matcher')). As you
can see, each object is referenced by a name, a string that uniquely identifies
each object. The name allows us to get an object and to reference it in other
object definitions.
By default, every time you get an object from the
container, it returns the exact same instance. That's because a container
manages your "global" objects.
The front controller is now only about wiring
everything together:
1
2
3
4
5
6
7
8
9
10
11
12
13
// example.com/web/front.php
require\_once
\_\_DIR\_\_.'/../vendor/autoload.php';
use
Symfony\\Component\\HttpFoundation\\Request;
$routes = include
\_\_DIR\_\_.'/../src/app.php';
$container = include
\_\_DIR\_\_.'/../src/container.php';
$request = Request::createFromGlobals();
$response = $container->get('framework')->handle($request);
$response->send();
As all the objects are now created in the dependency
injection container, the framework code should be the previous simple version:
1
2
3
4
5
6
7
8
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\\Component\\HttpKernel\\HttpKernel;
class Framework extends HttpKernel
{
}
If you want a light alternative
for your container, consider Pimple,
a simple dependency injection container in about 60 lines of PHP code.
Now,
here is how you can register a custom listener in the front controller:
1
2
3
4
5
6
7
// ...
use Simplex\\StringResponseListener;
$container->register('listener.string\_response',
StringResponseListener::class);
$container->getDefinition('dispatcher')
->addMethodCall('addSubscriber', \[new Reference('listener.string\_response')\])
;
Besides describing your objects, the dependency injection container can
also be configured via parameters. Let's create one that defines if we are in
debug mode or not:
1
2
3
$container->setParameter('debug', true);
echo $container->getParameter('debug');
These parameters can be used when
defining object definitions. Let's make the charset configurable:
1
2
3
4
// ...
$container->register('listener.response',
HttpKernel\\EventListener\\ResponseListener::class)
->setArguments(\['%charset%'\])
;
After this change, you must set the
charset before using the response listener object:
$container->setParameter('charset', 'UTF-8');
Instead of relying on the
convention that the routes are defined by the $routes variables, let's use a
parameter again:
1
2
3
4
// ...
$container->register('matcher', Routing\\Matcher\\UrlMatcher::class)
->setArguments(\['%routes%', new Reference('context')\])
;
And the related
change in the front controller:
$container->setParameter('routes', include
\_\_DIR\_\_.'/../src/app.php');
We have barely scratched the surface of what you
can do with the container: from class names as parameters, to overriding
existing object definitions, from shared service support to dumping a container
to a plain PHP class, and much more. The Symfony dependency injection container
is really powerful and is able to manage any kind of PHP class.
Don't yell at
me if you don't want to use a dependency injection container in your framework.
If you don't like it, don't use it. It's your framework, not mine.
This is
(already) the last chapter of this book on creating a framework on top of the
Symfony components. I'm aware that many topics have not been covered in great
details, but hopefully it gives you enough information to get started on your
own and to better understand how the Symfony framework works internally.
|