Cookie Services: How to Handle Cookies in Drupal & Symfony

Recently I found myself needing to manage cookie data in Drupal 9 for the first time. As oft, my first step in the process was to search through Drupal core and the Drupal API for examples of “the right way” to handle cookies. After I didn’t find any reasonable existing solution within Drupal, it was time to solve this on my own.

In my case I had an array of data I needed to store in a cookie, and that data needed to be easily accessible by many parts of the system. This data (visitor location) determined how various components would appear (or not appear) throughout the site, and what pages the visitor would be recommended.

Problems & Considerations

  1. We don’t want to access the global data directly through $_COOKIE because this is equivalent to changing values with $GLOBALS. Meaning we don’t have control over the data, we can’t inject it as a dependency, and as more parts of the system use the $_COOKIE data directly the more those parts become implicitly dependent on each other and on an unreliable statefulness.
  2. Cookies are a part of both the Request and Response. Their current values are submitted to the system as a part of a Request made by the visitors. And the setting of new values for the cookie is a part of the Response the system returns to the visitor.
  3. Drupal’s legacy cookie functions (user_cookie_save() and user_cookie_delete()) are incompatible with symfony cookie management because of how the Drupal functions prefix the cookie names. Trust me.

The Solution: Cookies as Services

The first problem with using Drupal’s legacy cookie functions is easily solved by avoiding those functions completely. Let’s forget about those functions forever.

The next consideration is that cookie data is global data. We want to create a reliable API for this data, and that API should be a real citizen of the system. It should be accessible by other parts of the system, and the modification of this data should be easy and reliable. This sounds like a Service to me.

Finally, cookies are a part of the Request and Response flow of the system, so we need to be able to integrate our cookie data into this flow. In the Symfony http foundation, the Request and Response are dispatched as events. And also in Symfony, events are handled as Services. So by making our new Cookie API into a Service we can easily have access to these events.

My Simple Cookie Service

Getting started, we need to know how to do a few things:

  1. Register a new service and subscribe to events
  2. Get cookie values from the Request
  3. Set new cookie values as a part of the Response

How to get a cookie value from Symfony Request

If we have an instance of a Symfony Request, retrieving the value of a specific cookie (named my_custom_cookie) works by getting the cookie by name from the Request’s cookie bag:

<?php
/** @var $request \Symfony\Component\HttpFoundation\Request */
$cookie_value = $request->cookies->get('my_custom_cookie');

How to set a new cookie value in a Symfony Response

If we have access to an instance of a Symfony Reponse object, setting a new value for a cookie (named my_custom_cookie) works by adding a new Cookie instance to the Response headers:

<?php

use Symfony\Component\HttpFoundation\Cookie;

$my_new_cookie = new Cookie('my_custom_cookie', 'this is the value for my cookie');

/** @var $response \Symfony\Component\HttpFoundation\Response */
$response->headers->setCookie($my_new_cookie);

Cookie Service Basics

Now that we know how to get and set cookies from Symfony Requests and Responses, let’s lay the foundation for a new service that handles these basics. This service will live in an imaginary Drupal module we’ll call my_cookies.

name: My Cookies
type: module
description: Example cookies as services.
core_version_requirement: ^8.8 || ^9
my_cookies.info.yml
services:
  my_simple_cookie:
    class: \Drupal\my_cookies\MySimpleCookie
    arguments:
      - '@request_stack'
    tags:
      - { name: 'event_subscriber' }
my_cookies.services.yml
<?php

namespace Drupal\my_cookies;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Class MySimpleCookie
 *
 * @package Drupal\my_cookies
 */
class MySimpleCookie implements EventSubscriberInterface {

  /**
   * Current request.
   *
   * @var \Symfony\Component\HttpFoundation\Request|null
   */
  protected $request;

  /**
   * MySimpleCookie constructor.
   *
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   Request stack service.
   */
  public function __construct(RequestStack $request_stack) {
    $this->request = $request_stack->getCurrentRequest();
  }

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

