Сессии и 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-hintSessionInterfaceработает как 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');
}
}