Save 37% off PRO during our Black Friday Sale! »

Building an Open-Source Campaign Platform for the new President of France

E2ed7c278c8c49bb3e7fe0b7de039997?s=47 Hugo Hamon
November 05, 2017

Building an Open-Source Campaign Platform for the new President of France

This case study will take you to the technical backdrop of Emmanuel Macron's victorious campaign in the French presidential elections of 2017. We will reveal how the En-Marche and SensioLabs teams worked closely together to develop a collaborative, Open-Source and citizen platform with PHP 7 and Symfony to structure the movement. In addition to the technical and political issues involved in this project, we'll present solutions such as continuous deployment with Docker under Kubernetes, continuous integration with Circle CI, SCRUM and Kanban project management, use of Rabbitmq, Symfony 3 new features, and more. We'll demonstrate the importance of choosing these solutions to meet the agility and scalability needs of the highlights of the campaign.

E2ed7c278c8c49bb3e7fe0b7de039997?s=128

Hugo Hamon

November 05, 2017
Tweet

Transcript

  1. Building an Open-Source Campaign Platform for the President of France

    . PHP CE 2017 / Nov. 5th / Ossa / Poland Hugo Hamon
  2. Hugo Hamon

  3. Hugo Hamon Senior Software Developer 15 years of PHP experience

    10 years of Symfony experience Conferences speaker @hhamon on social networks Books (co) author
  4. Titouan Galopin Enrolls to En-Marche movement Initiates the Symfony migration

    Full-Stack Developer Setup the new technical stack @titouangalopin
  5. Innovative e-learning solution targeting Symfony teams. https://university.sensiolabs.com

  6. https://clujcon2017.symfony.com/

  7. What is this talk trully about?

  8. French Political System Open-Source Technologies Project Management Presidential Campaign Strategy

    Real World Project Case Study
  9. The Context

  10. Why is the Presidential election such a challenging environment?

  11. Why building a Citizen Collaborative Platform?

  12. Accept donations Register adherents Facilitate communication Structure movement

  13. What was the French Politic landscape?

  14. Socialists, liberals, center Many Political Parties Revolutionists, workers, conservatives Ecologists,

    hunters & fishermen, etc. Citizens loosing confidence in politicians Mostly led by aged politicians Lack of diversity & equity Established parties have lots of money
  15. April 2016

  16. None
  17. Summer 2016

  18. +

  19. None
  20. October 2016

  21. None
  22. https://github.com/enmarche/en-marche.fr

  23. https://en-marche.fr

  24. Response Web Design Full featured Admin area Users management Fined

    grained permissions Http caching Images lazy loading Content Management Unit & functional tests Mass emails management Maps & address geocoding Social networks integration Procurations management Media management Field actions reports Legislatives elections support Logging Search engine Etc.
  25. November 2016

  26. Emmanuel Macron formalizes his candidacy for the presidential election.

  27. December 2016

  28. None
  29. None
  30. # app/config/parameters.yml parameters: env(DATABASE_HOST): db env(DATABASE_PORT): 3306 env(DATABASE_SOCKET): null env(DATABASE_NAME):

    enmarche env(DATABASE_USER): root env(DATABASE_PASSWORD): root
  31. None
  32. January 2017

  33. None
  34. None
  35. trait EntityIdentityTrait { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue

    */ private $id; /** * @ORM\Column(type="uuid") * @var Ramsey\Uuid\UuidInterface */ private $uuid; // ... + getters } Custom UUIDs for Doctrine entities.
  36. class MembershipController extends Controller { // ... public function registerAction(Request

    $request): Response { $membership = MembershipRequest::createWithCaptcha(...); $form = $this ->createForm(MembershipRequestType::class, $membership) ->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->get('app.membership')->handle($membership); return $this->redirectToRoute('app_membership_donate'); } return $this->render('...', [ 'form' => $form->createView(), // ... ]); } } Thin & dumb controllers.
  37. src/Mailjet/ ├── ApiClient.php ├── ClientInterface.php ├── EmailTemplate.php ├── EmailTemplateFactory.php ├──

    Event/ │ ├── MailjetEvent.php │ └── MailjetEvents.php ├── EventSubscriber/ │ └── EmailPersisterEventSubscriber.php ├── Exception/ │ └── MailjetException.php ├── MailjetService.php ├── MailjetUtils.php ├── Message/ │ ├── AdherentAccountActivationMessage.php │ ├── AdherentAccountConfirmationMessage.php │ ├── ... │ └── TonMacronFriendMessage.php └── Transport/ ├── ApiTransport.php ├── RabbitMQTransport.php └── TransportInterface.php
  38. class MailjetService { // ... public function sendMessage(MailjetMessage $message): bool

    { $email = $this->factory->createFromMailjetMessage($message); $event = new MailjetEvent($message, $email); try { $this->dispatch(MailjetEvents::DELIVERY_MESSAGE, $event); $this->transport->sendTemplateEmail($email); $this->dispatch(MailjetEvents::DELIVERY_SUCCESS, $event); } catch (MailjetException $e) { $this->dispatch( MailjetEvents::DELIVERY_ERROR, new MailjetEvent($message, $email, $e) ); return false; } return true; } }
  39. class MembershipRequestHandler { // ... public function handle(MembershipRequest $membershipRequest) {

    $adherent = $this->factory->createFromMembershipRequest($membershipRequest); $token = AdherentActivationToken::generate($adherent); $this->manager->persist($adherent); $this->manager->persist($token); $this->manager->flush(); $message = AdherentAccountActivationMessage::createFromAdherent( $adherent, $this->generateMembershipActivationUrl($adherent, $token) ); $this->mailjet->sendMessage($message); $e = new AdherentAccountWasCreatedEvent($adherent); $this->dispatch(..., $e); } }
  40. February 2017

  41. None
  42. class CommitteeManagementAuthority { // ... function followCommittee(Adherent $adherent, Committee $committee):

    void { $this->manager->followCommittee($adherent, $committee); if (!$hosts = $this->manager->getCommitteeHosts($committee)) { return; } $this->mailjet->sendMessage(CommitteeNewFollowerMessage::create( $committee, $hosts, $adherent, $this->urlGenerator->getUrl('...', $committee) )); } } Service Layer
  43. Service Layer Custom voters src/Committee/Voter ├─ AbstractCommitteeVoter.php ├─ CreateCommitteeVoter.php ├─

    FollowCommitteeVoter.php ├─ HostCommitteeVoter.php ├─ ShowCommitteeVoter.php └─ SuperviseCommitteeVoter.php
  44. class Geocoder implements GeocoderInterface { // ... public function geocode(string

    $address): Coordinates { try { $addresses = $this->geocoder->geocode($address); } catch (\Exception $exception) { throw GeocodingException::create($address, $exception); } if (!count($addresses)) { throw GeocodingException::create($address); } $geo = $addresses->first(); return new Coordinates( $geo->getLatitude(), $geo->getLongitude() ); } } Geocoding addresses Based on Google Maps
  45. class EntityAddressGeocodingSubscriber implements EventSubscriberInterface { // ... public function onCommitteeCreated(CommitteeWasCreatedEvent

    $event) { $this->updateGeocodableEntity($event->getCommittee()); } private function updateGeocodableEntity(GeoPointInterface $geocodable) { if ($coords = $this->geocode($geocodable->getGeocodableAddress())) { $geocodable->updateCoordinates($coords); $this->manager->flush(); } } } Doctrine listener to update coordinates of any Geocodable entity when it’s saved to the database.
  46. None
  47. None
  48. /** * @group functional */ class CommitteeControllerTest extends MysqlWebTestCase {

    // ... function testAnonymousUserIsNotAllowedToFollowCommittee() { $crawler = $this->client->request('GET', ...); $this->assertResponseStatusCode(200, ...); $this->assertFalse($this->seeFollowLink($crawler)); $this->assertFalse($this->seeUnfollowLink($crawler)); $this->assertTrue($this->seeRegisterLink($crawler)); } } Functional Testing PHPUnit
  49. Continuous Integration Pull-Requests SensioLabsInsight Style CI Circle CI

  50. March 2017

  51. Program announced! +6,000 users live +300,000 daily

  52. <script type="application/ld+json"> { "@context": "http://schema.org", "@type": "WebSite", "url": "http://en-marche.fr", "name":

    "En Marche !", "image": "http://en-marche.fr/images/default_sharer.jpg", "description": "Pour ceux qui ... l'Europe.", "funder": { "@type": "Person", "givenName": "Emmanuel", "familyName": "Macron", "jobTitle": "President of France" } } </script> Schema.org JSON LD Metadata in HTML code
  53. Optimizing natural indexing in search engines.

  54. # app/config/parameters.yml parameters: # Staging configuration env(ENABLE_CANARY): 1 # Production

    configuration env(ENABLE_CANARY): 0 Feature Flags Env vars
  55. class PageController extends Controller { // ... public function ellesMarchentAction()

    { if (!$this->getParameter('enable_canary'))) { throw $this->createNotFoundException(); } return $this->render('... '); } } Feature Flags
  56. Procuration System

  57. April 2017

  58. Official regulations require candidates to stop proactive propaganda 24h before

    each ballot.
  59. Notifications Committees & events Make the website read-only! Contents publication

    Emails
  60. Who’s trully concerned?

  61. https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg

  62. UTC -3 Guyana Saint Pierre and Miquelon UTC -4 St.

    Martin St. Barthelemy Guadeloupe Martinique UTC -8 Clipperton UTC -9 / UTC -9,5 / UTC -10 French Polynesia https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg
  63. UTC +3 Mayotte Europa Island https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg UTC +4 Crozet Islands

    Reunion Glorious Islands Tromelin Island Juan de Nova UTC +5 St. Paul and Amsterdam Kerguelen Islands UTC +10 Adelie Land UTC +10 New Caledonia UTC +12 Wallis and Futuna
  64. How to make the platform fullfil the official regulations?

  65. {% if not app.request.attributes.get('_campaign_expired') and not user_is_adherent %} <a href="{{

    hwi_oauth_login_url('auth') }}" class="btn b__nudge--right-nano"> Connexion </a> <a href="{{ auth_register_url }}" class="btn b__nudge--right"> Inscription </a> {% endif %} Disabling links in Twig
  66. class ArticleController extends Controller { /** * @Route( * "/articles/{category}/{page}",

    * requirements={"category"="\w+", "page"="\d+"}, * defaults={ "_enable_campaign_silence": true } * ) */ public function actualitesAction(...): Response { ... } } Marking a mutable action
  67. class CampaignSilenceSubscriber implements EventSubscriberInterface { // ... public function onKernelController(FilterControllerEvent

    $event) { // ... $expired = $this->processor->isCampaignExpired($request); $request->attributes->set('_campaign_expired', $expired); if (!$expired || $request->attributes->get('_enable_campaign_silence', false)) { return; } $event->setController(function () { return new Response($this->twig->render('campaign_silent.html.twig')); }); } }
  68. April 2017 The road to the first round! Fullfil government

    rules & regulations # app/config/config_prod.yml services: app.mailjet.transport.transactional: class: 'AppBundle\Mailjet\Transport\MailjetNullTransport' arguments: ['@?logger'] public: false app.mailjet.transport.campaign: class: 'AppBundle\Mailjet\Transport\MailjetNullTransport' arguments: ['@?logger'] public: false
  69. April 23th 1st Ballot

  70. ~500,000 connections to the website.

  71. May 2017

  72. # ... http { # ... server { # ...

    # Block WP Pingback DDoS attacks if ($http_user_agent ~* "WordPress") { return 403; } # ... } } WordPress pingback DDoS attacks
  73. class AssetsController extends Controller { /** * @Route("/assets/{path}") * @Method("GET")

    * @Cache(maxage=900, smaxage=900) */ public function assetAction(string $path, ...) { // ... } } Http caching for static assets
  74. May 7th 2nd Ballot

  75. ~650,000 connections to the website.

  76. Wrap Up

  77. Thank you! https://www.flickr.com/photos/enmarchefr/31534490113/ @titouangalopin @hhamon @sensiolabs