  /**
   * React to the symfony kernel response event by managing visitor cookies.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The event to process.
   */
  public function onResponse(ResponseEvent $event) {
    $response = $event->getResponse();
  }

}
src/MySimpleCookie.php

There we have it. A very simple module info file, a custom class that will be used to manage our cookie, and a services file that registers our new class as a service with events.

Notice:

  • We have injected the @request_stack core service and retreived the current request. Now we have a way to get the current cookie’s value.
  • We have subscribed to the Symfony\Component\HttpKernel\KernelEvents::RESPONSE event. With access to the Response we have a way to set a new cookie value.

We’re almost ready to build this service into something practical, but we have a few more problems to solve.

Remaining Problems

  • We need to make it easy for other services to get and set the cookie’s value.
  • When the onResponse() event occurs, we need a way to know whether the cookie should be updated.
  • We need a way to know whether the cookie should be deleted. And we need method that allows other parts of the system to tell our service to delete the cookie.
  • Once we start accessing and modifying the cookie, it would be nice to centralize the cookie’s name so that we don’t have to continuously type it out.

Let’s improve our service by adding some properties and methods that solve each of these problems.

  1. First, we’ll store the cookie name as a property and create a getter method to standardize access to the cookie name. For this example we don’t need a setter method for the cookie name, because cookies are global data and we don’t want another part of the system to change the name of the cookie this service is managing.
  2. Also we’ll create a property to track the cookie’s new value, a method for getting the cookie’s value, another method for setting the cookie’s value, and a property where we’ll track whether or not the cookie should be updated during our onResponse() event.
  3. During the response event, we need to be able to set our new cookie value.
  4. Also during the response event we need to be able to delete our cookie if desired.

Result: Reliable Cookie Service

After solving our remaining problems, here is the result. This new service is ready to be used by the system to reliably manage a cookie.

<?php

namespace Drupal\my_cookies;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Class MySimpleCookie.
 *
 * @package Drupal\my_cookies
 */
class MySimpleCookie implements EventSubscriberInterface {

  /**
   * Current request.
   *
   * @var \Symfony\Component\HttpFoundation\Request|null
   */
  protected $request;

  /**
   * Name of the cookie this service will manage.
   *
   * @var string
   */
  protected $cookieName = 'my_custom_cookie';

  /**
   * The cookie value that will be set during the respond event.
   *
   * @var mixed
   */
  protected $newCookieValue;

  /**
   * Whether or not the cookie should be updated during the response.
   *
   * @var bool
   */
  protected $shouldUpdateCookie = FALSE;

  /**
   * Whether or not the cookie should be deleted during the response.
   *
   * @var bool
   */
  protected $shouldDeleteCookie = FALSE;

  /**
   * MySimpleCookie constructor.
   *
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   Request stack service.
   */
  public function __construct(RequestStack $request_stack) {
    $this->request = $request_stack->getCurrentRequest();
  }

  /**
   * Get this cookie's name.
   *
   * @return string
   */
  public function getCookieName() {
    return $this->cookieName;
  }

  /**
   * Get the cookie's value.
   *
   * @return mixed
   *   Cookie value.
   */
  public function getCookieValue() {
    // If we're mid-request and setting a new cookie value, return that new
    // value. This allows other parts of the system access to the most recent
    // cookie value.
    if (!empty($this->newCookieValue)) {
      return $this->newCookieValue;
    }

    return $this->request->cookies->get($this->getCookieName());
  }

  /**
   * Set the cookie's new value.
   *
   * @param mixed $value
   */
  public function setCookieValue($value) {
    $this->shouldUpdateCookie = TRUE;
    $this->newCookieValue = $value;
  }

  /**
   * Whether or not the cookie should be updated during the response.
   *
   * @return bool
   */
  public function getShouldUpdateCookie() {
    return $this->shouldUpdateCookie;
  }

  /**
   * Whether or not the cookie should be deleted during the response.
   *
   * @return bool
   */
  public function getShouldDeleteCookie() {
    return $this->shouldDeleteCookie;
  }

