Skip to content

6. Data Scopes

A web application serves many users at once, and each user makes numerous requests. Data can therefore be shared at several levels, known as its scope:

  • application scope: the data is shared by all users throughout the application’s lifetime (the list of doctors, for example);
  • request scope: the data exists only for the duration of processing a request (a view’s model, the logged-in user as identified for that request);
  • session scope: the data is specific to a user and must persist from one request to the next (the selected language, a shopping cart);
  • client scope: data stored in the browser itself, in a cookie.

In [PHP], these scopes behave in a particular way because the application is rebuilt with every request (Example 01): nothing survives in memory from one request to the next. Any data that needs to last longer than a single request is therefore stored outside of [PHP]: in the cache, a database, files, or on the client’s side. This is the so-called “shared-nothing” architecture: each request is processed in isolation by a process that shares nothing with those processing other requests. This approach has significant advantages: an error affects only a single request, no data leaks from one user to another, and multiple identical servers can be run behind a load balancer, provided they share the same cache and database.

In [Symfony], each scope has its own container:

Scope
Container
Lifetime
application
the cache (service [cache.app], example 20), or the database
the selected duration, or unlimited
Query
the container’s services (one object per class, during the request), the request attributes (example 12)
processing a request
session
the session ([$request->getSession()], Example 20), maintained by the server and retrieved via a cookie
until the browser is closed by default, and at most for the duration of the [PHP] session (24 minutes of inactivity by default)
client
a cookie ([Cookie::create], example 22), in plain text
The cookie's expiration time

The [Symfony] session is a server-side session, like those for [Java] ([JSESSIONID]) or the standard [PHP], from which it actually borrows the mechanism and the cookie ([PHPSESSID]): its data is stored on the server (one file per session, with the configuration used in our examples), and the browser holds only the identifier—a random number that is impossible to guess. This differs from [Flask], whose entire session is stored in a signed cookie on the client’s side.

6.1. Example 23: Scopes

23-scopes/
├── config/
│   └── packages/
│       ├── framework.yaml
│       └── twig.yaml
├── src/
│   ├── Controller/
│   │   └── ScopesController.php
│   └── Service/
│       ├── CounterService.php
│       ├── SignedCookies.php
│       └── RequestInfo.php
├── templates/
│   └── scopes/
│       └── scopes.html.twig
└── ...

The file [config/packages/framework.yaml] configures the session and locks, as in Example 20.

6.1.1. The objects: src/Service/CounterService.php and RequestInfo.php

<?php
// src/Service/CounterService.php: APPLICATION scope, counters shared by
// all the requests of all the users, stored in the cache (see example 20)

namespace App\Service;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

class CounterService
{
    public function __construct(
        private CacheItemPoolInterface $cache,
        private LockFactory $locks,
    ) {
    }

    // one more number under the [$key] key; the "file" cache does not increase a
    // number atomically: a lock protects the reading and the writing
    public function next(string $key): int
    {
        $lock = $this->locks->createLock("$key-lock");
        $lock->acquire(true);
        try {
            $item = $this->cache->getItem($key);
            $value = ($item->isHit() ? $item->get() : 0) + 1;
            $this->cache->save($item->set($value));
            return $value;
        } finally {
            $lock->release();
        }
    }

    public function increment(): int
    {
        return $this->next('visits');
    }
}
  • lines 12–16: the service receives the application cache and the lock factory (Example 20);
  • lines 20–32: an application-scope counter: a number stored in the cache under a key and incremented by 1 on each call. The “file” cache in our examples does not support atomic increment operations: the value must be read, incremented, and then written, and two concurrent requests could read the same value—resulting in a missed hit. A lock—one per key—therefore protects both read and write operations; the block [finally] releases it in all cases, even after the [return] in line 28;
  • lines 34–37: the visit counter.
<?php
// src/Service/RequestInfo.php: REQUEST scope, the information of a request

namespace App\Service;

use DateTimeImmutable;

class RequestInfo
{
    public readonly int $number;

    public readonly string $start;

