Skip to content

refactor!: build the session on symfony/http-foundation - #6659

Open
gharlan wants to merge 9 commits into
6.xfrom
symfony-session
Open

refactor!: build the session on symfony/http-foundation#6659
gharlan wants to merge 9 commits into
6.xfrom
symfony-session

Conversation

@gharlan

@gharlan gharlan commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #2065, and removes session — the last nested block — from the config.yml.

Login::startSession() implied a login for everything that just needs a session in the frontend (a cart, a multistep form, the case in the issue). The session moves into Http\Session, and its mechanics are handed to symfony/http-foundation, which is a direct dependency already:

Session::start();                  // was Login::startSession()
Core::getRequest()->getSession();  // the session itself, as a SessionInterface

The config becomes what it always was

The session.<env>.cookie.* keys are the session.* php ini settings, which is exactly what NativeSessionStorage takes as options. So they become options plus a handler, per environment:

Session::$backendOptions['cookie_domain'] = 'backend.example.org';
Session::$backendHandler = new PdoSessionHandler($pdo);

The handlers shipped with http-foundation (pdo, redis, memcached, mongodb, migrating, …) make the storage exchangeable — something the save_path key could not do, and the reason to take http-foundation's session rather than to build our own.

The data keeps its shape

Both environments get an AttributeBag whose storage key is the namespace used so far (<instanceId> / <instanceId>_backend), and a bag shares its array with $_SESSION by reference (loadSession()$bag->initialize($session[$key])). Verified against a running instance: a symfony session put on top of an existing REDAXO session sees backend_login and the csrf tokens, and writes go both ways. So existing sessions survive an update, and addons reading $_SESSION[<namespace>] directly keep working.

BackendLogin keeps reading the backend bag also in the frontend — that is how a logged in backend user is detected there, and it is why there are two bags instead of one.

REX_SESSID is dropped

