Mid📖Теория6 min

Сессии и Flash-сообщения

SessionInterface, FlashBag, CSRF-токены, конфигурация сессий, PRG pattern

Сессии и Flash-сообщения

Symfony предоставляет объектно-ориентированный API для работы с сессиями через SessionInterface. Flash-сообщения, CSRF-токены и управление сессиями — важные темы сертификации.

Доступ к сессии

Три способа получения сессии

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Attribute\Route;

class CartController extends AbstractController
{
    // Method 1: Type-hint SessionInterface in action method
    #[Route('/cart', name: 'cart_show')]
    public function show(SessionInterface $session): Response
    {
        $cart = $session->get('cart', []);
        return $this->render('cart/show.html.twig', ['cart' => $cart]);
    }

    // Method 2: From Request object
    #[Route('/cart/add/{id}', name: 'cart_add')]
    public function add(int $id, Request $request): Response
    {
        $session = $request->getSession();
        $cart = $session->get('cart', []);
        $cart[] = $id;
        $session->set('cart', $cart);

        return $this->redirectToRoute('cart_show');
    }
}

// Method 3: Via RequestStack in services (recommended for non-controllers)
class CartService
{
    public function __construct(
        private readonly RequestStack $requestStack,
    ) {
    }

    public function getCart(): array
    {
        $session = $this->requestStack->getSession();
        return $session->get('cart', []);
    }
}

Подвох экзамена: В сервисах нельзя инжектить SessionInterface напрямую в конструктор — сессия привязана к запросу, которого может не быть (CLI). Используйте RequestStack::getSession(). В контроллерах type-hint SessionInterface работает как argument resolver.

Методы SessionInterface

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Session\SessionInterface;

/** @var SessionInterface $session */

// Basic CRUD operations
$session->set('key', 'value');              // Set value
$session->get('key');                        // Get value or null
$session->get('key', 'default');             // Get value or default
$session->has('key');                        // Check if exists
$session->remove('key');                     // Remove and return old value
$session->all();                             // Get all data as array
$session->replace(['key' => 'new_value']);   // Replace all data
$session->clear();                           // Remove ALL data

// Session ID management
$session->getId();                           // Get session ID
$session->setId('custom_id');                // Set session ID (before start!)
$session->getName();                         // Get cookie name (PHPSESSID)
$session->setName('MY_SESSION');             // Set cookie name (before start!)

// Session lifecycle
$session->start();                           // Start session manually
$session->isStarted();                       // Check if started
$session->save();                            // Write data and close

// Session regeneration
$session->invalidate();                      // Destroy session + create new
$session->invalidate(300);                   // Destroy + new with 300s lifetime
$session->migrate();                         // Regenerate ID, keep data
$session->migrate(true);                     // Regenerate ID, destroy old session

invalidate() vs migrate()

Метод Данные Session ID Когда использовать
invalidate() Удаляются Новый Выход из системы
migrate() Сохраняются Новый После входа (session fixation protection)
migrate(true) Сохраняются Новый + старая сессия удалена После входа (рекомендуется)

Подвох экзамена: invalidate() УНИЧТОЖАЕТ все данные сессии и создаёт новую (чистую). migrate() меняет ТОЛЬКО session ID, данные сохраняются. После логина используйте migrate(true) для защиты от session fixation атаки.

Конфигурация сессий

# config/packages/framework.yaml
framework:
    session:
        handler_id: null                        # null = PHP default handler
        cookie_secure: auto                     # auto = secure only on HTTPS
        cookie_samesite: lax                    # CSRF protection
        cookie_lifetime: 0                      # 0 = session cookie (dies with browser)
        cookie_httponly: true                    # No JavaScript access
        gc_maxlifetime: 1440                    # Garbage collection (24 min)
        gc_probability: 1                       # GC probability (1/gc_divisor)
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
        metadata_update_threshold: 120          # Update metadata every 120s

Session Handlers

# File-based (default)
framework:
    session:
        handler_id: null  # Uses php.ini session.save_handler

