The framework's redirect system works differently from a standard HTTP redirect. Instead of issuing a 302 and making the browser send a new request, the framework processes the target route in the same PHP process. This page explains how it works, why it was built this way, and how to use it correctly.
Two reasons, in order of importance.
First — form resubmission is impossible by design.
With a standard HTTP POST → 302 → GET cycle, the browser holds the original POST request in history. If the user refreshes the final page before the redirect completes, or hits the back button and goes forward again, the browser asks "resend form data?" — and if they say yes, the form submits again. Duplicate orders, duplicate actions, duplicate game commands. Every serious web application has to explicitly defend against this.
With the framework's redirect system, by the time the browser renders the page its URL is already the target URL via history.replaceState(), set before any content renders. The browser has no POST request in its history to replay. Refreshing the page issues a clean GET to the target URL. The problem doesn't exist. This simplifies controllers significantly — there is no need to add duplicate submission guards, idempotency checks, or token-based POST protection for the redirect pattern itself.
Second — performance.
A traditional POST → redirect → GET cycle:
Browser POST /auth/login
→ Server returns 302 → /dashboard
→ Browser GET /dashboard
→ Server boots entire application again
→ loads config, connects to Redis, connects to DB, parses routes...
→ renders response
Every redirect means a full application boot on the server and a full round trip to the client. For a game backend where player actions trigger frequent redirects, that overhead compounds quickly.
The framework's approach:
Browser POST /auth/login
→ Server processes login
→ stores redirect data in Redis (5s TTL)
→ emits history.replaceState() to update browser URL
→ calls Router::init() again in the same process
→ renders /dashboard response
→ sends to client
No second HTTP request. No second application boot. The client receives the final rendered page in a single round trip — and it's beautiful.
The session is accessed via the global s() helper:
s(): Symfony\Component\HttpFoundation\Session\Session
This returns the Symfony session singleton. It's available everywhere in the application:
// Store a value
s()->set( 'player_selected_map', $mapId );
// Retrieve a value
$mapId = s()->get( 'player_selected_map' );
// Check existence
s()->has( 'player_selected_map' );
// Remove a value
s()->remove( 'player_selected_map' );
// Clear entire session
s()->clear();
The session is configured in config/packages/framework.yaml with cookie_samesite: lax and cookie_secure: auto. The session handler is the native PHP file-based handler by default — swap it for Redis in production if you're running multiple application servers.
The framework uses one session key internally:
| Key | Purpose |
|---|---|
session_user_id |
Stores authenticated user ID — set by auth::logInUser(), cleared by auth::logOutUser() |
locale |
Current language — set by Lang::setCurrentLocale() |
page_title |
Current page title — set by AbstractController::render() |
All redirect methods come from RedirectTrait, which is used by both AbstractController and Middleware. Controllers call redirect methods via $this->redirectTo(), $this->redirectBack().
protected function redirectTo(
string $linkOrRoute,
array|null $withParams = null,
array|null $get = null,
array|null $post = null,
array|null $errors = null,
array|null $messages = null,
array|null $formData = null
): RedirectResponse
The first parameter accepts either a route name or a URL path. The trait detects which by checking for a / character — if the string contains a slash it's treated as a URL, otherwise as a route name.
// By route name
return $this->redirectTo( 'login_page' );
// By URL path
return $this->redirectTo( '/auth/login' );
// By route name with path parameters
return $this->redirectTo( 'user_profile', withParams: [ 'id' => 42 ] );
// With query parameters
return $this->redirectTo( 'dashboard', get: [ 'tab' => 'inventory' ] );
Redirects to the referring URL from the Referer header. Falls back to / if no referer is available:
return $this->redirectBack();
// With errors
return $this->redirectBack( errors: [ 'Invalid password.' ] );
All redirect methods accept four optional data parameters: $errors, $messages, $get, $post, and $formData. This data is stored in Redis with a 5-minute TTL and retrieved automatically when the target route is processed.
// Redirect with errors
return $this->redirectBack(
errors: [ 'Email and password cannot be empty.' ]
);
// With typed error categories
return $this->redirectBack( errors: [
RedirectResponse::ALERT_DANGER => 'Invalid credentials.',
RedirectResponse::ALERT_WARNING => 'Your account is locked.',
] );
Available alert types: ALERT_PRIMARY, ALERT_SECONDARY, ALERT_SUCCESS, ALERT_DANGER, ALERT_WARNING, ALERT_INFO.
Retrieve errors in the target view:
// Get all errors (returns array)
$errors = getErrors();
// Get errors of a specific type
$dangerErrors = getErrors( RedirectResponse::ALERT_DANGER );
Same API as errors but for success/info notifications:
return $this->redirectTo( 'dashboard', messages: [
RedirectResponse::ALERT_SUCCESS => 'Welcome back!',
] );
// In view
$messages = getMessages();
$successMessages = getMessages( RedirectResponse::ALERT_SUCCESS );
When a form fails validation and you redirect back, the user expects their input to still be populated. The framework handles this automatically — the current request's POST data is always merged into $formData by RedirectTrait:
// In RedirectTrait — happens automatically
$formData = array_merge( $formData, $this->request->request->all() );
Retrieve specific form field values in the view using formValue():
// Returns the previously submitted value, or empty string if none
formValue( 'email' ) // e.g. 'user@example.com'
formValue( 'login' ) // e.g. 'john_doe'
The input() and formInput() view helpers call formValue() automatically when no explicit default value is provided — submitted form values are re-populated without any extra work in the view.
Pass additional query or POST parameters to the target route:
return $this->redirectTo( 'search_page',
get: [ 'query' => $searchTerm, 'page' => '1' ]
);
These are merged into $_GET and $_POST before the target route is processed, making them available via $this->request->query->get() and $this->request->request->get() in the target controller.
When you return a RedirectResponse from a controller method, Router::sendRouteMethodResponse() calls $response->send(). Here's what happens inside:
1. Data storage
Before the RedirectResponse is constructed, RedirectTrait::generateData() stores all redirect data in Redis:
ca()->set( ":GET:$hashedUrl:$redirectId", j_encode( $get ), 300 );
ca()->set( ":POST:$hashedUrl:$redirectId", j_encode( $post ), 300 );
ca()->set( ":ERRORS:$hashedUrl:$redirectId", j_encode( $errors ), 300 );
ca()->set( ":MESSAGES:$hashedUrl:$redirectId", j_encode( $messages ), 300 );
ca()->set( ":FORM_DATA:$hashedUrl:$redirectId", j_encode( $formData ), 300 );
The $redirectId is a random float from hrtime(true), making each redirect's data unique even if the same URL is redirected to multiple times concurrently.
2. Data retrieval
Inside RedirectResponse::send(), the data is loaded back from Redis using the target URL and redirect ID:
$get = ca()->get( ":GET:$hashedUrl:$redirectId" );
$post = ca()->get( ":POST:$hashedUrl:$redirectId" );
$errors = ca()->get( ":ERRORS:$hashedUrl:$redirectId" );
$messages = ca()->get( ":MESSAGES:$hashedUrl:$redirectId" );
$formData = ca()->get( ":FORM_DATA:$hashedUrl:$redirectId" );
If any of these keys are missing (expired or never set), send() throws HttpException with status 410 — the page has expired. This is the 5-minutes TTL in action: if the same redirect is somehow triggered again after 5 minutes, the data is gone.
3. Global state mutation
The retrieved data is applied to PHP superglobals and framework globals:
$_GET = $get data
$_POST = $post data
$GLOBALS['errors'] = $errors data
$GLOBALS['messages'] = $messages data
$GLOBALS['form_data'] = $formData
4. URL update
A JavaScript snippet is emitted to update the browser's URL bar without triggering a new request:
?><script>
history.replaceState( {}, '', '<?= $replacedUrl ?>' );
</script><?php
This runs before the page content renders, so by the time the user sees the page their browser URL already shows the target URL. Refreshing the page will issue a GET to the target URL, not replay the original POST.
5. Re-routing
Router::init() is called again with the mutated server state. The target route is matched, middleware runs, the controller method executes, and the response is rendered and sent normally. The entire process happens within the original PHP request.
The auth middleware uses redirectTo() to send unauthenticated users to the login page:
// In auth::result()
if ( self::isAuthenticated() === false ) {
if ( str_starts_with( Router::$currentRoute->url, '/api/' ) )
return new JsonResponse( [ 'error' => 'Unauthorized!' ], 401 );
return $this->redirectTo( 'login_page' );
}
For API routes it returns a JSON 401. For page routes it redirects to the login page. The original URL the user was trying to reach is not preserved in the redirect — if you want to redirect back after login, store the intended URL in the session before redirecting.
auth::logOutUser();
This clears the entire session and sets auth::$user = false. Typically called in a logout controller method followed by a redirect:
#[Route( url: 'auth/logout', httpMethod: 'GET', middleware: auth::class )]
public function logout(): RedirectResponse
{
auth::logOutUser();
return $this->redirectTo( 'login_page' );
}
Because redirect data is stored in Redis with a 5-minute TTL and retrieved once, redirect chains (redirect → redirect → final destination) are supported but have a limitation — each hop must complete within 5 minutes or the data expires.
More importantly, data passed to the first redirect is not automatically forwarded through the chain. If you need errors to appear at the final destination after two redirects, you must pass them at each hop explicitly or store them in the session instead of using redirect data.
For multi-hop scenarios, the session is the more reliable choice:
// Store in session for multi-hop scenarios
s()->set( 'flash_error', 'Something went wrong.' );
return $this->redirectTo( 'intermediate_page' );
// At the final destination, read and clear
$error = s()->get( 'flash_error' );
s()->remove( 'flash_error' );
Returning a RedirectResponse from an API route — API routes should always return JsonResponse. Returning a RedirectResponse from a route under /api/ will emit a history.replaceState() JavaScript snippet into a JSON response, corrupting it. Check str_starts_with( Router::$currentRoute->url, '/api/' ) if you need conditional redirect vs JSON response behaviour.
Relying on redirect data after 5 minutes — redirect data is deleted from Redis
immediately after it's consumed, with a 300-second TTL as a safety net for
orphaned keys. In practice you will never hit this limit — if a redirect is taking
anywhere near 5 minutes to process, the TTL is the least of your problems.
Echoing before RedirectResponse sends — any output before history.replaceState() is emitted will break the page layout since the script tag needs to be the first thing rendered. Avoid echoing in controller methods — always return a response object.
Expecting $_POST to be available after redirect — after a RedirectResponse processes the target route, $_POST contains the data you passed via the $post parameter, not the original form submission. The original POST data is available via formValue() from $formData, not from $_POST directly.
Not passing errors when redirecting back on validation failure — a common pattern is to validate, find errors, and redirect back. If you redirect without passing errors, the user sees a blank form with no indication of what went wrong. Always pass errors explicitly:
if ( isset( $errors ) )
return $this->redirectBack( errors: $errors );