    // the container creates ONE object of this class, at the first request for it, and
    // then gives it back to all those who ask for it during the request
    public function __construct(CounterService $counter)
    {
        // one number per OBJECT created, taken from a counter shared by all the requests
        $this->number = $counter->next('instances');
        // the time, with its milliseconds (v)
        $this->start = (new DateTimeImmutable())->format('H:i:s.v');
    }
}
  • lines 10–12: properties declared as [readonly] one by one (rather than the entire class): they can only be assigned once, by the constructor;
  • lines 16–22: request-scope information: a number, derived from a counter shared by all requests (the previous service, under a different key), and the request time, including milliseconds ([v] format). The constructor receives the [CounterService] from the container: a service can request another service, just like an action.

No configuration is required: by default, a service in the container is shared (shared). The container creates the object on the first request, then returns the same one to all subsequent requests. In standard [PHP] mode, the container is rebuilt with each request: a shared service is therefore a request-scoped object. (You can also request a new object with each injection using the [shared: false] option in the [services.yaml] file.) A nuance arises with application servers that keep the application in memory from one request to the next to improve performance, such as the worker mode of [FrankenPHP]: in this case, a service is shared by all requests handled by the process. [Symfony] then resets, between requests, the services that implement the [ResetInterface] interface; it is up to those services to ensure that nothing is retained from one request to the next.

6.1.2. A global variable for the views: config/packages/twig.yaml

1
2
3
4
5
6
7
# config/packages/twig.yaml: the configuration of the TwigBundle
twig:
    default_path: '%kernel.project_dir%/templates'
    # a GLOBAL variable of the views, which is a SERVICE (the @ designates a service):
    # the RequestInfo object of the current request
    globals:
        info: '@App\Service\RequestInfo'
  • Lines 6–7: A global variable in the views (Example 15) can be a service: the name of its class, preceded by [@]. All views thus have access to the variable [info], which is the [RequestInfo] object of the current request. [Twig] has no equivalent to the [@inject] directive of [Blade]: a view does not request anything from the container; it receives what is given to it.

6.1.3. Signed cookies: src/Service/SignedCookies.php

A cookie is a piece of data entrusted to the client, which can read and modify it. [Laravel] encrypts its cookies; [Symfony] does not. For a cookie to be trustworthy, however, it simply needs to be signed: its value is transmitted in plaintext, but it is followed by a signature that only the server knows how to calculate, because it depends on a secret key. A value modified by the client no longer has the correct signature, and the server rejects it.

