|
| 1 | +Create your own framework... on top of the Symfony2 Components (part 2) |
| 2 | +======================================================================= |
| 3 | + |
| 4 | +Before we dive into the code refactoring, I first want to step back and take a |
| 5 | +look at why you would like to use a framework instead of keeping your |
| 6 | +plain-old PHP applications as is. Why using a framework is actually a good |
| 7 | +idea, even for the simplest snippet of code and why creating your framework on |
| 8 | +top of the Symfony2 components is better than creating a framework from |
| 9 | +scratch. |
| 10 | + |
| 11 | +.. note:: |
| 12 | + |
| 13 | + I won't talk about the obvious and traditional benefits of using a |
| 14 | + framework when working on big applications with more than a few |
| 15 | + developers; the Internet has already plenty of good resources on that |
| 16 | + topic. |
| 17 | + |
| 18 | +Even if the "application" we wrote yesterday was simple enough, it suffers |
| 19 | +from a few problems:: |
| 20 | + |
| 21 | + <?php |
| 22 | + |
| 23 | + // framework/index.php |
| 24 | + |
| 25 | + $input = $_GET['name']; |
| 26 | + |
| 27 | + printf('Hello %s', $input); |
| 28 | + |
| 29 | +First, if the ``name`` query parameter is not given in the URL query string, |
| 30 | +you will get a PHP warning; so let's fix it:: |
| 31 | + |
| 32 | + <?php |
| 33 | + |
| 34 | + // framework/index.php |
| 35 | + |
| 36 | + $input = isset($_GET['name']) ? $_GET['name'] : 'World'; |
| 37 | + |
| 38 | + printf('Hello %s', $input); |
| 39 | + |
| 40 | +Then, this *application is not secure*. Can you believe it? Even this simple |
| 41 | +snippet of PHP code is vulnerable to one of the most widespread Internet |
| 42 | +security issue, XSS (Cross-Site Scripting). Here is a more secure version:: |
| 43 | + |
| 44 | + <?php |
| 45 | + |
| 46 | + $input = isset($_GET['name']) ? $_GET['name'] : 'World'; |
| 47 | + |
| 48 | + header('Content-Type: text/html; charset=utf-8'); |
| 49 | + |
| 50 | + printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')); |
| 51 | + |
| 52 | +.. note:: |
| 53 | + |
| 54 | + As you might have noticed, securing your code with ``htmlspecialchars`` is |
| 55 | + tedious and error prone. That's one of the reasons why using a template |
| 56 | + engine like `Twig`_, where auto-escaping is enabled by default, might be a |
| 57 | + good idea (and explicit escaping is also less painful with the usage of a |
| 58 | + simple ``e`` filter). |
| 59 | + |
| 60 | +As you can see for yourself, the simple code we had written first is not that |
| 61 | +simple anymore if we want to avoid PHP warnings/notices and make the code |
| 62 | +more secure. |
| 63 | + |
| 64 | +Beyond security, this code is not even easily testable. Even if there is not |
| 65 | +much to test, it strikes me that writing unit tests for the simplest possible |
| 66 | +snippet of PHP code is not natural and feels ugly. Here is a tentative PHPUnit |
| 67 | +unit test for the above code:: |
| 68 | + |
| 69 | + <?php |
| 70 | + |
| 71 | + // framework/test.php |
| 72 | + |
| 73 | + class IndexTest extends \PHPUnit_Framework_TestCase |
| 74 | + { |
| 75 | + public function testHello() |
| 76 | + { |
| 77 | + $_GET['name'] = 'Fabien'; |
| 78 | + |
| 79 | + ob_start(); |
| 80 | + include 'index.php'; |
| 81 | + $content = ob_get_clean(); |
| 82 | + |
| 83 | + $this->assertEquals('Hello Fabien', $content); |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | +.. note:: |
| 88 | + |
| 89 | + If our application were just slightly bigger, we would have been able to |
| 90 | + find even more problems. If you are curious about them, read the `Symfony2 |
| 91 | + versus Flat PHP`_ chapter of the Symfony2 documentation. |
| 92 | + |
| 93 | +At this point, if you are not convinced that security and testing are indeed |
| 94 | +two very good reasons to stop writing code the old way and adopt a framework |
| 95 | +instead (whatever adopting a framework means in this context), you can stop |
| 96 | +reading this series now and go back to whatever code you were working on |
| 97 | +before. |
| 98 | + |
| 99 | +.. note:: |
| 100 | + |
| 101 | + Of course, using a framework should give you more than just security and |
| 102 | + testability, but the more important thing to keep in mind is that the |
| 103 | + framework you choose must allow you to write better code faster. |
| 104 | + |
| 105 | +Going OOP with the HttpFoundation Component |
| 106 | +------------------------------------------- |
| 107 | + |
| 108 | +Writing web code is about interacting with HTTP. So, the fundamental |
| 109 | +principles of our framework should be centered around the `HTTP |
| 110 | +specification`_. |
| 111 | + |
| 112 | +The HTTP specification describes how a client (a browser for instance) |
| 113 | +interacts with a server (our application via a web server). The dialog between |
| 114 | +the client and the server is specified by well-defined *messages*, requests |
| 115 | +and responses: *the client sends a request to the server and based on this |
| 116 | +request, the server returns a response*. |
| 117 | + |
| 118 | +In PHP, the request is represented by global variables (``$_GET``, ``$_POST``, |
| 119 | +``$_FILE``, ``$_COOKIE``, ``$_SESSION``...) and the response is generated by |
| 120 | +functions (``echo``, ``header``, ``setcookie``, ...). |
| 121 | + |
| 122 | +The first step towards better code is probably to use an Object-Oriented |
| 123 | +approach; that's the main goal of the Symfony2 HttpFoundation component: |
| 124 | +replacing the default PHP global variables and functions by an Object-Oriented |
| 125 | +layer. |
| 126 | + |
| 127 | +To use this component, open the ``composer.json`` file and add it as a |
| 128 | +dependency for the project: |
| 129 | + |
| 130 | +.. code-block:: json |
| 131 | +
|
| 132 | + # framework/composer.json |
| 133 | + { |
| 134 | + "require": { |
| 135 | + "symfony/class-loader": "2.1.*", |
| 136 | + "symfony/http-foundation": "2.1.*" |
| 137 | + } |
| 138 | + } |
| 139 | +
|
| 140 | +Then, run the composer ``update`` command: |
| 141 | + |
| 142 | +.. code-block:: sh |
| 143 | +
|
| 144 | + $ php composer.phar update |
| 145 | +
|
| 146 | +Finally, at the bottom of the ``autoload.php`` file, add the code needed to |
| 147 | +autoload the component:: |
| 148 | + |
| 149 | + <?php |
| 150 | + |
| 151 | + // framework/autoload.php |
| 152 | + |
| 153 | + $loader->registerNamespace('Symfony\\Component\\HttpFoundation', __DIR__.'/vendor/symfony/http-foundation'); |
| 154 | + |
| 155 | +Now, let's rewrite our application by using the ``Request`` and the |
| 156 | +``Response`` classes:: |
| 157 | + |
| 158 | + <php |
| 159 | + |
| 160 | + // framework/index.php |
| 161 | + |
| 162 | + require_once __DIR__.'/autoload.php'; |
| 163 | + |
| 164 | + use Symfony\Component\HttpFoundation\Request; |
| 165 | + use Symfony\Component\HttpFoundation\Response; |
| 166 | + |
| 167 | + $request = Request::createFromGlobals(); |
| 168 | + |
| 169 | + $input = $request->get('name', 'World'); |
| 170 | + |
| 171 | + $response = new Response(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'))); |
| 172 | + |
| 173 | + $response->send(); |
| 174 | + |
| 175 | +The ``createFromGlobals()`` method creates a ``Request`` object based on the |
| 176 | +current PHP global variables. |
| 177 | + |
| 178 | +The ``send()`` method sends the ``Response`` object back to the client (it |
| 179 | +first outputs the HTTP headers followed by the content). |
| 180 | + |
| 181 | +.. tip:: |
| 182 | + |
| 183 | + Before the ``send()`` call, we should have added a call to the |
| 184 | + ``prepare()`` method (``$response->prepare($request);``) to ensure that |
| 185 | + our Response were compliant with the HTTP specification. For instance, if |
| 186 | + we were to call the page with the ``HEAD`` method, it would have removed |
| 187 | + the content of the Response. |
| 188 | + |
| 189 | +The main difference with the previous code is that you have total control of |
| 190 | +the HTTP messages. You can create whatever request you want and you are in |
| 191 | +charge of sending the response whenever you see fit. |
| 192 | + |
| 193 | +.. note:: |
| 194 | + |
| 195 | + We haven't explicitly set the ``Content-Type`` header in the rewritten |
| 196 | + code as the Response object defaults to ``UTF-8`` by default. |
| 197 | + |
| 198 | +With the ``Request`` class, you have all the request information at your |
| 199 | +fingertips thanks to a nice and simple API:: |
| 200 | + |
| 201 | + <?php |
| 202 | + |
| 203 | + // the URI being requested (e.g. /about) minus any query parameters |
| 204 | + $request->getPathInfo(); |
| 205 | + |
| 206 | + // retrieve GET and POST variables respectively |
| 207 | + $request->query->get('foo'); |
| 208 | + $request->request->get('bar', 'default value if bar does not exist'); |
| 209 | + |
| 210 | + // retrieve SERVER variables |
| 211 | + $request->server->get('HTTP_HOST'); |
| 212 | + |
| 213 | + // retrieves an instance of UploadedFile identified by foo |
| 214 | + $request->files->get('foo'); |
| 215 | + |
| 216 | + // retrieve a COOKIE value |
| 217 | + $request->cookies->get('PHPSESSID'); |
| 218 | + |
| 219 | + // retrieve an HTTP request header, with normalized, lowercase keys |
| 220 | + $request->headers->get('host'); |
| 221 | + $request->headers->get('content_type'); |
| 222 | + |
| 223 | + $request->getMethod(); // GET, POST, PUT, DELETE, HEAD |
| 224 | + $request->getLanguages(); // an array of languages the client accepts |
| 225 | + |
| 226 | +You can also simulate a request:: |
| 227 | + |
| 228 | + $request = Request::create('/index.php?name=Fabien'); |
| 229 | + |
| 230 | +With the ``Response`` class, you can easily tweak the response:: |
| 231 | + |
| 232 | + <?php |
| 233 | + |
| 234 | + $response = new Response(); |
| 235 | + |
| 236 | + $response->setContent('Hello world!'); |
| 237 | + $response->setStatusCode(200); |
| 238 | + $response->headers->set('Content-Type', 'text/html'); |
| 239 | + |
| 240 | + // configure the HTTP cache headers |
| 241 | + $response->setMaxAge(10); |
| 242 | + |
| 243 | +.. tip:: |
| 244 | + |
| 245 | + To debug a Response, cast it to a string; it will return the HTTP |
| 246 | + representation of the response (headers and content). |
| 247 | + |
| 248 | +Last but not the least, these classes, like every other class in the Symfony |
| 249 | +code, have been `audited`_ for security issues by an independent company. And |
| 250 | +being an Open-Source project also means that many other developers around the |
| 251 | +world have read the code and have already fixed potential security problems. |
| 252 | +When was the last you ordered a professional security audit for your home-made |
| 253 | +framework? |
| 254 | + |
| 255 | +Even something as simple as getting the client IP address can be insecure:: |
| 256 | + |
| 257 | + <?php |
| 258 | + |
| 259 | + if ($myIp == $_SERVER['REMOTE_ADDR']) { |
| 260 | + // the client is a known one, so give it some more privilege |
| 261 | + } |
| 262 | + |
| 263 | +It works perfectly fine until you add a reverse proxy in front of the |
| 264 | +production servers; at this point, you will have to change your code to make |
| 265 | +it work on both your development machine (where you don't have a proxy) and |
| 266 | +your servers:: |
| 267 | + |
| 268 | + <?php |
| 269 | + |
| 270 | + if ($myIp == $_SERVER['HTTP_X_FORWARDED_FOR'] || $myIp == $_SERVER['REMOTE_ADDR']) { |
| 271 | + // the client is a known one, so give it some more privilege |
| 272 | + } |
| 273 | + |
| 274 | +Using the ``Request::getClientIp()`` method would have given you the right |
| 275 | +behavior from day one (and it would have covered the case where where you have |
| 276 | +chained proxies):: |
| 277 | + |
| 278 | + <?php |
| 279 | + |
| 280 | + $request = Request::createFromGlobals(); |
| 281 | + |
| 282 | + if ($myIp == $request->getClientIp()) { |
| 283 | + // the client is a known one, so give it some more privilege |
| 284 | + } |
| 285 | + |
| 286 | +And there is an added benefit: it is *secure* by default. What do I mean by |
| 287 | +secure? The ``$_SERVER['HTTP_X_FORWARDED_FOR']`` value cannot be trusted as it |
| 288 | +can be manipulated by the end user when there is no proxy. So, if you are |
| 289 | +using this code in production without a proxy, it becomes trivially easy to |
| 290 | +abuse your system. That's not the case with the ``getClientIp()`` method as |
| 291 | +you must explicitly trust this header by calling ``trustProxyData()``:: |
| 292 | + |
| 293 | + <?php |
| 294 | + |
| 295 | + Request::trustProxyData(); |
| 296 | + |
| 297 | + if ($myIp == $request->getClientIp(true)) { |
| 298 | + // the client is a known one, so give it some more privilege |
| 299 | + } |
| 300 | + |
| 301 | +So, the ``getClientIp()`` method works securely in all circumstances. You can |
| 302 | +use it in all your projects, whatever the configuration is, it will behave |
| 303 | +correctly and safely. That's one of the goal of using a framework. If you were |
| 304 | +to write a framework from scratch, you would have to think about all these |
| 305 | +cases by yourself. Why not using a technology that already works? |
| 306 | + |
| 307 | +.. note:: |
| 308 | + |
| 309 | + If you want to learn more about the HttpFoundation component, you can have |
| 310 | + a look at the `API`_ or read its dedicated `documentation`_ on the Symfony |
| 311 | + website. |
| 312 | + |
| 313 | +Believe or not but we have our first framework. You can stop now if you want. |
| 314 | +Using just the Symfony2 HttpFoundation component already allows you to write |
| 315 | +better and more testable code. It also allows you to write code faster as many |
| 316 | +day-to-day problems have already been solved for you. |
| 317 | + |
| 318 | +As a matter of fact, projects like Drupal have adopted (for the upcoming |
| 319 | +version 8) the HttpFoundation component; if it works for them, it will |
| 320 | +probably work for you. Don't reinvent the wheel. |
| 321 | + |
| 322 | +I've almost forgot to talk about one added benefit: using the HttpFoundation |
| 323 | +component is the start of better interoperability between all frameworks and |
| 324 | +applications using it (as of today Symfony2, Drupal 8, phpBB 4, Silex, |
| 325 | +Midguard CMS, ...). |
| 326 | + |
| 327 | +.. _`Twig`: http://twig.sensiolabs.com/ |
| 328 | +.. _`Symfony2 versus Flat PHP`: http://symfony.com/doc/current/book/from_flat_php_to_symfony2.html |
| 329 | +.. _`HTTP specification`: http://tools.ietf.org/wg/httpbis/ |
| 330 | +.. _`API`: http://api.symfony.com/2.0/Symfony/Component/HttpFoundation.html |
| 331 | +.. _`documentation`: http://symfony.com/doc/current/components/http_foundation.html |
| 332 | +.. _`audited`: http://symfony.com/blog/symfony2-security-audit |
0 commit comments