$30 off During Our Annual Pro Sale. View Details »

Developing cacheable PHP applications - Confoo 2018

Developing cacheable PHP applications - Confoo 2018

Thijs Feryn

March 09, 2018
Tweet

More Decks by Thijs Feryn

Other Decks in Technology

Transcript

  1. Developing cacheable
    PHP applications
    Thijs Feryn

    View Slide

  2. Slow websites suck

    View Slide

  3. Hi, I’m Thijs

    View Slide

  4. I’m
    @ThijsFeryn
    on Twitter

    View Slide

  5. I’m an
    Evangelist
    At

    View Slide

  6. I’m an
    Evangelist
    At

    View Slide

  7. View Slide

  8. Cache

    View Slide

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

    View Slide

  10. View Slide

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

    View Slide

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

    View Slide

  13. View Slide

  14. Reverse
    caching
    proxy

    View Slide

  15. Normally
    User Server

    View Slide

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

    View Slide

  17. Content Delivery Network

    View Slide

  18. View Slide

  19. 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 Slide

  20. In an ideal world

    View Slide

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

    View Slide

  22. Reality
    sucks

    View Slide

  23. Common
    problems

    View Slide

  24. View Slide

  25. View Slide

  26. Time To Live

    View Slide

  27. Cache variations

    View Slide

  28. Authentication

    View Slide

  29. Legacy

    View Slide

  30. View Slide

  31. Twig templates

    View Slide

  32. View Slide

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

    View Slide

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

    View Slide

  35. 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 Slide




  36. {% 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 Slide

  37. {% 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 Slide



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


    templates/nav.twig

    View Slide



  39. Footer

    templates/footer.twig

    View Slide

  40. 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 Slide

  41. 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 Slide

  42. 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 Slide

  43. 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 Slide

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

    View Slide

  45. View Slide

  46. View Slide

  47. The mission
    Maximum
    Cacheability

    View Slide

  48. Cache-control

    View Slide

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

    View Slide

  50. 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 Slide

  51. Conditional
    requests

    View Slide

  52. Only fetch
    payload that has
    changed

    View Slide

  53. HTTP/1.1 200 OK

    View Slide

  54. Otherwise:
    HTTP/1.1 304 Not Modified

    View Slide

  55. 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 Slide

  56. 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 Slide

  57. 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 Slide

  58. 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 Slide

  59. composer require symfony-bundles/redis-bundle

    View Slide

  60. 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 Slide

  61. {
    $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 Slide

  62. Do not cache

    View Slide

  63. Cache-Control: private, no-store

    View Slide

  64. /**
    * @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 Slide

  65. session
    cookie
    No cache

    View Slide

  66. View Slide

  67. Code
    renders
    single HTTP
    response

    View Slide

  68. Lowest
    common
    denominator:
    no cache

    View Slide

  69. Block caching

    View Slide


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

    View Slide

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

    Parse ESI placeholders
    Varnish

    View Slide

  72. ESI
    vs
    AJAX

    View Slide

  73. ✓ 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 Slide

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

    View Slide

  75. Subrequests

    View Slide


  76. {{ 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 Slide









  77. 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 Slide

  78. /**
    * @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 Slide

  79. Problem:
    no language
    cache
    variation

    View Slide

  80. Vary: Accept-Language

    View Slide

  81. 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 Slide

  82. View Slide

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

    View Slide

  84. Move state client-side

    View Slide

  85. Replace PHP session with
    JSON Web Tokens

    View Slide

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

    View Slide

  87. 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 Slide

  88. 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 Slide

  89. 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 Slide

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

    View Slide

  91. Extra cache
    variation
    required

    View Slide

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

    View Slide

  93. 
<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 Slide

  94. Does not require
    backend access

    View Slide

  95. composer require firebase/php-jwt

    View Slide

  96. 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 Slide

  97. $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 Slide

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

    View Slide

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

    View Slide

  100. /**
    * @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 Slide

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

    View Slide

  102. /**
    * @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 Slide

  103. {% 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 Slide

  104. View Slide

  105. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  110. View Slide