<?php
// src/Service/SignedCookies.php: SIGNED cookies. The value travels in clear text,
// followed by a signature that only the server knows how to compute: a value modified
// by the client no longer has the right signature, and it is refused

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class SignedCookies
{
    // the key of the signature: the secret of the application (APP_SECRET, .env file)
    public function __construct(#[Autowire('%kernel.secret%')] private string $secret)
    {
    }

    // "5" -> "5.3f1c...": the value, a dot, the signature (HMAC-SHA256 of the value)
    public function sign(string $value): string
    {
        return $value.'.'.hash_hmac('sha256', $value, $this->secret);
    }

    // the value if the signature is the right one, null otherwise
    public function verify(?string $cookie): ?string
    {
        if ($cookie === null || ! str_contains($cookie, '.')) {
            return null;
        }
        // strrpos: the position of the LAST dot
        $value = substr($cookie, 0, strrpos($cookie, '.'));
        // hash_equals compares two strings in a constant time, which does not reveal
        // to an attacker how many characters are right
        return hash_equals($this->sign($value), $cookie) ? $value : null;
    }
}
  • line 13: The signature key is the application’s secret—the variable [APP_SECRET] in the file [.env] (Example 01)—which the container recognizes as the parameter [kernel.secret]. A parameter is not an object: the attribute [#[Autowire]] tells the container the value to provide, where [%name%] refers to a parameter;
  • lines 18–21: The signature is HMAC (Hash-based Message Authentication Code): the SHA-256 hash of the value, calculated using the secret key, written in hexadecimal. The cookie contains the value, a period, and then the signature;
  • Lines 24–34: The verification process recalculates the signature of the received value and compares it to the one in the cookie. [hash_equals] compares two strings in a time that does not depend on the number of identical characters: a standard comparison would stop at the first differing character, and an attacker could, by measuring the response time, guess the signature character by character.

This is the principle behind the JWT token, which the next chapter will use for authentication.

6.1.4. The controller: src/Controller/ScopesController.php

<?php
// src/Controller/ScopesController.php: the four scopes of data

namespace App\Controller;

use App\Service\CounterService;
use App\Service\SignedCookies;
use App\Service\RequestInfo;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ScopesController extends AbstractController
{
    // the services are provided by the container (see example 06)
    #[Route('/', name: 'scopes', methods: ['GET'])]
    public function display(Request $request, CounterService $counter, RequestInfo $info,
        SignedCookies $cookies): Response
    {
        // APPLICATION: the shared counter
        $total = $counter->increment();
        // SESSION: data specific to THIS browser, kept by the SERVER
        // (a file of var/sessions), found again thanks to the session cookie
        $session = $request->getSession();
        $session->set('visits', $session->get('visits', 0) + 1);
        // CLIENT: data kept by the BROWSER in cookies,
        // sent back to the server with each request; one in clear text, the other signed
        $myVisits = (int) $request->cookies->get('myVisits') + 1;
        $mySignedVisits = (int) $cookies->verify($request->cookies->get('mySignedVisits')) + 1;
        $response = $this->render('scopes/scopes.html.twig', [
            'total' => $total, 'instance' => $info->number,
            'sessionVisits' => $session->get('visits'), 'myVisits' => $myVisits,
            'mySignedVisits' => $mySignedVisits,
        ]);
        $response->headers->setCookie(Cookie::create('myVisits', (string) $myVisits));
        $response->headers->setCookie(Cookie::create('mySignedVisits', $cookies->sign((string) $mySignedVisits)));
        return $response;
    }
}
  • lines 19–20: the action receives the request and three services provided by the container;
  • line 23: the application scope;
  • lines 26–27: the session scope. [$request->getSession()] returns the user’s session; [get(key, default)] reads a value, and [set(key, value)] writes it. [Symfony] saves the session at the end of the request;
  • Lines 30–31: the client scope, with two cookies. Their values are strings provided by the browser and may therefore be missing or invalid: the conversion [(int)] returns 0 for [null] or for text that is not a number. The value of the signed cookie is accepted only if its signature is correct;
  • lines 32–39: To set a cookie, you need the response object: [render] constructs it (example 13), then [setCookie] adds the cookies to it. Without an expiration date, a cookie lasts until the browser is closed (this is a “session” cookie, as defined by browsers).

6.1.5. The view: templates/scopes/scopes.html.twig

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Scopes</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {file: 'css/bootstrap.min.css'}) }}">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">The scopes of data</h1>
    {# info: the global variable of config/packages/twig.yaml, a service; during
       the SAME request, it is the same object as the one of the action #}
    <ul>
      <li>application: {{ total }} visit(s) to the site, all browsers combined</li>
      <li>request: object no. {{ instance }} (no. {{ info.number }} in the view),
        request received at {{ info.start }}</li>
      {# app.session: the session, also accessible in the views #}
      <li>session: {{ sessionVisits }} visit(s) from this browser ({{ app.session.get('visits') }} in the view)</li>
      <li>client: {{ myVisits }} visit(s) from this browser (clear-text cookie),
        {{ mySignedVisits }} (signed cookie)</li>
    </ul>
  </div>
</body>
</html>
  • line 16: [info], the global variable: during the same request, this is the same [RequestInfo] object as the one from the action; the view displays its number, which must be the one received from the action;
  • line 19: the session is also accessible in the views via the variable [app.session].

6.1.6. Execution

After a few visits in a browser:

Image

Let’s track the requests using [curl], which acts as a first browser by storing its cookies in the file [a.txt]:

curl -i -c a.txt -b a.txt http://localhost:8000/
HTTP/1.1 200 OK
...
Set-Cookie: myVisits=1; path=/; httponly; samesite=lax
Set-Cookie: mySignedVisits=1.e50058404359cc9be2e65b8d9366aa98b7d7db4ca7ee576cfdffb6d1a1150d37; path=/; httponly; samesite=lax
Set-Cookie: PHPSESSID=32da14307b05da3847ea54b2ae3e6f77; path=/; httponly; samesite=lax
...
      <li>application: 1 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 1 (no. 1 in the view),
        request received at 09:48:29.888</li>
            <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1 visit(s) from this browser (clear-text cookie),
        1 (signed cookie)</li>
...

The response sets three cookies. The first, [myVisits], contains the value in plain text. The second, [mySignedVisits], contains the same value, followed by a period and its signature. The third, [PHPSESSID], is the session ID. The session data is stored in a file on the server, [var/sessions/dev/sess_32da14307b05da3847ea54b2ae3e6f77], whose name matches the session ID:

_sf2_attributes|a:1:{s:6:"visits";i:1;}_sf2_meta|a:3:{s:1:"u";i:1790502509;s:1:"c";i:1790502509;s:1:"l";i:0;}

In the serialization format of [PHP], we can see an array with a single key, [visits], and an integer value of 1 ([i:1]), followed by information from [Symfony] regarding the session (creation and last use dates). A second visit, with the same cookies:

curl -i -c a.txt -b a.txt http://localhost:8000/
...
Set-Cookie: myVisits=2; path=/; httponly; samesite=lax
Set-Cookie: mySignedVisits=2.4c7239f31c7e195c32dc5dcf2c7734ea05e6cb316eeab9a53239ab047db525ff; path=/; httponly; samesite=lax
...
      <li>application: 2 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 2 (no. 2 in the view),
        request received at 09:48:32.813</li>
            <li>session: 2 visit(s) from this browser (2 in the view)</li>
      <li>client: 2 visit(s) from this browser (clear-text cookie),
        2 (signed cookie)</li>
...

The session already exists: the [PHPSESSID] cookie is not sent back. A second “browser,” with its own cookie file, which is initially empty:

curl -c b.txt -b b.txt http://localhost:8000/
1
2
3
4
5
6
      <li>application: 3 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 3 (no. 3 in the view),
        request received at 09:48:32.826</li>
            <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1 visit(s) from this browser (clear-text cookie),
        1 (signed cookie)</li>

The application counter has continued; the session and cookies for the second browser start over at 1. Each request had its own [RequestInfo] object, and in each one, the action and view saw the same one. Finally, a malicious user forges their own cookies: 1,000 visits in [myVisits], 999 in [mySignedVisits] (with a fabricated signature), and a session ID of their own invention:

curl -i -b "myVisits=1000;mySignedVisits=999.abc;PHPSESSID=abc" http://localhost:8000/
1
2
3
4
5
6
7
8
...
Set-Cookie: PHPSESSID=1885ac965cf2dc862c4aecb994300956; path=/; httponly; samesite=lax
...
      <li>application: 4 visit(s) to the site, all browsers combined</li>
      ...
            <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1001 visit(s) from this browser (clear-text cookie),
        1 (signed cookie)</li>

The plaintext cookie was accepted as is: 1,001 visits. The cookie [mySignedVisits], however, did not have the correct signature: it was ignored, as if it were missing. Copying the signature from another value (such as that of [1], next to the value [999]) doesn’t work any better: the signature depends on the value. As for the made-up session ID, it does not correspond to any existing session: [Symfony] rejects it (this is the “strict” mode for [PHP] sessions) and creates a new session with a new ID. The user therefore cannot choose another user’s session.

Let’s remember the rule: data coming from the client is trustworthy only if it is protected by the server (signed, as here or as with the token in the chapter on authentication, or encrypted); data that has nothing to hide, and whose value is verified by the server (the language in Example 22), can remain in plaintext. A signature guarantees that a value has not been altered, but it does not hide it: a secret must never be entrusted to the client, even if it is signed.