Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Developing cacheable PHP applications - PHP Limburg BE 2018

Thijs Feryn
March 28, 2018
63

Developing cacheable PHP applications - PHP Limburg BE 2018

Thijs Feryn

March 28, 2018
Tweet

Transcript

  1. Developing cacheable
    PHP applications
    Thijs Feryn

    View full-size slide

  2. Slow websites suck

    View full-size slide

  3. Hi, I’m Thijs

    View full-size slide

  4. I’m
    @ThijsFeryn
    on Twitter

    View full-size slide

  5. I’m an
    Evangelist
    At

    View full-size slide

  6. Don’t
    recompute
    if the data
    hasn’t
    changed

    View full-size slide

  7. What if we
    could design
    our software
    with HTTP
    caching in
    mind?

    View full-size slide

  8. ✓Portability
    ✓Developer empowerment
    ✓Control
    ✓Consistent caching behavior
    Caching state of mind

    View full-size slide

  9. Reverse
    caching
    proxy

    View full-size slide

  10. Normally
    User Server

    View full-size slide

  11. With ReCaPro *
    User ReCaPro Server
    * Reverse Caching Proxy

    View full-size slide

  12. Content Delivery Network

    View full-size slide

  13. HTTP caching mechanisms
    Expires: Sat, 09 Sep 2017 14:30:00 GMT
    Cache-control: public, max-age=3600,
    s-maxage=86400
    Cache-control: private, no-cache, no-store

    View full-size slide

  14. In an ideal world

    View full-size slide

  15. ✓Stateless
    ✓Well-defined TTL
    ✓Cache / no-cache per resource
    ✓Cache variations
    ✓Conditional requests
    ✓HTTP fragments for non-
    cacheable content
    In an ideal world

    View full-size slide

  16. Reality
    sucks

    View full-size slide

  17. Common
    problems

    View full-size slide

  18. Time To Live

    View full-size slide

  19. Cache variations

    View full-size slide

  20. Authentication

    View full-size slide

  21. Twig templates

    View full-size slide

  22. ✓Multi-lingual (Accept-Language)
    ✓Nav
    ✓Header
    ✓Footer
    ✓Main
    ✓Login page & private content

    View full-size slide

  23. composer require symfony/flex
    composer require annotations twig translation

    View full-size slide

  24. namespace App\Controller;
    use Symfony\Component\HttpFoundation\Request;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    class DefaultController extends Controller
    {
    /**
    * @Route("/", name="home")
    */
    public function index()
    {
    return $this->render('index.twig');
    }
    }
    src/Controller/DefaultController.php

    View full-size slide




  25. {% block title %}{% endblock %}


    integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
    crossorigin="anonymous">



    {{ include('header.twig') }}


    {{ include('nav.twig') }}


    {% block content %}{% endblock %}


    {{ include('footer.twig') }}



    templates/base.twig

    View full-size slide

  26. {% extends "base.twig" %}
    {% block title %}Home{% endblock %}
    {% block content %}

    {{ 'example' | trans }}

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci
    eget libero sollicitudin, non ultrices turpis mollis. Aliquam sit amet tempus elit.
    Ut viverra risus enim, ut venenatis justo accumsan nec. Praesent a dolor tellus.
    Maecenas non mauris leo. Pellentesque lobortis turpis at dapibus laoreet. Mauris
    rhoncus nulla et urna mollis, et lobortis magna ornare. Etiam et sapien consequat,
    egestas felis sit amet, dignissim enim.
    Quisque quis mollis justo, imperdiet fermentum velit. Aliquam nulla justo,
    consectetur et diam non, luctus commodo metus. Vestibulum fermentum efficitur nulla
    non luctus. Nunc lorem nunc, mollis id efficitur et, aliquet sit amet ante. Sed
    ipsum turpis, vehicula eu semper eu, malesuada eget leo. Vestibulum venenatis dui
    id pulvinar suscipit. Etiam nec massa pharetra justo pharetra dignissim quis non
    magna. Integer id convallis lectus. Nam non ullamcorper metus. Ut vestibulum ex ut
    massa posuere tincidunt. Vestibulum hendrerit neque id lorem rhoncus aliquam. Duis
    a facilisis metus, a faucibus nulla.
    {% endblock %}
    templates/index.twig

    View full-size slide



  27. Home
    {{ 'log_in' | trans }}
    Private


    templates/nav.twig

    View full-size slide



  28. Footer

    templates/footer.twig

    View full-size slide

  29. home: Home
    welcome : Welcome to the site
    rendered : Rendered at %date%
    example : An example page
    log_in : Log in
    login : Login
    log_out : Log out
    username : Username
    password : Password
    private : Private
    privatetext : Looks like some very private data
    translations/messages.en.yml

    View full-size slide

  30. home: Start
    welcome : Welkom op de site
    rendered : Samengesteld op %date%
    example : Een voorbeeldpagina
    log_in : Inloggen
    login : Login
    log_out : Uitloggen
    username : Gebruikersnaam
    password : Wachtwoord
    private : Privé
    privatetext : Deze tekst ziet er vrij privé uit
    translations/messages.nl.yml

    View full-size slide

  31. namespace App\EventListener;
    use Symfony\Component\HttpKernel\Event\GetResponseEvent;
    class LocaleListener
    {
    public function onKernelRequest(GetResponseEvent $event)
    {
    $request = $event->getRequest();
    $preferredLanguage = $request->getPreferredLanguage();
    if(null !== $preferredLanguage) {
    $request->setLocale($preferredLanguage);
    }
    }
    }
    src/EventListener/LocaleListener.php

    View full-size slide

  32. services:
    _defaults:
    autowire: true
    autoconfigure: true
    public: false
    App\:
    resource: '../src/*'
    exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
    App\Controller\:
    resource: '../src/Controller'
    tags: ['controller.service_arguments']
    App\EventListener\LocaleListener:
    tags:
    - { name: kernel.event_listener, event: kernel.request, priority: 100}
    config/services.yml

    View full-size slide

  33. Accept-Language: en
    Accept-Language: nl
    VS

    View full-size slide

  34. The mission
    Maximum
    Cacheability

    View full-size slide

  35. Cache-control

    View full-size slide

  36. Cache-Control: public, s-maxage=500

    View full-size slide

  37. namespace App\Controller;
    use Symfony\Component\HttpFoundation\Request;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    class DefaultController extends Controller
    {
    /**
    * @Route("/", name="home")
    */
    public function index()
    {
    return $this
    ->render('index.twig')
    ->setSharedMaxAge(500)
    ->setPublic();
    }
    }

    View full-size slide

  38. Conditional
    requests

    View full-size slide

  39. Only fetch
    payload that has
    changed

    View full-size slide

  40. HTTP/1.1 200 OK

    View full-size slide

  41. Otherwise:
    HTTP/1.1 304 Not Modified

    View full-size slide

  42. Conditional requests
    HTTP/1.1 200 OK
    Host: localhost
    Etag: 7c9d70604c6061da9bb9377d3f00eb27
    Content-type: text/html; charset=UTF-8
    Hello world output
    GET / HTTP/1.1
    Host: localhost
    User-Agent: curl/7.48.0

    View full-size slide

  43. Conditional requests
    HTTP/1.0 304 Not Modified
    Host: localhost
    Etag: 7c9d70604c6061da9bb9377d3f00eb27
    GET / HTTP/1.1
    Host: localhost
    User-Agent: curl/7.48.0
    If-None-Match:
    7c9d70604c6061da9bb9377d3f00eb27

    View full-size slide

  44. Conditional requests
    HTTP/1.1 200 OK
    Host: localhost
    Last-Modified: Fri, 22 Jul 2016 10:11:16 GMT
    Content-type: text/html; charset=UTF-8
    Hello world output
    GET / HTTP/1.1
    Host: localhost
    User-Agent: curl/7.48.0

    View full-size slide

  45. Conditional requests
    HTTP/1.0 304 Not Modified
    Host: localhost
    Last-Modified: Fri, 22 Jul 2016 10:11:16 GMT
    GET / HTTP/1.1
    Host: localhost
    User-Agent: curl/7.48.0
    If-Last-Modified: Fri, 22 Jul 2016 10:11:16
    GMT

    View full-size slide

  46. composer require symfony-bundles/redis-bundle

    View full-size slide

  47. namespace App\EventListener;
    use Symfony\Bridge\Monolog\Logger;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Event\GetResponseEvent;
    use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
    use SymfonyBundles\RedisBundle\Redis\Client as RedisClient;
    class ConditionalRequestListener
    {
    protected $redis;
    protected $logger;
    public function __construct(RedisClient $redis, Logger $logger)
    {
    $this->redis = $redis;
    $this->logger = $logger;
    }
    protected function isModified(Request $request, $etag)
    {
    if ($etags = $request->getETags()) {
    return in_array($etag, $etags) || in_array('*', $etags);
    }
    return true;
    }
    ...
    src/EventListener/ConditionalRequestListener.php

    View full-size slide

  48. {
    $this->redis = $redis;
    $this->logger = $logger;
    }
    protected function isModified(Request $request, $etag)
    {
    if ($etags = $request->getETags()) {
    return in_array($etag, $etags) || in_array('*', $etags);
    }
    return true;
    }
    public function onKernelRequest(GetResponseEvent $event)
    {
    $request = $event->getRequest();
    $etag = $this->redis->get('etag:'.md5($request->getUri()));
    if(!$this->isModified($request,$etag)) {
    $event->setResponse(Response::create('Not Modified',Response::HTTP_NOT_MODIFIED));
    }
    }
    public function onKernelResponse(FilterResponseEvent $event)
    {
    $response = $event->getResponse();
    $request = $event->getRequest();
    $etag = md5($response->getContent());
    $response->setEtag($etag);
    if($this->isModified($request,$etag)) {
    $this->redis->set('etag:'.md5($request->getUri()),$etag);
    }
    }
    }
    src/EventListener/ConditionalRequestListener.php

    View full-size slide

  49. Do not cache

    View full-size slide

  50. Cache-Control: private, no-store

    View full-size slide

  51. /**
    * @Route("/private", name="private")
    */
    public function private()
    {
    $response = $this
    ->render('private.twig')
    ->setPrivate();
    $response->headers->addCacheControlDirective('no-store');
    return $response;
    }
    Dot not
    cache private
    page

    View full-size slide

  52. session
    cookie
    No cache

    View full-size slide

  53. Code
    renders
    single HTTP
    response

    View full-size slide

  54. Lowest
    common
    denominator:
    no cache

    View full-size slide

  55. Block caching

    View full-size slide


  56. Edge Side Includes
    ✓Placeholder
    ✓Parsed by Varnish
    ✓Output is a composition of blocks
    ✓State per block
    ✓TTL per block

    View full-size slide

  57. Surrogate-Capability: key="ESI/1.0"
    Surrogate-Control: content="ESI/1.0"
    Varnish
    Backend

    Parse ESI placeholders
    Varnish

    View full-size slide

  58. ✓ Server-side
    ✓ Standardized
    ✓ Processed on the
    “edge”, no in the
    browser
    ✓ Generally faster
    Edge-Side Includes
    - Sequential
    - One fails, all fail
    - Limited
    implementation in
    Varnish

    View full-size slide

  59. ✓ Client-side
    ✓ Common knowledge
    ✓ Parallel processing
    ✓ Graceful
    degradation
    AJAX
    - Processed by the
    browser
    - Extra roundtrips
    - Somewhat slower

    View full-size slide


  60. {{ include('header.twig') }}



    {{ include('nav.twig') }}



    {% block content %}{% endblock %}



    {{ include('footer.twig') }}



    {{ render_esi(url('header')) }}



    {{ render_esi(url('nav')) }}



    {% block content %}{% endblock %}



    {{ render_esi(url('footer')) }}


    View full-size slide









  61. An example page Rendered at 2017-05-17 16:57:14

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci eget libero
    sollicitudin,…




    View full-size slide

  62. /**
    * @Route("/", name="home")
    */
    public function index()
    {
    return $this
    ->render('index.twig')
    ->setPublic()
    ->setSharedMaxAge(500);
    }
    /**
    * @Route("/header", name="header")
    */
    public function header()
    {
    $response = $this
    ->render('header.twig')
    ->setPrivate();
    $response->headers->addCacheControlDirective('no-store');
    return $response;
    }
    /**
    * @Route("/footer", name="footer")
    */
    public function footer()
    {
    $response = $this->render('footer.twig');
    $response
    ->setSharedMaxAge(500)
    ->setPublic();
    return $response;
    }
    /**
    * @Route("/nav", name="nav")
    */
    public function nav()
    {
    $response = $this->render('nav.twig');
    $response
    ->setVary('X-Login',false)
    ->setSharedMaxAge(500)
    ->setPublic();
    return $response;
    }
    Controller
    action per
    fragment

    View full-size slide

  63. Problem:
    no language
    cache
    variation

    View full-size slide

  64. Vary: Accept-Language

    View full-size slide

  65. namespace App\EventListener;
    use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
    class VaryListener
    {
    public function onKernelResponse(FilterResponseEvent $event)
    {
    $response = $event->getResponse();
    $response->setVary('Accept-Language',false);
    }
    }
    src/EventListener/VaryListener.php

    View full-size slide

  66. ✓Navigation page
    ✓Private page
    Weak spots
    Not cached
    because of
    stateful content

    View full-size slide

  67. Move state client-side

    View full-size slide

  68. Replace PHP session with
    JSON Web Tokens

    View full-size slide

  69. JWT
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pb
    iIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydWV9.u4Idy-
    SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8
    ✓3 parts
    ✓Dot separated
    ✓Base64 encoded JSON
    ✓Header
    ✓Payload
    ✓Signature (HMAC with secret)

    View full-size slide

  70. eyJzdWIiOiJhZG1pbiIsIm
    V4cCI6MTQ5NTUyODc1Niwi
    bG9naW4iOnRydWV9
    {
    "alg": "HS256",
    "typ": "JWT"
    }
    {
    "sub": "admin",
    "exp": 1495528756,
    "login": true
    }
    HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
    )
    eyJhbGciOiJIUzI1NiIsI
    nR5cCI6IkpXVCJ9
    u4Idy-
    SYnrFdnH1h9_sNc4OasOR
    BJcrh2fPo1EOTre8

    View full-size slide

  71. JWT
    Cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz
    dWIiOiJhZG1pbiIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydW
    V9.u4Idy-SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8
    ✓Stored in a cookie
    ✓Can be validated by Varnish
    ✓Payload can be processed by any
    language (e.g. Javascript)

    View full-size slide

  72. sub jwt {
    std.log("Ready to perform some JWT magic");
    if(cookie.isset("jwt_cookie")) {
    #Extract header data from JWT
    var.set("token", cookie.get("jwt_cookie"));
    var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
    var.set("type", regsub(digest.base64url_decode(var.get("header")),{"^.*?"typ"\s*:\s*"(\w+)".*?$"},"\1"));
    var.set("algorithm", regsub(digest.base64url_decode(var.get("header")),{"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1"));
    #Don't allow invalid JWT header
    if(var.get("type") == "JWT" && var.get("algorithm") == "HS256") {
    #Extract signature & payload data from JWT
    var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+$","\1"));
    var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+)$","\1"));
    var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var.get("key"),var.get("header") + "." + var.get("rawPayload"))));
    var.set("payload", digest.base64url_decode(var.get("rawPayload")));
    var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*([0-9]+).*?$"},"\1"));
    var.set("jti",regsub(var.get("payload"),{"^.*?"jti"\s*:\s*"([a-z0-9A-Z_\-]+)".*?$"},"\1"));
    var.set("userId",regsub(var.get("payload"),{"^.*?"uid"\s*:\s*"([0-9]+)".*?$"},"\1"));
    var.set("roles",regsub(var.get("payload"),{"^.*?"roles"\s*:\s*"([a-z0-9A-Z_\-, ]+)".*?$"},"\1"));
    #Only allow valid userId
    if(var.get("userId") ~ "^\d+$") {
    #Don't allow expired JWT
    if(std.time(var.get("exp"),now) >= now) {
    #SessionId should match JTI value from JWT
    if(cookie.get(var.get("sessionCookie")) == var.get("jti")) {
    #Don't allow invalid JWT signature
    if(var.get("signature") == var.get("currentSignature")) {
    #The sweet spot
    set req.http.X-login="true";
    } else {
    std.log("JWT: signature doesn't match. Received: " + var.get("signature") + ", expected: " + var.get("currentSignature"));
    }
    } else {
    std.log("JWT: session cookie doesn't match JTI." + var.get("sessionCookie") + ": " + cookie.get(var.get("sessionCookie")) + ", JTI:" + var.get("jti"));
    }
    } else {
    std.log("JWT: token has expired");
    }
    } else {
    std.log("UserId '"+ var.get("userId") +"', is not numeric");
    }
    } else {
    std.log("JWT: type is not JWT or algorithm is not HS256");
    }
    std.log("JWT processing finished. UserId: " + var.get("userId") + ". X-Login: " + req.http.X-login);
    }
    #Look for full private content
    if(req.url ~ "/node/2" && req.url !~ "^/user/login") {
    if(req.http.X-login != "true") {
    return(synth(302,"/user/login?destination=" + req.url));
    }
    }
    }
    Insert incomprehensible
    Varnish VCL code here …

    View full-size slide

  73. X-Login: true
    End result:
    X-Login: false
    Custom request
    header set by
    Varnish

    View full-size slide

  74. Extra cache
    variation
    required

    View full-size slide

  75. Vary: Accept-Language, X-Login
    Content for logged-in
    & anonymous differs

    View full-size slide

  76. 