It was introduced in 2015 (#323) as the userland workaround from the strict_sessions rfc — the issue quotes that section of the rfc literally, including its $_SESSION['valid_id'] = session_id() snippet. The rfc's actual solution is session.use_strict_mode, which REDAXO enables unconditionally since #5583. What the check additionally did — dropping the login when someone else regenerated the id — was a side effect, and a hostile one for instances sharing a php session.

Session fixation stays covered by strict mode plus the regeneration on login, which is now $session->migrate(true).

Deliberately unchanged

A session is never started implicitly. The accessors keep throwing when none is started, although http-foundation would start one on any bag access — an implicit session in the frontend is what ruins cacheability later.

Verified against the instance

  • an existing session (created by the old code) stays logged in
  • logout, login, and login with "stay logged in" — including dropping the session cookie afterwards, where the login is restored from the stay cookie alone
  • a csrf protected save in the system settings
  • the frontend detects the backend session (BackendLogin::hasSession() is true with the cookie, false without)
  • console commands still work, where there is no request object at all
  • PdoSessionHandler against the instance's MariaDB: table created, session survives requests

While testing, one spot turned up that read the removed config key: the "stay logged in" cookie took its secure and samesite from session.backend.cookie and would have silently fallen back to the defaults. It follows the session cookie parameters now.

Rector

Login::startSession()Session::start(), Login::getCookieParams()Session::getCookieParams(), Request::getSessionNamespace()Session::getNamespace(), all with the rename rule already in use.

Request loses its session methods

Request::session(), setSession(), unsetSession() and clearSession() are gone. After the switch they were delegating to the session object, and what was left of them is the type casting — which exists because $_GET and $_POST deliver everything as a string. The session keeps the types it was given: in every one of the ~20 call sites in core the cast was a no-op ('boolean' on a value written as true, 'int' on one written as an int, 'array[string]' on an array of strings).

Session::start()->get('x');
Session::start()->set('x', $value);
Session::start()->remove('x');
Session::start()->clear();

Their other service is preserved elsewhere: they refused to work on a session that was not started, and http-foundation would silently start one on first access. That check moves into SessionStorage, so it also covers Core::getRequest()->getSession(). Verified: reading the session in the frontend without starting it throws Session not started, call Session::start() before. and sends no cookie; after Session::start() the same code works and the cookie is sent. A session that was closed to release its lock stays readable.

There is no rector path for this one — the signature and the receiver both change, so it needs a rule of its own or manual work in addons.

Added after review of the open questions

  • rex.cookie_params is gone. It was added in use config.yml cookie settings for rex_htaccess_check-cookie #5214 so that standard.js could set the rex_htaccess_check cookie with the same parameters. That check was removed in .htaccess-Check entfernt #6391, and nothing has used the property since — neither in core nor in the addons here.
  • cookie_secure defaults to auto, the value symfony uses for this in its own configuration: the flag is set for requests over https, and left alone otherwise. A session cookie without it is sent over an accidental http request too. An instance served over http is unaffected, as is one behind a proxy where php does not see the https request — the failure mode is "no hardening", never a broken login. Instances serving both schemes get separate sessions per scheme from now on.
  • The lock handling has wrappers now. session_write_close() and session_abort() release the session lock while a response is sent (Response::sendFile(), Response::sendContent() before RESPONSE_SHUTDOWN, MediaManager). Called directly they leave the session object claiming to be started, so a later Session::start() silently does nothing. Session::close() and Session::abort() keep that state straight — measured: writing in RESPONSE_SHUTDOWN is now a loud "Failed to start the session because headers have already been sent" instead of vanishing (it never worked, a session cannot be restarted after output).

The remaining direct session_* calls stay as they are: session_id(), session_name() and session_status() ask php for a fact, and go through paths (UserSession, BackendLogin::hasSession(), the guards in Request) that deliberately do not want to create or start a session object first.

`Login::startSession()` implied a login for anything that just needs a session
in the frontend — a cart, a multistep form (#2065). The session moves out of the
login into `Http\Session`, and its mechanics are handed to http-foundation,
which is a direct dependency already:

    Session::start();                     // was Login::startSession()
    Core::getRequest()->getSession();      // the session itself

`session.*` leaves the config.yml. The cookie parameters were the php ini
settings all along, which is exactly what `NativeSessionStorage` takes, so they
become options next to a handler per environment:

    Session::$backendOptions['cookie_domain'] = 'backend.example.org';
    Session::$backendHandler = new PdoSessionHandler($pdo);

The handlers shipped with http-foundation (pdo, redis, memcached, …) make the
storage exchangeable, which the save path could not do.

The data keeps its shape: both environments get an attribute bag whose storage
key is the namespace used so far, and a bag shares its array with `$_SESSION` by
reference. Existing sessions survive, and code reading `$_SESSION[<namespace>]`
directly still works. The backend login keeps reading the backend bag in the
frontend, where a logged in backend user is detected this way.

`REX_SESSID` is dropped. It was introduced in 2015 as the userland workaround
from the strict_sessions rfc, which the issue behind it quotes literally, and
the rfc's actual solution — `session.use_strict_mode` — has been enabled
unconditionally since #5583.

A session is still never started implicitly: the accessors keep throwing if it
is not started, even though the bags of http-foundation would start it.
@gharlan gharlan added this to the REDAXO 6.0 milestone Sep 6, 2026
gharlan and others added 8 commits September 6, 2026 13:45
It was added for the .htaccess check in standard.js, which sets its cookie with
the same parameters. That check is gone since #6391, the property has had no
consumer since.
The `cookie_secure` option defaults to `auto` now, the value symfony uses for
this in its own configuration: the flag is set for requests over https and left
alone otherwise, so an instance served over http is unaffected, as is one behind
a proxy where php does not see the https request.

A session cookie without the flag on an https site is sent over an accidental
http request as well, which is what the flag prevents. Instances serving both
http and https get separate sessions per scheme from now on.
`session_write_close()` and `session_abort()` release the session lock while a
long response is sent. Called directly they leave the session object claiming
that it is started, so a later `Session::start()` does nothing and everything
written from then on is silently dropped — where the same code used to fail
loudly, because restarting a session after the response has been sent is not
possible in the first place.

`Session::close()` saves through http-foundation, which keeps that state
straight, and `Session::abort()` drops the session object, since http-foundation
has no abort of its own. What can not work still can not work, but it says so.
`Request::session()`, `setSession()`, `unsetSession()` and `clearSession()` were
delegating to the session object after the previous commits. What is left of
them is the type casting, and that exists because `$_GET` and `$_POST` deliver
everything as a string — the session keeps the types it was given, so in all
call sites in core the cast was a no-op. The session is not request input, and
`Request` is about request input.

    Session::start()->get('x');
    Session::start()->set('x', $value);
    Session::start()->remove('x');
    Session::start()->clear();

Their other service was refusing to work on a session that was not started,
which http-foundation would silently start on the first access — a session
started by a mere read sends a cookie for every visitor and makes the response
uncacheable. That check moves into `SessionStorage`, where it also covers
`Core::getRequest()->getSession()`, and it lets a closed session be read, which
is what happens while a response is being sent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

2 participants