# Redis
framework:
    session:
        handler_id: 'session.handler.native_file'  # File handler
        # OR
        handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
        save_path: 'redis://localhost:6379'

# Database (PDO)
framework:
    session:
        handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
<?php

declare(strict_types=1);

// Register PDO session handler
// config/services.yaml → services:
//   Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
//     arguments:
//       - '%env(DATABASE_URL)%'

use Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler;

// PdoSessionHandler automatically creates the sessions table
// Table: sessions (sess_id, sess_data, sess_lifetime, sess_time)

Подвох экзамена: cookie_samesite: lax защищает от CSRF-атак из других сайтов, но разрешает GET-запросы при навигации (ссылки). cookie_samesite: strict — полная блокировка, но может ломать UX (куки не отправляются при переходе с другого сайта). cookie_httponly: true предотвращает доступ из JavaScript.

Flash-сообщения

Flash-сообщения — одноразовые данные в сессии, доступные только в СЛЕДУЮЩЕМ запросе. Идеально для Post/Redirect/Get (PRG) pattern.

Создание flash-сообщений

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;

class ProductController extends AbstractController
{
    #[Route('/products', name: 'product_create', methods: ['POST'])]
    public function create(Request $request): Response
    {
        // Process form data...

        // Add flash (available in NEXT request only)
        $this->addFlash('success', 'Product created successfully!');
        $this->addFlash('info', 'Remember to add product images.');

        // Multiple messages of same type
        $this->addFlash('warning', 'Low stock detected');
        $this->addFlash('warning', 'Price below recommended minimum');

        // PRG: Post → Redirect → Get
        return $this->redirectToRoute('product_list', [], Response::HTTP_SEE_OTHER);
    }
}

Отображение в Twig