  /**
   * Set whether or not the cookie should be deleted during the response.
   *
   * @param bool $delete_cookie
   *   Whether or not to delete the cookie during the response.
   */
  public function setShouldDeleteCookie($delete_cookie = TRUE) {
    $this->shouldDeleteCookie = (bool) $delete_cookie;
  }

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

  /**
   * React to the symfony kernel response event by managing visitor cookies.
   *
   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
   *   The event to process.
   */
  public function onResponse(ResponseEvent $event) {
    $response = $event->getResponse();

    if ($this->getShouldUpdateCookie()) {
      $my_new_cookie = new Cookie($this->getCookieName(), $this->getCookieValue());
      $response->headers->setCookie($my_new_cookie);
    }

    // The "should delete" needs to happen after "should update", or we could
    // find ourselves in a situation where we are unable to delete the cookie
    // because another part of the system is trying to update its value.
    if ($this->getShouldDeleteCookie()) {
      $response->headers->clearCookie($this->getCookieName());
    }
  }

}
src/MySimpleCookie.php

Now let’s look at how another part of the system can make use of our new service.

Example using our new Cookie Service

Let’s assume we have another module (named some_other_module). Let’s also imagine that this module has its own service that needs to access and modify this cookie, and that its goal is to change the cookie’s value to a random number on each Request. Sure, it’s an impractical example, but the purpose is to show another part of the system can modify our cookie value using the my_simple_cookie service.

Since our cookie is a service, we can now inject it into any other service.

services:
  my_other_service:
    class: \Drupal\some_other_module\MyOtherService
    arguments:
      - '@my_simple_cookie'
    tags:
      - { name: 'event_subscriber' }
some_other_module.services.yml
<?php

namespace Drupal\some_other_module;

use Drupal\my_cookies\MySimpleCookie;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Class MyOtherService.
 *
 * @package Drupal\some_other_module
 */
class MyOtherService implements EventSubscriberInterface {

  /**
   * The cookie as a service.
   *
   * @var \Drupal\my_cookies\MySimpleCookie
   */
  protected $mySimpleCookie;

  /**
   * MyOtherService constructor.
   *
   * @param \Drupal\my_cookies\MySimpleCookie $my_simple_cookie
   */
  public function __construct(MySimpleCookie $my_simple_cookie) {
    $this->mySimpleCookie = $my_simple_cookie;
  }

  /**
   * {@inheritDoc}
   */
  public static function getSubscribedEvents() {
    return [
      KernelEvents::REQUEST=> 'onRequest',
    ];
  }

  /**
   * React to the symfony kernel request event.
   *
   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
   *   The event to process.
   */
  public function onRequest(RequestEvent $event) {
    // The request event can fire more than once. Don't do anything if it's
    // not the master request.
    if ($event->isMasterRequest()) {
      $this->mySimpleCookie->setCookieValue(random_int(0, 10000));
    }
  }

}
src/MyOtherService.php

There we have it. We have a working cookie service, and an example for using that cookie service in another part of the system!

Conclusion

With cookies as services we have made our custom global data into a real citizen of the system. Access to its data is available to the rest of Drupal (or Symfony), controlled, and documented within our service. So what’s next?

There are a number of ways we could improve this class to make it more reusable. Most of the logic contained in this example would apply to any cookie you may want to handle, so there isn’t a reason we shouldn’t be able to reuse this class for multiple cookies. Additionally some cookie data may not as simple as a string or integer, what if we want to store complex data within a cookie? In that case we would need to serialize the data before saving it, and ideally unserialize the data when accessed through the service.

For examples of how we could make this class more reusable, see the cookie_services module in my collection of Drupal 8 examples on GitHub. It contains the following examples:

  • Simple Cookie Service that tracks the number of requests a visitor makes to the website.
  • Cookie Service for Complex Data that un/serialized the cookie value as json if needed, and allows for the $cookieName property to be set so that the class is reusable as multiple services.
  • Provides an example of using data from the complex cookie as a custom Cache Context.

As far as I can tell this is a new approach to handling cookies, so I’d love to hear what you think about it. Please leave a comment below if you like/dislike this approach, or have thoughts on additional improvements and considerations. Thanks!

0 Thoughts

Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *