Listen up Drupal!

Robert Slootjes



slootjes

  • Who am I?
  • Some History
  • Symfony Goodies
  • Controllers Redefined
  • API Building
  • Bonus
  • Conclusions
Who am I?

PHP Developer at MediaMonks

“MediaMonks is a global creative production partner. We work with the world’s leading agencies, biggest brands and innovative media and technology companies.”

Founded in 2001, 600 Monks, 10 Offices

2006 Multiple custom frameworks

2008 Zend Framework

2012 Composer & Packagist

2013 Silex

2015 Symfony Framework

So....?

no drupal?

2016 Drupal

Dad, Gamer, DJ, Beer Snob™

Goals

Create scalability

Combine strengths

Get people of their islands

Look beyond drupalisms

Make Drupal look like Symfony Framework ;-)

Some History

Lot's of different backend languages
with their own frameworks

PHP Ruby .NET Node.JS Python

And don't forget about the frontend

Javascript iOS Android Unity Flash

APIs were a mess

XML AMF JSON

Data Status Codes Error handling

Pagination Form Handling

Rest API Spec

A collaboration between all dev department leads

github.com/mediamonks/documents

Making it easy for PHP devs

Event Subscriber for Silex

Return anything from a controller

Throw exceptions anywhere

Single file, no tests...

Ported to a Symfony Framework bundle

No longer a single file

Customizable

Properly tested


github.com/mediamonks/symfony-rest-api-bundle

Show us some code!

Basic Usage


class ExampleController {
    public function integerAction() {
        return 42;
    }
}
						

{
    "data": 42
}
						

Basic Usage


class ExampleController {
    public function stringAction() {
        return 'foobar';
    }
}
						

{
    "data": "foobar"
}
						

Basic Usage


class ExampleController {
    public function arrayAction() {
        return ['foo', 'bar'];
    }
}
						

{
    "data": ["foo","bar"]
}
						

Custom Status Code And Headers


use Symfony\Component\HttpFoundation\Response;

class ExampleController {
    public function symfonyResponseAction() {
        return new Response('foobar'
            Response::HTTP_CREATED,
                ['X-My-Header' => 'My Value']
        );
    }
}
						

Exceptions


class ExampleController {
    public function exceptionAction() {
        throw new \Exception('Foo');
    }
}
						

{
    "error": {
        "code": "error",
        "message": "Foo"
    }
}
						

Symfony Http Exceptions


class ExampleController {
    public function notFoundExceptionAction() {
        throw new NotFoundHttpException('Entity not found');
    }
}
						

{
    "error": {
        "code": "error.http.not_found",
        "message": "Entity not found"
    }
}
						

Symfony Form Exceptions


class ExampleController {
    public function formAction() {
        /** @var Symfony\Form $form **/
        throw new FormValidationException($form);
    }
}
						

Form Exceptions


{
    "error": {
        "code": "error.form.validation",
        "message": "Not all fields are filled in correctly.",
        "fields": [{
            "field": "#",
            "code": "error.form.validation.csrf",
            "message": "The CSRF token is invalid."
        }, {
            "field": "task",
            "code": "error.form.validation.not_blank",
            "message": "This value should not be blank."
        }]
    }
}
						

Profit

But what about Drupal?

Symfony Goodies
“Drupal 8 is using Symfony Framework”

wrong...

Symfony Components !== Symfony Framework

Symfony Framework bundles ≈ Drupal modules

Bundles and modules are not compatible

“Symfony is a set of reusable PHP components...”

The standard foundation on which the best PHP applications are built. Choose any of the 50 stand-alone components available for your own applications.

“... and a PHP framework for web projects”

Speed up the creation and maintenance of your PHP web applications. End repetitive coding tasks and enjoy the power of controlling your code.

So what components does Drupal 8 use?

  • HttpKernel
    • HttpFoundation
    • EventDispatcher
  • Routing
  • DependencyInjection
  • YAML
  • ...

HttpKernel

Converting a Request into a Response


use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;

$autoloader = require_once __DIR__.'/../vendor/autoload.php';

$kernel = new DrupalKernel('prod', $autoloader);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

The HttpKernel Component

Hook into the kernel with EventSubscribers

A powerful method to change behavior

What kernel events are there?

"kernel.request"

  • Add more information
  • Check something in the request
  • Get something from cache
  • Directly return a response

"kernel.controller"

  • Allows setting/updating the controller callable
  • Initialize services

"kernel.view"

  • Transform non-response object return value into a response object

"kernel.response"

  • Add headers
  • Store in cache
  • Inject content
  • Serialize

"kernel.exception"

  • Convert into a response object
  • Change exception

"kernel.terminate"

  • Perform "heavy" action after response is sent
  • Useful with PHP FPM

Creating an EventSubscriber


use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CustomHeaderSubscriber implements EventSubscriberInterface {

  public function onResponse(FilterResponseEvent $event) {
    $event->getResponse()->headers->set('Custom', 'Drupal <3 Symfony');
  }

  public static function getSubscribedEvents() {
    return [KernelEvents::RESPONSE => ['onResponse']];
  }
}
						

Setting a priority


public static function getSubscribedEvents() {
    return [
        KernelEvents::RESPONSE => [
            ['onResponse', 100]
        ]
    ];
}
						

Higher priority = earlier execution

Register the service


services:
  acme.event_subscriber.custom_header:
    class: Drupal\acme\EventSubscriber\CustomHeaderSubscriber:
    tags:
      - { name: event_subscriber }
						

Register the service, the new way


services:
  Drupal\acme\EventSubscriber\CustomHeaderSubscriber:
    tags:
      - { name: event_subscriber }
						

That was easy!

Porting the bundle to Drupal

The library only relies on HttpFoundation

Split the bundle into a package and a bundle

Framework agnostic

Would it work with Drupal?

A custom module with 2 yml files did the trick

github.com/mediamonks/php-rest-api

info.yml


name: MediaMonks Rest API
description: Transforms controller results into Rest API spec
package: Custom
type: module
core: 8.x
					

services.yml


services:
  MediaMonks\RestApi\EventSubscriber\RestApiEventSubscriber:
    autowire: true
    tags:
      - { name: event_subscriber }

  MediaMonks\RestApi\Request\PathRequestMatcher
    public: false
    arguments:
      - '/api'

  # a few more services
					

Controller


public function helloWorldAction() {
    return 'Hello World';
}
					

Response


{
    data: "Hello World"
}
					
Controllers Redefined

Controllers as Services

No ControllerBase

Inject only services you need

Advantages

  • Configuration with Dependency Injection
  • See what's available
  • Prevent getting fat controllers

Disadvantages

  • Create your own helper functions
  • A controller is usually not reusable
  • Takes more work to configure*

Highly debated in the Symfony community.

There is no right or wrong,
it depends on personal preference

How to do it in Drupal?


use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class AcmeController implements ContainerInjectionInterface {
    private $wordGenerator;

    public function __construct(WordGenerator $wordGenerator) {
        $this->wordGenerator = $wordGenerator;
    }

    public static function create(ContainerInterface $container) {
        return new static($container->get(WordGenerator::class));
    }
}
					

