Sessões e cookies
HTTP não guarda estado entre uma requisição e outra. Para lembrar algo do usuário, como um carrinho, uma mensagem de "salvo com sucesso" ou uma preferência de tema, a aplicação usa a sessão, que fica no servidor, ou cookies, que ficam no navegador. Este capítulo mostra os dois no Yii3, mais as mensagens flash e os cookies assinados.
SessionInterface
A sessão vem do pacote yiisoft/session, que o template já instala e configura. Ele faz duas coisas:
- O
SessionMiddleware, na pilha global de middlewares do template, lê o ID de sessão do cookie da requisição e, no fim, grava o cookie na resposta quando necessário. - O serviço
Yiisoft\Session\SessionInterfacedá acesso aos dados. Por baixo, ele usa a sessão nativa do PHP.
Um contador de visitas, em src/Web/Requisicoes/Visitas/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Requisicoes\Visitas;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Yiisoft\Session\SessionInterface;
final readonly class Action
{
public function __construct(
private ResponseFactoryInterface $responseFactory,
private SessionInterface $session,
) {}
public function __invoke(): ResponseInterface
{
$visitas = (int) $this->session->get('requisicoes.visitas', 0) + 1;
$this->session->set('requisicoes.visitas', $visitas);
$response = $this->responseFactory->createResponse();
$response->getBody()->write("Você abriu esta página $visitas vez(es) nesta sessão.\n");
return $response->withHeader('Content-Type', 'text/plain; charset=utf-8');
}
}Com a rota Route::get('/visitas'), chamando três vezes com o mesmo cookie, e uma vez sem:
curl -b sessao.txt -c sessao.txt http://127.0.0.1:8080/requisicoes/visitas
curl -b sessao.txt -c sessao.txt http://127.0.0.1:8080/requisicoes/visitas
curl -b sessao.txt -c sessao.txt http://127.0.0.1:8080/requisicoes/visitas
curl http://127.0.0.1:8080/requisicoes/visitasVocê abriu esta página 1 vez(es) nesta sessão.
Você abriu esta página 2 vez(es) nesta sessão.
Você abriu esta página 3 vez(es) nesta sessão.
Você abriu esta página 1 vez(es) nesta sessão.Sem o cookie PHPSESSID, cada requisição começa uma sessão nova.
Os métodos principais da SessionInterface:
get($chave, $padrao),set($chave, $valor),has($chave)eremove($chave).pull($chave): lê e remove de uma vez.all()eclear(): todos os valores, ou apagar todos.regenerateId(): troca o ID da sessão mantendo os dados. Chame depois do login, para evitar fixação de sessão. O pacoteyiisoft/userjá faz isso; veja Autenticação.destroy(): apaga a sessão inteira, como num logout.
A sessão é aberta só quando você usa um desses métodos. Uma página que não toca na sessão não cria cookie.
Como a sessão é compartilhada por toda a aplicação, prefixe as chaves (requisicoes.visitas) para não colidir com dados de outras partes.
Configuração
As opções da sessão ficam nos params, na chave yiisoft/session. O pacote define cookie_secure como 0 nos params, para a sessão funcionar em http:// durante o desenvolvimento. Em produção com HTTPS, sobrescreva no seu arquivo de params:
'yiisoft/session' => [
'session' => [
'options' => ['cookie_secure' => 1],
],
],Com essa opção ligada, o SessionMiddleware recusa conexões sem HTTPS: uma requisição para http:// que use a sessão termina numa SessionException com a mensagem "cookie_secure" is on but connection is not secure. Por isso ela fica desligada no desenvolvimento.
Os valores de options são as opções de sessão do PHP sem o prefixo session.. A classe Session já liga por padrão cookie_httponly, use_strict_mode, use_only_cookies e cookie_samesite=Lax. A chave handler aceita um SessionHandlerInterface para guardar as sessões em outro lugar, como banco ou Redis.
Mensagens flash
Uma mensagem flash é um valor guardado na sessão que some depois de lido. O uso clássico: o formulário é enviado por POST, a action salva, guarda "Recado salvo." e redireciona; a página seguinte mostra a mensagem uma vez.
O serviço é o Yiisoft\Session\Flash\FlashInterface. Crie src/Web/Requisicoes/Recado/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Requisicoes\Recado;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Http\Header;
use Yiisoft\Http\Method;
use Yiisoft\Http\Status;
use Yiisoft\Router\UrlGeneratorInterface;
use Yiisoft\Session\Flash\FlashInterface;
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
final readonly class Action
{
public function __construct(
private WebViewRenderer $viewRenderer,
private ResponseFactoryInterface $responseFactory,
private UrlGeneratorInterface $urlGenerator,
private FlashInterface $flash,
) {}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if ($request->getMethod() === Method::POST) {
$this->flash->set('sucesso', 'Recado salvo.');
return $this->responseFactory
->createResponse(Status::SEE_OTHER)
->withHeader(Header::LOCATION, $this->urlGenerator->generate('requisicoes/recado'));
}
return $this->viewRenderer->render(__DIR__ . '/template', [
'mensagem' => $this->flash->get('sucesso'),
]);
}
}E a view, em src/Web/Requisicoes/Recado/template.php:
<?php
declare(strict_types=1);
use Yiisoft\Html\Html;
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var string|null $mensagem
*/
$this->setTitle('Recado');
?>
<h1>Recado</h1>
<?php if ($mensagem !== null): ?>
<p class="aviso"><?= Html::encode($mensagem) ?></p>
<?php endif ?>
<?= Html::form()->post($urlGenerator->generate('requisicoes/recado'))->csrf($csrf)->open() ?>
<?= Html::submitButton('Salvar recado') ?>
<?= Html::form()->close() ?>A rota aceita os dois métodos:
Route::methods([Method::GET, Method::POST], '/recado')
->action(Requisicoes\Recado\Action::class)
->name('recado'),Abra /requisicoes/recado e clique no botão. O POST responde 303 See Other para a mesma URL, e o GET seguinte mostra "Recado salvo.". Recarregando a página, a mensagem não aparece mais, e o navegador não pergunta se deve reenviar o formulário, porque a última requisição foi um GET.
Detalhes do FlashInterface:
set($chave, $valor)substitui;add($chave, $valor)acumula valores num array, útil para várias mensagens do mesmo tipo.get($chave)devolvenullse não houver mensagem. A mensagem é removida na requisição seguinte à leitura.getAll()devolve todas as mensagens, e é o que um layout costuma usar para mostrar avisos em qualquer página.- O terceiro parâmetro de
set(),removeAfterAccess, quandofalse, faz a mensagem sumir depois da próxima requisição mesmo que ninguém a leia.
Cookies
Para cookies próprios, o pacote é o yiisoft/cookies. Um cookie é um objeto imutável Yiisoft\Cookies\Cookie, e o addToResponse() gera o header Set-Cookie na resposta. Para ler, a requisição traz os cookies em getCookieParams(), e o CookieCollection::fromArray() transforma isso em objetos.
Os padrões do construtor do Cookie são os mais seguros: path=/, Secure, HttpOnly e SameSite=Lax. Com Secure, o navegador só devolve o cookie em conexões HTTPS (alguns navegadores abrem exceção para localhost). Para mudar, use withSecure(false), withHttpOnly(false), withSameSite(), withDomain() e withPath(). A validade vem de withMaxAge(new DateInterval('P30D')) ou withExpires(); sem nenhum dos dois, o cookie dura até o navegador fechar. Para apagar, envie o cookie com expire().
Cookies assinados
Um cookie fica no navegador, e o usuário pode alterar o valor. Se a aplicação confia nele para alguma decisão, assine o cookie. O CookieSigner acrescenta ao valor um hash calculado com uma chave secreta; na leitura, validate() confere o hash e devolve o cookie com o valor limpo, ou lança RuntimeException se o valor foi alterado.
A chave secreta fica nos params, em config/common/params/requisicoes.php:
<?php
declare(strict_types=1);
return [
'requisicoes' => [
// Em produção, defina APP_COOKIE_KEY no ambiente com um valor aleatório e longo.
'cookieKey' => $_ENV['APP_COOKIE_KEY'] ?? 'chave-de-desenvolvimento-troque-em-producao',
],
];E o CookieSigner é configurado no container, em config/web/di/requisicoes.php:
<?php
declare(strict_types=1);
use Yiisoft\Cookies\CookieSigner;
/** @var array $params */
return [
CookieSigner::class => [
'__construct()' => [
'key' => $params['requisicoes']['cookieKey'],
],
],
];Uma preferência de tema guardada num cookie assinado, em src/Web/Requisicoes/Tema/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Requisicoes\Tema;
use DateInterval;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RuntimeException;
use Yiisoft\Cookies\Cookie;
use Yiisoft\Cookies\CookieCollection;
use Yiisoft\Cookies\CookieSigner;
use Yiisoft\Input\Http\Attribute\Parameter\Query;
final readonly class Action
{
private const TEMAS = ['claro', 'escuro'];
public function __construct(
private ResponseFactoryInterface $responseFactory,
private CookieSigner $signer,
) {}
public function __invoke(ServerRequestInterface $request, #[Query('definir')] ?string $novoTema = null): ResponseInterface
{
if ($novoTema !== null && in_array($novoTema, self::TEMAS, true)) {
$cookie = (new Cookie('tema', $novoTema))->withMaxAge(new DateInterval('P30D'));
return $this->signer->sign($cookie)->addToResponse($this->texto("Tema definido: $novoTema"));
}
return $this->texto('Tema atual: ' . $this->lerTema($request));
}
private function lerTema(ServerRequestInterface $request): string
{
$cookie = CookieCollection::fromArray($request->getCookieParams())->get('tema');
if ($cookie === null) {
return 'claro (padrão)';
}
try {
$tema = $this->signer->validate($cookie)->getValue();
} catch (RuntimeException) {
return 'claro (cookie inválido)';
}
return in_array($tema, self::TEMAS, true) ? $tema : 'claro (valor desconhecido)';
}
private function texto(string $conteudo): ResponseInterface
{
$response = $this->responseFactory->createResponse();
$response->getBody()->write($conteudo . "\n");
return $response->withHeader('Content-Type', 'text/plain; charset=utf-8');
}
}Testando com a rota Route::get('/tema'):
curl -b tema.txt -c tema.txt "http://127.0.0.1:8080/requisicoes/tema?definir=escuro"
curl -b tema.txt -c tema.txt http://127.0.0.1:8080/requisicoes/tema
curl -b "tema=escuro" http://127.0.0.1:8080/requisicoes/temaTema definido: escuro
Tema atual: escuro
Tema atual: claro (cookie inválido)O header enviado na primeira chamada mostra o formato do valor assinado:
Set-Cookie: tema=b0338b6d6bc762b3e5c8265837378c31258f2d39...c3862b0338b6d6bc762b3e5c8265837378c31escuro; Expires=...; Max-Age=2592000; Path=/; Secure; HttpOnly; SameSite=LaxNa terceira chamada, o cookie foi forjado com o valor escuro sem assinatura, e a action não aceitou. O mesmo acontece se alguém trocar só o final do valor assinado.
Repare que escuro aparece no fim do valor: assinar garante que o valor não foi alterado, mas não o esconde. Para que o usuário não consiga ler o conteúdo, use o CookieEncryptor, do mesmo pacote, com encrypt() e decrypt().
Se a aplicação tem vários cookies assinados ou criptografados, o CookieMiddleware do pacote faz isso de forma automática: você informa os nomes (ou padrões como prefixo*) e se cada um deve ser assinado ou criptografado, e o middleware valida os cookies da requisição e assina os da resposta. Para montar a pilha de middlewares, veja Middleware.
WARNING
A chave do CookieSigner precisa ser secreta e diferente em cada ambiente. Quem souber a chave consegue forjar cookies válidos. O capítulo Criptografia mostra como gerar uma chave aleatória.
Comparando com o Yii2
Yii::$app->sessionvira oSessionInterfaceinjetado;$session['chave']viraget()eset().Yii::$app->session->setFlash()egetFlash()viram oFlashInterface.- No Yii2, todos os cookies eram validados com o
cookieValidationKeydo componenterequest. No Yii3, assinar ou criptografar é uma escolha por cookie, comCookieSigner,CookieEncryptorou oCookieMiddleware. Yii::$app->response->cookies->add()vira$cookie->addToResponse($response).