This page traces the complete path of an HTTP request through the framework — from the moment public/index.php receives it to the moment the response is sent and the process exits. Understanding this flow is the mental model everything else in the framework builds on.
public/index.php
│
├── 1. vendor/autoload.php
├── 2. functions/functions.php
├── 3. config/constants.php
├── 4. Debug::enable() (DEV_MODE only)
├── 5. config/eventListeners.php
├── 6. Dotenv::bootEnv()
├── 7. PHP_SF\System\Kernel boot
│ ├── addControllers()
│ ├── addTranslationFiles()
│ ├── setHeaderTemplateClassName()
│ ├── setFooterTemplateClassName()
│ ├── setApplicationUserClassName()
│ └── addTemplatesDirectory()
│
├── 8. auth::logInUser()
│
├── 9. Router::init()
│ ├── Parse or load routes from cache
│ ├── Match current URL + HTTP method
│ │
│ ├── No match → fall through to Symfony
│ │
│ └── Match found →
│ ├── 10. Build Request object
│ ├── 11. Initialize controller
│ ├── 12. Set route parameters
│ ├── 13. Run middleware
│ │ ├── Middleware returns false/JsonResponse/RedirectResponse
│ │ │ └── send middleware response → exit()
│ │ └── Middleware returns true → continue
│ │
│ ├── 14. Invoke controller method
│ │
│ └── 15. Send response → exit()
│
├── 16. Kernel::addRoutesToSymfony()
└── 17. autoload_runtime.php → Symfony kernel
Steps 1–9 happen on every request. If the framework matches a route at step 9, steps 10–15 execute and the process exits — Symfony never boots. If no match is found, execution falls through to steps 16–17 and Symfony takes over.
Before the framework kernel is instantiated, four files are loaded in strict order and environment variables are parsed:
require_once __DIR__ . '/../vendor/autoload.php'; // Composer autoloader
require_once __DIR__ . '/../functions/functions.php'; // global helper functions
require_once __DIR__ . '/../config/constants.php'; // DEV_MODE, LANGUAGES_LIST, etc.
if ( DEV_MODE )
Debug::enable(); // Symfony error handler
require_once __DIR__ . '/../config/eventListeners.php'; // middleware event listeners
( new Dotenv )->bootEnv( __DIR__ . '/../.env' ); // DATABASE_URL, REDIS_CACHE_URL, etc.
Constants must be available before the framework kernel boots because the kernel uses them internally — DEV_MODE controls cache behaviour, LANGUAGES_LIST drives the translator. Changing this load order will cause undefined constant errors.
The framework kernel wires together the components the router and controller system need:
$kernel = ( new PHP_SF\Kernel )
->addControllers( __DIR__ . '/../App/Http/Controller' )
->addTranslationFiles( __DIR__ . '/../lang' )
->setHeaderTemplateClassName( header::class )
->setFooterTemplateClassName( footer::class )
->setApplicationUserClassName( User::class )
->addTemplatesDirectory( 'templates', 'App\View' );
This does not boot the Symfony kernel or the DI container — it's purely configuration. The Symfony kernel boots later at step 17 only if needed.
During this step the Translator is initialised — it loads all locale files for every language in LANGUAGES_LIST from every registered translation directory. In DEV_MODE it also scans all entities for TranslatablePropertyName attributes and updates locale files with any missing keys.
Before routing begins, the framework attempts to restore the authenticated user from the session:
auth::logInUser();
When called with no arguments it checks session_user_id in the session. If found, it loads the user entity from the database and stores it in auth::$user. If the session is empty or the user doesn't exist, it does nothing — the request continues as unauthenticated.
This must happen before routing because middleware checks authentication state, and middleware runs during routing.
Router::init() is the core of the framework. It handles route parsing, caching, and resolution:
Route parsing — on first request (or every request in DEV_MODE), the router scans all registered controller directories, reads #[Route] attributes via reflection, and builds two indexes:
$routesList // all routes indexed by name
$routesByUrl // all routes indexed by httpMethod → url
Both indexes are cached in Redis when DEV_MODE = false.
URL matching — the router first checks for an exact URL match. If none found, it scans for routes with dynamic segments ({param}) that match the current URL structure. When multiple dynamic routes could match, the one with the fewest dynamic segments wins.
Per-URL caching — once a URL is matched, the result (matched route + extracted parameters) is cached in Redis under a SHA-256 hash of the URL. Subsequent requests to the same URL skip matching entirely.
If no route matches, Router::init() returns without calling exit() and execution falls through to Symfony.
When a route is matched, a Symfony Request object is built from PHP superglobals:
$request = new Request(
query: $_GET,
request: array_merge( $_POST, json_decode( file_get_contents( 'php://input' ), true ) ?? [] ),
cookies: $_COOKIE,
files: $_FILES,
server: $_SERVER
);
The JSON body merge means API endpoints receive POST body and JSON body parameters through the same $this->request->request->get() interface without needing to handle them differently in the controller.
Request headers from apache_request_headers() are added when available.
The matched controller class is instantiated with the request object:
self::$controller = new ( $currentRoute->class )( $request );
The controller constructor receives the Request object and stores it as $this->request. Any controller-level setup (such as loading a repository in AuthController) happens here.
If the matched route has URL parameters, they are extracted, type-cast to match the method signature, and stored:
// URL: /user/42
// Route: user/{id}
// Method: public function user_profile( int $id )
$routeParams = [ 'id' => 42 ] // cast to int
The router validates that every parameter in the URL has a corresponding method parameter and vice versa. A mismatch throws RouteParameterException before the controller method is ever called.
Middleware runs between route matching and controller method invocation. The MiddlewaresExecutor processes the middleware configuration from the route attribute and executes each middleware class:
$me = new MiddlewaresExecutor(
$currentRoute->middleware,
$request,
$kernel,
$controller
);
$mResult = $me->execute();
Each middleware's result() method returns one of three things:
true — allow the request to continuefalse — block the request, return a redirect or 403 JSON depending on URLJsonResponse or RedirectResponse — block and return this specific responseIf any middleware blocks the request, its response is sent immediately and the process exits — the controller method never runs. If all middleware passes, execution continues to step 14.
Event listeners registered in config/eventListeners.php fire during this step, after their associated middleware executes. See Events & Listeners for details.
The matched controller method is called via reflection:
$response = $reflectionMethod->invokeArgs(
$controller,
$methodParametersValues // cast URL params
);
The method receives only URL parameters as arguments — the Request object is available via $this->request on the controller instance, not as a method parameter.
The method must return one of:
PHP_SF\System\Core\Response — rendered view responseSymfony\Component\HttpFoundation\JsonResponse — JSON responsePHP_SF\System\Core\RedirectResponse — server-side redirectThe response object's send() method is called, behaviour differs by type:
Response (view)
Response::send() renders the full page:
header template → show()
└── head, navigation, etc.
view wrapper div
└── $view->show()
└── your template content
footer template → show()
└── scripts, debug info (admin only), page load time
Output buffering is enabled during rendering. When TEMPLATES_CACHE_ENABLED = true, the buffer callback applies additional whitespace collapsing to the final HTML output. After the view renders, fastcgi_finish_request() or litespeed_finish_request() is called if available, releasing the connection to the client before the shutdown function runs.
API routes (URLs starting with /api/) skip header and footer rendering entirely.
JsonResponse
Symfony's standard JsonResponse::send() — sets Content-Type: application/json, serializes the data, sends headers and body.
RedirectResponse
The framework's server-side redirect — does not issue an HTTP 302. Instead it:
$_SERVER['REQUEST_URI'] and $_SERVER['REQUEST_METHOD']history.replaceState() to update the browser URLRouter::init() again to process the target routeThis means a redirect processes the target route in the same PHP process rather than making the browser issue a new HTTP request. See Sessions & Redirects for full details on why this design was chosen.
All three response types call exit() at the end of send(). The shutdown function registered during kernel boot runs after exit, flushing the Redis pipeline (rp()->execute()).
The framework distinguishes between API routes and page routes by checking whether the URL starts with /api/:
str_starts_with( Router::$currentRoute->url, '/api/' )
This affects three things:
| Behaviour | API route | Page route |
|---|---|---|
| Header/footer rendered | No | Yes |
| Middleware block response | JsonResponse 401/403 |
RedirectResponse |
| Unauthenticated redirect | JSON error | Redirect to login |
Output buffering is started just before Response::send() is called:
ob_start( function ( $b ) {
if ( TEMPLATES_CACHE_ENABLED )
return preg_replace( [ '/>\s+</' ], [ '><' ], $b );
return $b;
} );
The buffer callback does a final pass of whitespace stripping between HTML tags on the full rendered output. This is separate from the template cache compilation — it runs on every request regardless of whether template caching is enabled, but only applies the regex when TEMPLATES_CACHE_ENABLED = true.
If an exception is thrown inside send(), the buffer is cleaned with ob_end_clean() before the exception is re-thrown as a ViewException to prevent partial output being sent to the client.
A shutdown function is registered during kernel boot:
register_shutdown_function( function () {
rp()->execute();
} );
This flushes the Redis pipeline after every request, sending all queued pipeline commands to Redis in a single round trip. Commands queued during request handling via rp()->set(), rp()->rpush(), etc. are held in memory until this point.
Because fastcgi_finish_request() is called before exit() in Response::send(), the client connection is released before the shutdown function runs — the pipeline flush happens after the response is already sent, adding zero latency from the client's perspective.
Echoing output before $this->render() — any output before ob_start() is called will be sent directly to the client, bypassing the output buffer. This breaks the whitespace collapsing and can cause headers-already-sent errors. Never echo or var_dump directly in controllers — use dump() which goes through the VarDumper system, or return a JsonResponse for debugging.
Not returning from controller methods — the router expects a return value from the controller method. A method that falls off the end without returning will cause send() to be called on null, throwing a fatal error.
Calling exit() manually in a controller — bypasses Response::send(), which means the output buffer is never flushed, the Redis pipeline never executes, and the client may receive an incomplete response. Always return a response object and let the framework call send().
Mutating $_GET or $_POST directly — the Request object is built from superglobals once at step 10 and does not reflect subsequent changes to $_GET or $_POST. Always use $this->request->query->get() and $this->request->request->get() in controllers.