Still coupled to Drupal and the Symfony container :(

Without create()


use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;

class ExampleController {
    private $wordGenerator;

    public function __construct(WordGenerator $wordGenerator) {
        $this->wordGenerator = $wordGenerator;
    }
}
					

Register your controller


services:
  Drupal\acme\WordGenerator: ~

  Drupal\acme\Controller\ExampleController:
    autowire: true
					

Autowiring ftw!

Set up routing


example:
  path: /acme/foobar
  defaults:
    _controller: Drupal\acme\Controller\ExampleController:foobarAction
					

Undocumented at that moment

Structure of routes

Controller Annotations

Symfony Framework "Extra" Bundle


/**
 * @Route("/{id}")
 * @Method("GET")
 * @ParamConverter("post", class="AcmeBundleBundle:Post")
 * @Template("AcmeBundle:Annot:show.html.twig", vars={"post"})
 * @Cache(smaxage="15")
 * @Security("has_role('ROLE_ADMIN')")
 */
public function showAction(Post $post) { // your code }
					

Can we do this in Drupal?

No... but

it's based on Event Subscribers

Ported to Drupal


/**
 * @Route("/{article}")
 * @Method("GET")
 * @ParamConverter("article", options={"bundle": "article"})
 * @Template()
 * @Cache(maxage="15")
 * @Security(permission="access content")
 */
public function showAction(Node $article) { // your code }
					

Register the routing annotations


acme.annotations:
    path: /api
    options:
        type: annotation
        module: 'acme'
					

Routing


/** @Route("/examples") */
class ExampleController extends ControllerBase {
    /** @Route @Method("GET")*/
    public function listAction(Node $article) { // your code }

    /** @Route @Method("POST") */
    public function createAction(Node $article) { // your code }
}
					

Security


/**
 * @Security(permission="access content")
 * @Security(role="administrator")
 * @Security(access=true)
 * @Security(csrf=true)
 * @Security(custom="Drupal\acme\Security\Controller::access")
 */
public function editAction(Node $article) { // your code }
					

Template


/**
 * @Template("acme:example:show")
 * @Template("acme:show")
 * @Template
 */
public function showAction(Node $article) { }
					
modules/<module>/templates/<module>-<controller>(-<action>).html.twig

Cache


/**
 * @Cache(expires="tomorrow", public=true)
 * @Cache(expires="+2 days")
 * @Cache(smaxage="15", maxage="15", public=true)
 */
public function showAction(Node $article) { }
					

ParamConverter


/**
 * @ParamConverter("article", options={"bundle": "article"})
 * @ParamConverter("article")
 * @ParamConverter
 */
public function showAction(Node $article) { }
					

ParamConverter


/**
 * @Route("/blogs/archive/{start}/{end}")
 * @ParamConverter("start", options={"format": "Y-m-d"})
 * @ParamConverter("end", options={"format": "Y-m-d"})
 */
public function archiveAction(\DateTime $start, \DateTime $end) { }
					

Create your own


namespace Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\HttpFoundation\Request;

interface ParamConverterInterface
{
    function apply(Request $request, ParamConverter $configuration);

    function supports(ParamConverter $configuration);
}
					

PSR-7 Support did not need porting


namespace Drupal\acme\Controller;

use Psr\Http\Message\ServerRequestInterface;
use Zend\Diactoros\Response;

class AcmeController
{
    public function psr7Action(ServerRequestInterface $request) {
        $response = new Response();
        $response->getBody()->write('Hello world!');

        return $response;
    }
}
					

How does it work?

Act on certain events

Modify request and response objects

Routing uses "routing.route_dynamic" event

Development Process

  • Require Framework Extra Bundle
  • Create custom module
  • Register the event subscribers
  • Fail
  • Change event subscriber priorities
  • Refactor incompatible event subscribers
  • Remove Framework Extra Bundle

Try it!

drupal.org/project/controller_annotations

API Building

Content

Fractal

“Fractal provides a presentation and transformation layer for complex data output, the like found in RESTful APIs, and works really well with JSON. Think of this as a view layer for your JSON/YAML/etc.”

Transformers


namespace Drupal\api\Transformer;

use Drupal\node\Entity\Node;
use League\Fractal\TransformerAbstract;

class ArticleTransformer extends TransformerAbstract {
    public function transform(Node $article) {
        return [
            'id' => (int)$article->id(),
            'title' => $article->get('title')->value,
            'body' => $article->get('body')->value,
        ];
    }
}
					

Transform a single entity


$fractal = new League\Fractal\Manager;
$transformer = new Drupal\api\Transformer\ArticleTransformer;
$resource = new League\Fractal\Resource\Item($entity, $transformer);
$fractal->createData($resource)->toArray();

Transform multiple entities


$fractal = new League\Fractal\Manager;
$transformer = new Drupal\api\Transformer\ArticleTransformer;
$resource = new League\Fractal\Resource\Collection($entities, $transformer);
$fractal->createData($resource)->toArray();

Includes

/article?include=author


class ArticleTransformer extends TransformerAbstract {
    public function transform(Node $article) {
        // node conversion to array
    }
    public function includeAuthor(Node $article) {
        $author = $article->author;

        return $this->item($author, new AuthorTransformer);
    }
}                   

Enabling includes


use League\Fractal;

$fractal = new Fractal\Manager();
if (isset($_GET['include'])) {
    $fractal->parseIncludes($_GET['include']);
}
				

Be aware of lazy loading!

Try it!

fractal.thephpleague.com

Forms

No one likes forms

Symfony Form eases the pain

Great builder

Highly customizable

Validation included

Can be used in Drupal without issues

A simplified example


class ExampleType extends AbstractType {
  public function buildForm(FormBuilderInterface $builder, array $options) {
    $builder
      ->add('title', TextType::class, [
        'constraints' => [
          new NotBlank(),
          new Length(['min' => 3, 'max' => 50])]
        ]
      );
  }
}

Example API Implementation in Drupal


private function handleForm(Request $request, FormTypeInterface $form) {
    $form = $this->formFactory->create($form);
    $form->submit($request->request->all());
    if (!$form->isValid()) {
        throw new FormValidationException($form);
    }

    $node = $this->getNodeStorage()->create(
      array_merge($form->getData(), ['type' => 'article'])
    );
    $node->save();

    return new Response(null, Response::HTTP_CREATED);
}
					

Rendering with Twig - Controller Side

public function createAction(Request $request) {
    $form = $this->createFormBuilder($node)
        ->add('task', TextType::class)
        ->add('dueDate', DateType::class)
        ->add('save', SubmitType::class, ['label' => 'Create Task'])
        ->getForm();

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $node = $form->getData();
        return $this->redirect('node_created');
    }
    return ['form' => $form->createView()];
}

Rendering with Twig - Template side

{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}

Form types for every need

  • Money
  • Url
  • Range
  • Choice
  • Country
  • Language
  • Currency
  • DateTime
  • Create your own!

Validators for every need

  • NotBlank
  • Length
  • Email
  • Url
  • Date
  • Regex
  • Image
  • Create your own!

Try it!

symfony.com/doc/3.4/components/form.html

symfony.com/doc/3.4/components/validator.html

Putting it all together

github.com/slootjes/drupal-example-api-module

Bonus Time
More practical use of Event Subscribers!
SEO With Prerender

Decoupled since 2014

Scalability

Frontend use their own frameworks

Creative freedom

Page transitions

So...what about SEO?

Some Solutions

  • Ignore
  • Assume everyone uses Google
  • Duplicate routing in backend
  • Prerender / PhantomJS

Common Implementation

  • Request
  • Detect bot based on user agent
  • Send to Prerender server

Our Implementation

  • Crawl website
  • Send it to Prerender
  • Store in database
  • Listen for Request
  • Find match in database
  • Inject crawled content

PHP Crawler

  • Using generators
  • Highly customizable
  • Whitelisting
  • Blacklisting
  • Normalizing

Basic example


use MediaMonks\Crawler\Crawler;

$crawler = new Crawler;
foreach($crawler->crawl('https://mediamonks.com') as $page) {
    $page->getUrl(); // League Http Uri Scheme object
    $page->getCrawler(); // Symfony DomCrawler object
}
					

Getting page data


$c = $page->getCrawler(); // Symfony DomCrawler object
$c->filterXPath('html/head/title')->text();
$c->filterXPath('html/head/meta[@name="description"]')->attr('content');
$c->filter('div#main')->html();
$c->filter('div#main')->text();
					

Blacklist


use MediaMonks\Crawler\Crawler;
use MediaMonks\Crawler\Url\Matcher\PathRegexUrlMatcher;

$crawler = new Crawler;
$crawler->addBlacklistUrlMatcher(new PathRegexUrlMatcher('~^/admin~'));
$crawler->addBlacklistUrlMatcher(new PathRegexUrlMatcher('~^/search~'));
foreach($crawler->crawl('https://mediamonks.com') as $page) {
    $page->getUrl(); // Leaugue Http Uri Scheme object
    $page->getCrawler(); // Symfony DomCrawler object
}
					

How to implement it in Drupal?

  • Console command
  • Event subscriber
  • Return response if a match was found

Keeping it up to date?

  • Timed
  • CMS Triggered
  • Priority

Another benefit

Full site search!

Give it a try

mediamonks/crawler

mediamonks/symfony-crawler-bundle

More?

Development made easier

Warning: never do this in production!

Disable route cache


class RebuildRouteEventSubscriber implements EventSubscriberInterface {
    /** @param RouteBuilderInterface $routeBuilder */
    public function __construct(RouteBuilderInterface $routeBuilder) {
        $this->routeBuilder = $routeBuilder;
    }
    public static function getSubscribedEvents() {
        return [KernelEvents::REQUEST => [['onRequest', 1024]]];
    }
    public function onRequest() {
        $this->routeBuilder->rebuild();
    }
}
							

Disable container dumping


use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;

$autoloader = require_once __DIR__.'/../vendor/autoload.php';

$kernel = new DrupalKernel('dev', $autoloader, FALSE);
						

bool $allow_dumping
to stop the container from being written to or read from disk. Defaults to TRUE.

Drupal Console

Searching for routes and services


drupal router:debug | grep article
drupal container:debug | grep article
							
TL;DL
  • Events subscribers are really powerful
  • Symfony knowledge is very useful
  • Help out on a project without knowing Drupal
  • There is more than drupal.org
  • Tons of packages on packagist.org
  • We can learn from each others solutions
  • Please use Composer
  • In the end it's all PHP

That's it for now, questions?

slootjes

robert@mediamonks.com