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

Leveraging forms and validation with Symfony

Hugo Hamon
March 01, 2013

Leveraging forms and validation with Symfony

Forms are everywhere and they are key elements for interacting with applications users. Unfortunately, designing forms is a very tedious task. Indeed, forms may contain complex behaviors and business logic to validate data. This talk will explain how to bring the Symfony forms and validation components into PHP applications to ease forms management.

Hugo Hamon

March 01, 2013
Tweet

More Decks by Hugo Hamon

Other Decks in Technology

Transcript

  1. Leveraging forms and
    validation with
    Symfony.
    Hugo HAMON – Confoo - Montreal 2013

    View full-size slide

  2. About me…
    Hugo HAMON
    Head of training at SensioLabs
    Book author
    Speaker at conferences
    Symfony contributor
    @hhamon

    View full-size slide

  3. SensioLabs
    We’re hiring
    Symfony and
    PHP top stars.

    View full-size slide

  4. Introduction
    Forms & Validation

    View full-size slide

  5. Why is form handling complex?

    View full-size slide

  6. Forms
    Architecture

    View full-size slide

  7. Form engine
    Core CSRF DI
    Doctrine Propel
    Twig PHP Smarty …
    Zend …
    Rendering
    Extensions
    Foundation

    View full-size slide

  8. Bootstrapping
    {
    "require": {
    "doctrine/common": "2.*",
    "symfony/form": "2.2.*",
    "symfony/yaml": "2.2.*",
    "symfony/http-foundation": "2.2.*",
    "symfony/validator": "2.2.*",
    "symfony/config": "2.2.*",
    "symfony/translation": "2.2.*",
    "symfony/twig-bridge": "2.2.*"
    }
    }

    View full-size slide

  9. use Symfony\Bridge\Twig\Form\TwigRendererEngine;
    use Symfony\Component\Form\Forms;
    use Symfony\Bridge\Twig\Extension\FormExtension;
    use Symfony\Bridge\Twig\Form\TwigRenderer;
    // Define some constants to the main resources
    define('VENDOR_DIR', realpath(__DIR__ . '/../vendor'));
    define('DEFAULT_FORM_THEME', 'form_div_layout.html.twig');
    define('VENDOR_TWIG_BRIDGE_DIR', VENDOR_DIR . '/symfony/twig-bridge/Symfony/Bridge/Twig');
    define('VIEWS_DIR', realpath(__DIR__ . '/../views'));
    // Initialize a Twig compatible rendering engine
    $twig = new Twig_Environment(new Twig_Loader_Filesystem(array(
    VIEWS_DIR,
    VENDOR_TWIG_BRIDGE_DIR . '/Resources/views/Form',
    )));
    $formEngine = new TwigRendererEngine(array(DEFAULT_FORM_THEME));
    $formEngine->setEnvironment($twig);
    // Register the Twig Form extension
    $twig->addExtension(new FormExtension(new TwigRenderer($formEngine)));
    // Set up the Form component
    $formFactoryBuilder = Forms::createFormFactoryBuilder();
    $formFactory = $formFactoryBuilder->getFormFactory();

    View full-size slide

  10. Forms
    The Basics

    View full-size slide

  11. Creating a simple form
    $form = $formFactory
    ->createBuilder()
    ->add('name')
    ->add('bio', 'textarea')
    ->add('gender', 'choice', array(
    'choices' => array(
    'm' => 'Male',
    'f' => 'Female'
    ),
    ))
    ->getForm();

    View full-size slide

  12. Creating a simple form

    View full-size slide

  13. The form tree
    form   form bio   textarea
    name   text
    gender   choice
    Field name   Field type  

    View full-size slide

  14. Form handling
    $name = $form->getName();
    if (!empty($_POST[$name])) {
    $form->bind($_POST[$name]);
    $data = $form->getData();
    print_r($data);
    }

    View full-size slide

  15. Array mapping
    $data['name']
    $data['bio']
    $data['gender']

    View full-size slide

  16. Prepolutation
    $form->setData(array(
    'name' => 'Jane Smith',
    'bio' => 'I do great things!',
    'gender' => 'f',
    ));

    View full-size slide

  17. Prepolutation
    Jane Smith
    I do great things!
    Female

    View full-size slide

  18. Forms
    Core field types

    View full-size slide

  19. Core field types
    §  Birthday
    §  Checkbox
    §  Choice
    §  Collection*
    §  Country
    §  Date
    §  DateTime
    §  File
    §  Hidden
    §  Integer
    §  Language
    §  Locale
    §  Money
    §  Number
    §  Password
    §  Percent
    §  Radio
    §  Repeated*
    §  Search
    §  Textarea
    §  Text
    §  Time
    §  Timezone
    §  Url

    View full-size slide

  20. Native form types
    Text Choice Password File
    Form
    Date
    Country Language Timezone Birthday DateTime

    View full-size slide

  21. The repeated type
    $builder
    ->add('name')
    ->add('password', 'repeated', array(
    'type' => 'password',
    'invalid_message' => 'Passwords are not same.',
    'first_options' => array('label' => 'Password'),
    'second_options' => array('label' => 'Confirmation'),
    ))
    ->add('bio', 'textarea')
    // [...]
    ->getForm()
    ;
     

    View full-size slide

  22. The repeated eld is
    rendered as two same
    password elds.

    View full-size slide

  23. Forms
    Rendering

    View full-size slide

  24. Rendering a form
    // PHP rendering
    echo $engine->render('profile.php', array(
    'form' => $form->createView(),
    ));
    // Twig rendering
    echo $twig->render('profile.twig', array(
    'form' => $form->createView(),
    ));

    View full-size slide

  25. Prototyping (PHP)


    Your profile
    widget($form) ?>


    Save changes
    Cancel


    View full-size slide

  26. Prototyping (Twig)


    Your profile
    {{ form_widget(form) }}


    Save changes
    Cancel


    View full-size slide


  27. class="required">Name
    name="form[name]"
    required="required"
    value="Jane Smith" />

    Prototyping

    View full-size slide

  28. // Form rendering
    enctype($form) ?>
    widget($form) ?>
    errors($form) ?>
    rest($form) ?>
    // Field rendering
    row($form['bio']) ?>
    errors($form['bio']) ?>
    label($form['bio']) ?>
    widget($form['bio'], array(
    'attr' => array('class' => 'editor'),
    )) ?>
    Piece by piece rendering (PHP)

    View full-size slide

  29. {# General rendering #}
    {{ form_enctype(form) }}
    {{ form_widget(form) }}
    {{ form_errors(form) }}
    {{ form_rest(form) }}
    {# Field rendering #}
    {{ form_row(form.bio) }}
    {{ form_errors(form.bio) }}
    {{ form_label(form.bio, 'Biography') }}
    {{ form_widget(form.bio, { 'attr': { 'class': 'editor' }}) }}
    Piece by piece rendering (Twig)

    View full-size slide

  30. Forms
    Object mapping

    View full-size slide

  31. Public properties mapping
    class Person
    {
    public $name;
    public $password;
    public $bio;
    public $gender;
    }

    View full-size slide

  32. Public properties mapping
    $person = new Person();
    $person->name = 'John Doe';
    $person->password = 'S3cR3T$1337';
    $person->bio = 'Born in 1970...';
    $person->gender = 'm';
    $person->active = true;
    // The form reads & writes
    // the person object
    $form->setData($person);

    View full-size slide

  33. Private properties mapping
    class Person
    {
    private $name;
    // ...
    function setName($name) { $this->name = $name; }
    function getName() { return $this->name; }
    }

    View full-size slide

  34. Private properties mapping
    class Person
    {
    private $active;
    // ...
    public function isActive()
    {
    return $this->active;
    }
    }

    View full-size slide

  35. Private properties mapping
    $person = new Person();
    $person->setName('John Doe');
    $person->setPassword('S3cR3T$1337');
    $person->setBio('Born in 1970...');
    $person->setGender('m');
    $person->setActive(true);
    // The form reads & writes
    // the person object
    $form->setData($person);

    View full-size slide

  36. Private properties mapping
    $form = $formFactory
    ->createBuilder('form', $person, array(
    'data_class' => 'Person',
    ))
    // ...
    ->getForm()
    ;

    View full-size slide

  37. Forms
    CSRF Protection

    View full-size slide

  38. Enabling CSRF protection
    # bootstrap.php
    // ...
    use Symfony\Component\Form\Extension\Csrf\CsrfExtension;
    use Symfony\Component\Form\Extension\Csrf\CsrfProvider\DefaultCsrfProvider;
    use Symfony\Bridge\Twig\Form\TwigRenderer;
    define('CSRF_SECRET', 'c2ioeEU1n48QF2WsHGWd2HmiuUUT6dxr');
    // Set up the CSRF provider
    $csrfProvider = new DefaultCsrfProvider(CSRF_SECRET);
    $renderer = new TwigRenderer($formEngine, $csrfProvider);
    // ...
    $formFactory = $formFactoryBuilder
    ->addExtension(new CsrfExtension($csrfProvider))
    ->getFormFactory();

    View full-size slide

  39. Enabling CSRF protection


    Your profile






    Save changes
    Cancel


    View full-size slide

  40. Forms
    Custom form class

    View full-size slide

  41. Creating a custom type
    use Symfony\Component\Form\AbstractType;
    use Symfony\Component\OptionsResolver\OptionsResolverInterface;
    class PersonType extends AbstractType
    {
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
    $resolver->setDefaults(array('data_class' => 'Person'));
    }
    public function getName()
    {
    return 'person';
    }
    }

    View full-size slide

  42. // ...
    use Symfony\Component\Form\FormBuilderInterface;
    class PersonType extends AbstractType
    {
    public function buildForm(
    FormBuilderInterface $builder,
    array $options
    )
    {
    $builder
    ->add('name')
    ->add('password', 'repeated', array(...))
    ->add('bio', 'textarea')
    ->add('gender', 'choice', array(...))
    ->add('active', 'checkbox')
    ;
    }
    }

    View full-size slide

  43. Creating a custom type
    $person = new Person();
    $person->setName('John Doe');
    // ...
    $options = array('trim' => true);
    $form = $formFactory
    ->create(new PersonType(), $person, $options)
    ;

    View full-size slide

  44. Register new custom types
    # in your code...
    $form = $formFactory->create('person', $person);
    # bootstrap.php
    $formFactory = Forms::createFormFactoryBuilder()
    // [...]
    ->addType(new PersonType(), 'person')
    ->getFormFactory()
    ;

    View full-size slide

  45. Forms
    File upload

    View full-size slide

  46. Handling file uploads
    # bootstrap.php
    // ...
    use Symfony\…\HttpFoundation\HttpFoundationExtension;
    $formFactoryBuilder = Forms::createFormFactoryBuilder()
    $formFactory = $formFactoryBuilder
    ->addExtension(new HttpFoundationExtension())
    // [...]
    ->getFormFactory()
    ;

    View full-size slide

  47. Handling file uploads
    class PersonType extends AbstractType
    {
    function buildForm(FormBuilderInterface $builder, …)
    {
    $builder
    // […]
    ->add('picture', 'file', array(
    'required' => false,
    ))
    ->add('active', 'checkbox')
    ;
    }
    }

    View full-size slide

  48. Rendering the enctype attribute



    View full-size slide

  49. Handling the form
    use Symfony\Component\HttpFoundation\Request;
    $request = Request::createFromGlobals()
    $request->overrideGlobals();
    if ($request->isMethod('post')) {
    $form->bind($request);
    var_dump($form->getData());
    }

    View full-size slide

  50. Handling the form
    object(Person)
    private 'name' => 'John Doe'
    private 'picture' =>
    object(Symfony\Component\HttpFoundation\File\UploadedFile)
    private 'test' => false
    private 'originalName' => '445.jpg'
    private 'mimeType' => 'image/jpeg'
    private 'size' => 21645
    private 'error' => 0
    private 'password' => 'secret'
    private 'bio' => 'Famous actor!'
    private 'gender' => 'm'
    private 'active' => true

    View full-size slide

  51. Handling the form
    $file = $form->get('picture')->getData();
    $target = __DIR__. '/uploads';
    if ($file->isValid()) {
    $new = $file->move($target, 'jdoe.jpg');
    }

    View full-size slide

  52. Forms
    Unmapped fields

    View full-size slide

  53. Unmapping fields
    $builder
    ->add('rules', 'checkbox', array(
    'mapped' => false,
    ))
    ;

    View full-size slide

  54. array(
    'name' => 'John Doe'
    'password' => 'secret'
    'bio' => 'Actor!'
    'gender' => 'm'
    'picture' => null
    'active' => true
    )
    Unmapping fields
    No « rules » data

    View full-size slide

  55. Forms
    Collections

    View full-size slide

  56. Embedding a fields collection
    $builder
    // ...
    ->add('hobbies', 'collection', array(
    'allow_add' => true,
    'allow_delete' => true,
    ))
    // ...
    ;

    View full-size slide

  57. Embedding a fields collection
    $person = new Person();
    $person->setName('John Doe');
    $person->addHobby('music');
    $person->addHobby('movies');
    $person->addHobby('travels');

    View full-size slide

  58. Embedding a fields collection

    View full-size slide

  59. Coding conventions
    class Person
    {
    public function addHobby($hobby)
    {
    $this->hobbies[] = $hobby;
    }
    public function removeHobby($hobby)
    {
    $key = array_search($hobby, $this->hobbies);
    if (false !== $key) {
    unset($this->hobbies[$key]);
    }
    }
    }

    View full-size slide

  60. Data prototype

    View full-size slide

  61. Forms
    Embedded forms

    View full-size slide

  62. Embedding a form into another
    class Address
    {
    private $street;
    private $zipCode;
    private $city;
    private $state;
    private $country;
    // ...
    }

    View full-size slide

  63. class AddressType extends AbstractType
    {
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder
    ->add('street', 'textarea')
    ->add('zipCode')
    ->add('city')
    ->add('state')
    ->add('country', 'country')
    ;
    }
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
    $resolver->setDefaults(array('data_class' => 'Address'));
    }
    public function getName()
    {
    return 'address';
    }
    }

    View full-size slide

  64. Embedding a form into another
    class PersonType extends AbstractType
    {
    function buildForm(FormBuilderInterface $builder, …)
    {
    $builder
    // ...
    ->add('address', new AddressType())
    ;
    }
    }

    View full-size slide

  65. Embedding a form into another
    class PersonType extends AbstractType
    {
    function buildForm(FormBuilderInterface $builder, …)
    {
    $builder
    // ...
    ->add('address', 'address')
    ;
    }
    }

    View full-size slide

  66. Embedding a form into another
    $formBuilder = Forms::createFormFactoryBuilder();
    $formFactory = $formBuilder
    // ...
    ->addType(new AddressType(), 'address')
    ->addType(new PersonType(), 'person')
    ->getFormFactory()
    ;

    View full-size slide

  67. The form tree
    form   form bio   textarea
    name   text
    …   ...
    Field   Type  
    address   Address
    zipCode   text
    address   textarea
    city   text
    country   country
    state   text

    View full-size slide

  68. Person Object
    (
    [name] => Hugo Hamon
    [picture] =>
    [username] => hhamon
    [password] => secret
    [address] => Address Object
    (
    [street] => 42 Sunshine Street
    [zipCode] => 12345
    [city] => Miami
    [state] => Florida
    [country] => US
    )
    [bio] => Speaker at conference
    [gender] => m
    [active] => 1
    [hobbies] => Array
    (
    [1] => movies
    [2] => travels
    [3] => conferences
    )
    )

    View full-size slide

  69. Forms
    I18N & L10N

    View full-size slide

  70. $builder
    ->add('birthdate', 'birthday', array('format' => 'd/M/y'))
    ->add('salary', 'money', array('currency' => 'EUR'))
    ->add('language', 'language', array(
    'preferred_choices' => array('fr'),
    )
    ->add('country', 'country', array(
    'preferred_choices' => array('FR'),
    )
    ->add('timezone', 'timezone', array(
    'preferred_choices' => array('Europe/Paris')
    )
    ;
    Localized fields

    View full-size slide

  71. Locale: fr_FR
    Locale: en_US

    View full-size slide

  72. Forms
    Theming

    View full-size slide

  73. {% form_theme form _self %}
    {% block password_widget %}

    {{ block('field_widget') }}




    {% endblock password_widget %}
    Changing all password fields

    View full-size slide

  74. {% form_theme form _self %}
    {% block _person_username_widget %}

    {{ block('field_widget') }}




    {% endblock _person_username_widget %}
    Changing one single field

    View full-size slide

  75. Custom fields rendering

    View full-size slide

  76. Validation
    The Validator

    View full-size slide

  77. // ...
    use Symfony\Component\Validator\Validation;
    use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
    $validator = Validation::createValidatorBuilder()
    ->getValidator()
    ;
    $formFactoryBuilder = Forms::createFormFactoryBuilder()
    $formFactory = $formFactoryBuilder
    // ...
    ->addExtension(new ValidatorExtension($validator))
    ->getFormFactory()
    ;
    Configuring the validator

    View full-size slide

  78. Constraint & Validator
    For each validation rule, the component ships a
    Constraint class and its associated Validator class.
    The Constraint object describes the rule to check
    and the Validator implementation runs that
    validation logic.

    View full-size slide

  79. // ...
    use Symfony\Component\Validator\Constraints\NotBlank as Assert;
    class PersonType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder->add('name', 'text', array(
    'constraints' => array(
    new Assert\NotBlank(),
    new Assert\Length(array('min' => 5, 'max' => 40)),
    ),
    ));
    // ...
    }
    }
    Field constraints mapping

    View full-size slide

  80. $genders = array('m' => 'Male', 'f' => 'Female');
    $builder->add('gender', 'choice', array(
    'choices' => $genders,
    'constraints' => array(
    new Assert\NotBlank(),
    new Assert\Choice(array(
    'choices' => array_keys($genders),
    'message' => 'Alien genders are not supported.',
    )),
    ),
    ));
    Field constraints mapping

    View full-size slide

  81. Validating the form
    $request = Request::createFromGlobals();
    $request->overrideGlobals();
    if ($request->isMethod('POST')) {
    $form->bind($request);
    if ($form->isValid()) {
    $data = $form->getData();
    // ... handle sanitized data
    }
    }

    View full-size slide

  82. Validating the form

    View full-size slide

  83. Core constraints
    §  All
    §  Blank
    §  Callback
    §  Choice
    §  Collection
    §  Count
    §  Country
    §  Date
    §  DateTime
    §  Email
    §  False
    §  File
    §  Image
    §  Ip
    §  Language
    §  Length
    §  Locale
    §  NotBlank
    §  NotNull
    §  Null
    §  Range
    §  Regex
    §  Time
    §  True
    §  Type
    §  Url
    §  Valid…

    View full-size slide

  84. Validation
    Configuration formats

    View full-size slide

  85. # bootstrap.php
    // ...
    $validator = Validation::createValidatorBuilder()
    ->addMethodMapping('loadValidatorMetadata')
    ->addXmlMapping(__DIR__.'/config/validation.xml')
    ->addYamlMapping(__DIR__.'/config/validation.yml')
    ->enableAnnotationMapping()
    ->getValidator()
    ;
    Configuring the validator

    View full-size slide

  86. use Symfony\Component\Validator\Mapping\ClassMetadata;
    use Symfony\Component\Validator\Constraints\NotBlank;
    use Symfony\Component\Validator\Constraints\Length;
    class Person
    {
    private $name;
    // ...
    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
    $metadata->addPropertyConstraint('name', new NotBlank());
    $metadata->addPropertyConstraint('name', new Length(array(
    'min' => 5,
    'max' => 40,
    )));
    // ...
    }
    }
    PHP validation mapping

    View full-size slide

  87. # src/config/validation.yml
    Person:
    properties:
    name:
    - NotBlank: ~
    - Length: { 'min': 5, 'max': 40 }
    # ...
    getters:
    # ...
    constraints:
    # ...
    YAML validation mapping

    View full-size slide








  88. 5
    40




    XML validation mapping

    View full-size slide

  89. use Symfony\Component\Validator\Constraints as Assert;
    class Person
    {
    /**
    * @Assert\NotBlank()
    * @Assert\Length(min = 5, max = 40)
    */
    private $name;
    // ...
    }
    Annotations validation mapping

    View full-size slide

  90. Validation
    Adding constraints

    View full-size slide

  91. Property constraints
    use Symfony\Component\Validator\Constraints\Image;
    class Person
    {
    private $picture;
    // ...
    static function loadValidatorMetadata(ClassMetadata $metadata)
    {
    $metadata->addPropertyConstraint('picture', new Image(array(
    'minWidth' => 100,
    'maxWidth' => 150,
    'minHeight' => 100,
    'maxHeight' => 150,
    )));
    // ...
    }
    }

    View full-size slide

  92. class Person
    {
    //...
    static function loadValidatorMetadata(ClassMetadata $metadata)
    {
    $metadata->addConstraint(new Unique(array(
    'field' => 'username',
    'message' => 'This username already exist.',
    )));
    //...
    }
    }
    Class constraints

    View full-size slide

  93. use Symfony\Component\Validator\Constraints\True;
    class Person
    {
    private $username;
    private $password;
    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
    $metadata->addGetterConstraint('passwordValid', new True(array(
    'message' => 'Password and username cannot be same.',
    )));
    // ...
    }
    public function isPasswordValid()
    {
    return strtolower($this->username) !== strtolower($this->password);
    }
    }
    Method constraints

    View full-size slide

  94. Method constraints
    The error message is
    not mapped to the
    password eld. It’s a
    global error…

    View full-size slide

  95. class PersonType extends AbstractType
    {
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
    $resolver->setDefaults(array(
    'data_class' => 'Person',
    'error_mapping' => array('passwordValid' => 'password'),
    ));
    }
    }
    Method constraints

    View full-size slide

  96. Method constraints
    The error message is
    now reaffected to the
    password concrete
    eld.

    View full-size slide

  97. Validation
    Translating messages

    View full-size slide

  98. use Symfony\Component\Translation\Translator;
    use Symfony\Component\Translation\Loader\XliffFileLoader;
    use Symfony\Bridge\Twig\Extension\TranslationExtension;
    $t = new Translator('fr');
    $t->addLoader('xlf', new XliffFileLoader());
    // Built in translations
    $t->addResource('xlf', FORM_DIR . '/Resources/translations/validators.fr.xlf', 'fr', 'validators');
    $t->addResource('xlf', VALIDATOR_DIR . '/Resources/translations/validators.fr.xlf', 'fr', 'validators');
    // Your translations
    $t->addResource('xlf', __DIR__. '/translations/validators.fr.xlf', 'fr', 'validators');
    // ...
    $twig->addExtension(new TranslationExtension($t));
    // ...
    Enabling translations

    View full-size slide



  99. original="file.ext">


    Password and username cannot be same.
    Le mot de passe et le nom d'utilisateur doivent
    être différents.




    Translating messages

    View full-size slide

  100. Validation
    Advanced configurations

    View full-size slide

  101. Validation groups
    class Person
    {
    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
    $metadata->addPropertyConstraint('password', new NotBlank(array(
    'groups' => array('Signup'),
    )));
    $metadata->addGetterConstraint('passwordValid', new True(array(
    'message' => 'Password and username cannot be same.',
    'groups' => array('Signup', 'Profile'),
    )));
    // ...
    }
    }

    View full-size slide

  102. class RegistrationType extends AbstractType
    {
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
    $resolver->setDefaults(array(
    // ...
    'validation_groups' => array('Signup'),
    ));
    }
    }
    class EditAccountType extends AbstractType
    {
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
    $resolver->setDefaults(array(
    // ...
    'validation_groups' => array('Profile'),
    ));
    }
    }

    View full-size slide

  103. Official resources
    §  http://symfony.com/doc/current/book/forms.html
    §  http://symfony.com/doc/current/reference/forms/types.html
    §  http://symfony.com/doc/current/book/validation.html
    §  http://symfony.com/doc/current/reference/constraints.html
    §  http://symfony.com/doc/current/reference/forms/twig_reference.html
    §  https://github.com/bschussek/standalone-forms
    Useful resources

    View full-size slide

  104. Thank you!
    Hugo Hamon
    [email protected]
    @hhamon

    View full-size slide