<br/>function getCookie(name) {
<br/>var value = "; " + document.cookie;
<br/>var parts = value.split("; " + name + "=");
<br/>if (parts.length == 2) return parts.pop().split(";").shift();
<br/>}
<br/>function parseJwt (token) {
<br/>var base64Url = token.split('.')[1];
<br/>var base64 = base64Url.replace('-', '+').replace('_', '/');
<br/>return JSON.parse(window.atob(base64));
<br/>};
<br/>$(document).ready(function(){
<br/>if ($.cookie('token') != null ){
<br/>var token = parseJwt($.cookie("token"));
<br/>$("#usernameLabel").html(', ' + token.sub);
<br/>}
<br/>});
<br/>
    Parse JWT
    in Javascript

    View full-size slide

  77. Does not require
    backend access

    View full-size slide

  78. composer require firebase/php-jwt

    View full-size slide

  79. namespace App\Service;
    use Firebase\JWT\JWT;
    use Symfony\Component\HttpFoundation\Cookie;
    class JwtAuthentication
    {
    protected $key;
    protected $username;
    protected $password;
    public function __construct($key,$username,$password)
    {
    $this->key = $key;
    $this->username = $username;
    $this->password = $password;
    }
    public function jwt($username)
    {
    return JWT::encode([
    'sub'=>$username,
    'exp'=>time() + (4 * 24 * 60 * 60),
    'login'=>true,
    ],$this->key);
    }
    public function createCookie($username)
    {
    return new Cookie("token",$this->jwt($username), time() + (3600 * 48), '/', null,
    false, false);
    }
    public function validate($token)
    {
    src/Service/JwtAuthentication.php

    View full-size slide

  80. $this->password = $password;
    }
    public function jwt($username)
    {
    return JWT::encode([
    'sub'=>$username,
    'exp'=>time() + (4 * 24 * 60 * 60),
    //'exp'=>time() + 60,
    'login'=>true,
    ],$this->key);
    }
    public function createCookie($username)
    {
    return new Cookie("token",$this->jwt($username), time() + (3600 * 48), '/', null,
    false, false);
    }
    public function validate($token)
    {
    try {
    $data = JWT::decode($token,$this->key,['HS256']);
    $data = (array)$data;
    if($data['sub'] !== $this->username) {
    return false;
    }
    return true;
    } catch(\UnexpectedValueException $e) {
    return false;
    }
    }
    }
    src/Service/JwtAuthentication.php

    View full-size slide

  81. services:
    App\Service\JwtAuthentication:
    arguments:
    $key: '%env(JWT_KEY)%'
    $username: '%env(JWT_USERNAME)%'
    $password: '%env(JWT_PASSWORD)%'
    src/Service/JwtAuthentication.php

    View full-size slide

  82. ###> JWT authentication ###
    JWT_KEY=SlowWebSitesSuck
    JWT_USERNAME=admin
    JWT_PASSWORD=$2y$10$431rvq1qS9ewNFP0Gti/o.kBbuMK4zs8IDTLlxm5uzV7cbv8wKt0K
    ###< JWT authentication ###
    .env

    View full-size slide

  83. /**
    * @Route("/login", name="login", methods="GET")
    */
    public function login(Request $request, JwtAuthentication $jwt)
    {
    if($jwt->validate($request->cookies->get('token'))) {
    return new RedirectResponse($this->generateUrl('home'));
    }
    $response = $this->render('login.twig',['loginLogoutUrl'=>$this-
    >generateUrl('login'),'loginLogoutLabel'=>'log_in']);
    $response
    ->setSharedMaxAge(500)
    ->setVary('X-Login',false)
    ->setPublic();
    return $response;
    }
    /**
    * @Route("/login", name="loginpost", methods="POST")
    */
    public function loginpost(Request $request, JwtAuthentication $jwt)
    {
    $username = $request->get('username');
    $password = $request->get('password');
    if(!$username || !$password || getenv('JWT_USERNAME') != $username || !
    password_verify($password,getenv('JWT_PASSWORD'))) {
    return new RedirectResponse($this->generateUrl('login'));
    }
    $response = new RedirectResponse($this->generateUrl('home'));
    $response->headers->setCookie($jwt->createCookie($username));
    return $response;
    }
    src/Controller/DefaultController.php

    View full-size slide

  84. /**
    * @Route("/logout", name="logout")
    */
    public function logout()
    {
    $response = new RedirectResponse($this->generateUrl('login'));
    $response->headers->clearCookie('token');
    return $response;
    }
    src/Controller/DefaultController.php

    View full-size slide

  85. /**
    * @Route("/nav", name="nav")
    */
    public function nav(Request $request, JwtAuthentication $jwt)
    {
    if($jwt->validate($request->cookies->get('token'))) {
    $loginLogoutUrl = $loginLogoutUrl = $this->generateUrl('logout');
    $loginLogoutLabel = 'log_out';
    } else {
    $loginLogoutUrl = $this->generateUrl('login');
    $loginLogoutLabel = 'log_in';
    }
    $response = $this->render('nav.twig',
    ['loginLogoutUrl'=>$loginLogoutUrl,'loginLogoutLabel'=>$loginLogoutLabel]);
    $response
    ->setVary('X-Login',false)
    ->setSharedMaxAge(500)
    ->setPublic();
    return $response;
    }
    src/Controller/DefaultController.php

    View full-size slide

  86. {% extends "base.twig" %}
    {% block title %}Login{% endblock %}
    {% block content %}

    {{ 'log_in' | trans }}



    {{ 'username' | trans }}

    placeholder="{{ 'username' | trans }}">



    {{ 'password' | trans }}

    placeholder="{{ 'password' | trans }}">




    {{ 'log_in' | trans }}



    {% endblock %}
    templates/login.twig

    View full-size slide

  87. Cookie:
    token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1Ni
    J9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTUyMDk0M
    jExNSwibG9naW4iOnRydWV9._161Lf8BKkbzjqVv
    5d62O5aMdCotKvCqd7F8qFqZC2Y

    View full-size slide

  88. With the proper VCL, Varnish
    can process the JWT and
    make cache variations for
    authenticated & anonymous
    content

    View full-size slide

  89. https://github.com/ThijsFeryn/
    cacheable-sites-symfony4

    View full-size slide

  90. https://feryn.eu
    https://twitter.com/ThijsFeryn
    https://instagram.com/ThijsFeryn

    View full-size slide