{# templates/base.html.twig #}

{# Method 1: All flash types #}
{% for type, messages in app.flashes %}
    {% for message in messages %}
        <div class="alert alert-{{ type }}">
            {{ message }}
        </div>
    {% endfor %}
{% endfor %}

{# Method 2: Specific types #}
{% for message in app.flashes('success') %}
    <div class="alert alert-success">{{ message }}</div>
{% endfor %}

{% for message in app.flashes('error') %}
    <div class="alert alert-danger">{{ message }}</div>
{% endfor %}

{# Method 3: Store in variable to avoid double-read issue #}
{% set flashes = app.flashes %}
{% if flashes|length > 0 %}
    {% for type, messages in flashes %}
        {% for message in messages %}
            <div class="alert alert-{{ type }}">{{ message }}</div>
        {% endfor %}
    {% endfor %}
{% endif %}

Подвох экзамена: app.flashes вызывает get() на FlashBag, что УДАЛЯЕТ сообщения после чтения. Если вызвать app.flashes дважды в одном шаблоне — второй вызов вернёт пустой массив. Сохраните в переменную: {% set flashes = app.flashes %}.

FlashBag API

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;

/** @var FlashBagInterface $flashBag */
$flashBag = $session->getFlashBag();

// add() — append a message for type
$flashBag->add('success', 'Created!');

// get() — get and REMOVE messages for type
$messages = $flashBag->get('success');         // Returns array, removes
$messages = $flashBag->get('success', []);     // With default

// peek() — get WITHOUT removing
$messages = $flashBag->peek('success');        // Returns array, keeps
$allPeeked = $flashBag->peekAll();             // All types, keeps

// all() — get all and REMOVE
$all = $flashBag->all();                       // Returns and removes ALL

// Utility
$flashBag->has('error');                       // bool
$flashBag->keys();                             // ['success', 'error', ...]
$flashBag->set('info', ['Message 1', 'Message 2']); // Replace messages for type
$flashBag->setAll(['success' => ['OK'], 'info' => ['Note']]); // Replace all
Метод Возвращает Удаляет Используйте для
get($type) Массив сообщений Да Финальное отображение
peek($type) Массив сообщений Нет Проверка без потребления
all() Все сообщения Да Финальное отображение всех
peekAll() Все сообщения Нет Проверка наличия

CSRF-токены

Cross-Site Request Forgery (CSRF) защита — обязательная тема экзамена.

Генерация и проверка в контроллере

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class DeleteController extends AbstractController
{
    #[Route('/products/{id}/delete', name: 'product_delete', methods: ['POST'])]
    public function delete(int $id, Request $request): Response
    {
        // Validate CSRF token
        $token = $request->request->get('_token');

        if (!$this->isCsrfTokenValid('delete-product-' . $id, $token)) {
            throw $this->createAccessDeniedException('Invalid CSRF token');
        }

        // Proceed with deletion...
        $this->addFlash('success', 'Product deleted');

        return $this->redirectToRoute('product_list');
    }
}

CSRF в Twig-шаблоне

{# Manual CSRF token #}
<form method="post" action="{{ path('product_delete', {id: product.id}) }}">
    <input type="hidden" name="_token" value="{{ csrf_token('delete-product-' ~ product.id) }}">
    <button type="submit">Delete</button>
</form>

{# In Symfony Forms — CSRF is automatic #}
{{ form_start(form) }}
    {# _token field is added automatically #}
    {{ form_rest(form) }}
{{ form_end(form) }}

Программная генерация CSRF-токенов

<?php

declare(strict_types=1);

use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Csrf\CsrfToken;

class PaymentService
{
    public function __construct(
        private readonly CsrfTokenManagerInterface $csrfTokenManager,
    ) {
    }

    public function generateToken(string $intention): string
    {
        return $this->csrfTokenManager->getToken($intention)->getValue();
    }

    public function validateToken(string $intention, string $tokenValue): bool
    {
        $token = new CsrfToken($intention, $tokenValue);
        return $this->csrfTokenManager->isTokenValid($token);
    }
}

Подвох экзамена: CSRF-токен привязан к "intention" (идентификатору действия) и сессии пользователя. Один intention = один токен на всю сессию. Токен НЕ меняется при каждом запросе (по умолчанию). Symfony Forms добавляют CSRF-токен автоматически — отдельная настройка не нужна.

PRG Pattern (Post/Redirect/Get)

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ContactController extends AbstractController
{
    #[Route('/contact', name: 'contact', methods: ['GET', 'POST'])]
    public function contact(Request $request): Response
    {
        $form = $this->createForm(ContactType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // Process form...

            $this->addFlash('success', 'Message sent!');

            // POST → Redirect (303 See Other) → GET
            return $this->redirectToRoute('contact', [], Response::HTTP_SEE_OTHER);
        }

        // GET → Show form
        return $this->render('contact/form.html.twig', [
            'form' => $form,
        ]);
    }
}

Symfony 8.0: Рекомендуется использовать Response::HTTP_SEE_OTHER (303) для редиректа после POST вместо 302. Код 303 гарантирует, что браузер отправит GET на новый URL. Код 302 технически не гарантирует смену метода (хотя браузеры обычно делают GET).

Конфигурация сессий для тестов

# config/packages/test/framework.yaml
when@test:
    framework:
        test: true
        session:
            storage_factory_id: session.storage.factory.mock_file
<?php

declare(strict_types=1);

// In functional tests — session works through the test client
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class CartControllerTest extends WebTestCase
{
    public function testAddToCart(): void
    {
        $client = static::createClient();

        // Session is maintained between requests within same client
        $client->request('POST', '/cart/add/42');
        $client->followRedirect();

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('.cart-count', '1');
    }
}

Проверь себя

🧪

Какой параметр `cookie_samesite` рекомендуется для защиты от CSRF?

🧪

Что произойдёт при двойном вызове `app.flashes` в Twig-шаблоне?

🧪

Какой HTTP-код рекомендуется для редиректа после POST в Symfony 8.0?

🧪

Как рекомендуется получать сессию в Symfony-сервисах (не контроллерах)?

🧪

Чем `invalidate()` отличается от `migrate()` в SessionInterface?