Request
StaticPHP\Core\Models\Request is not an object wrapping the current http request. There
is no such object in this framework - the superglobals are read directly, and everything
derived from the url lives on the router as static
properties.
What this class does is the other direction: it runs the front controller as a child
process to serve a url internally, and it builds the superglobals back up from argv at
the far end so that the child sees a request rather than a bare cli invocation.
<?php
namespace StaticPHP\Core\Models;
class Request{ public static function internal(string $url, ?array $post = null, ?array $query = null, bool $https = false): string; public static function httpErrorInData(string $data): bool; public static function populateFromCli(?array $argv = null): void;}internal()
Section titled “internal()”<?php
$html = Request::internal('site/reports/monthly', ['month' => '2026-07']);
if (Request::httpErrorInData($html)) { Logger::error('monthly report render failed');}Builds a command line, runs it with exec(), and returns the child’s stdout joined with
"\n". The command is:
LC_ALL=lv_LV.utf8 'php' '<PUBLIC_PATH>/index.php' ['--post' '<urlencoded>'] ['--query' '<urlencoded>'] ['--https'] '<url>'Worth knowing before you rely on it:
- The php binary is invoked as plain
php, so it comes fromPATHand is whatever the web server user resolves - not necessarily the SAPI serving the parent request. - The front controller is assumed to be
PUBLIC_PATH . '/index.php'. - Every element is passed through
escapeshellarg(), and theLC_ALL=lv_LV.utf8prefix is prepended afterwards. That locale is hardcoded in the source. $postand$queryare serialised withhttp_build_query().- The child’s exit code is captured into a local and then ignored. A failure is only
visible in the returned body, which is what
httpErrorInData()is for.
httpErrorInData() is a substring test, not a status code check: it returns true if the
body contains 403 Forbidden, 404 Not Found, 500 Internal Server Error or
syntax error, case insensitively. That means a page legitimately containing one of those
strings reads as an error.
populateFromCli()
Section titled “populateFromCli()”<?php
public static function populateFromCli(?array $argv = null): void;Called by the bootstrap, third, before the configuration is loaded. Under any SAPI other
than cli it returns immediately.
It drops the script name from $argv and then walks the rest, recognising --post,
--query and --https; anything else is taken as the url, so a later bare argument
overwrites an earlier one. It then writes:
| Superglobal key | Set to |
|---|---|
$_GET |
the parsed --query data |
$_POST |
the parsed --post data |
$_REQUEST |
$query + $post |
$_SERVER['REQUEST_URI'] |
/ + the url, plus ? and the rebuilt query string |
$_SERVER['QUERY_STRING'] |
the rebuilt query string |
$_SERVER['REQUEST_METHOD'] |
POST when there was post data, GET otherwise |
$_SERVER['SCRIPT_NAME'] |
left as is, or '' |
$_SERVER['REMOTE_ADDR'] |
left as is, or 127.0.0.1 |
$_SERVER['HTTP_HOST'] |
left as is, or localhost |
$_SERVER['SERVER_PORT'] |
left as is, or 443 with --https, or 80 |
$_SERVER['HTTPS'] |
on, only with --https |
$_SERVER['CONTENT_TYPE'] |
application/x-www-form-urlencoded, only when there is post data |
The four entries that fall back with ?? keep any value the environment already provided,
so an existing REMOTE_ADDR is not overwritten.
The ordering constraint matters: the application’s Config.php binds
$config['request_uri'] and $config['script_name'] to $_SERVER entries by
reference. Rewriting the superglobals after the configuration was read would leave the
configuration describing the wrong request - which is exactly why this call sits where it
does in the boot sequence. See bootstrap.
$_SERVER['CONTENT_TYPE'] is set only when there is post data, which is what keeps the
framework’s content type negotiation sensible under cli: a GET has no content type, so the
negotiation falls through to NONE and errors come out as html. That negotiation is a
router concern rather than one of this class - see
the router.
Proxy headers
Section titled “Proxy headers”Behind a reverse proxy the connection this process sees is the proxy’s. HTTPS is unset
when the proxy terminated tls, SERVER_PORT is the internal hop rather than the port the
client dialled, and REMOTE_ADDR is the proxy for every request on the site. Three static
methods on Router are the only places the framework asks what the client actually did:
<?php
public static function forwardedHeader(string $name): ?string;public static function clientIp(): ?string;public static function requestIsSecure(): bool;trust_proxy_headers gates forwardedHeader() outright - off, it always returns null.
clientIp() and requestIsSecure() only consult it for the forwarded value; off, they fall
back to what this process actually sees - the validated REMOTE_ADDR and the HTTPS/
SERVER_PORT tests respectively - rather than returning nothing. trusted_proxy_hops
shapes what clientIp() returns once trust is on, and client_ip is where the bootstrap
keeps the answer - no method here reads it:
| Key | Default | What it does |
|---|---|---|
$config['trust_proxy_headers'] |
false |
Read X-Forwarded-Proto, X-Forwarded-Port and X-Forwarded-For |
$config['trusted_proxy_hops'] |
1 |
How many proxies are in front, counted from the right of X-Forwarded-For |
$config['client_ip'] |
null |
null means “work it out”; anything else is kept as set |
Turn trust_proxy_headers on only when a proxy that rewrites those headers is the sole
route in. They are client-supplied otherwise, and what they feed - base_url and the
session cookie’s Secure flag - ends up in redirects, emails and cached pages. The
resolved address itself has one consumer in the framework: the
audit trail records it as the address the change came
from.
forwardedHeader()
Section titled “forwardedHeader()”Returns null when trust_proxy_headers is off, and otherwise the first comma-separated
entry of $_SERVER[$name], trimmed, or null when that is empty. Chained proxies append
to these lists and the leftmost entry is the original request, which is right for the
headers that describe the request itself - a proxy sets X-Forwarded-Proto outright.
X-Forwarded-For is the exception, and clientIp() reads it from the other end.
clientIp()
Section titled “clientIp()”Starts from REMOTE_ADDR put through the same validateIp() every other candidate goes
through, and returns that when trust_proxy_headers is off - so a value that is malformed,
or that carries a port, comes back normalised or null rather than as it arrived.
Otherwise it splits X-Forwarded-For on ,, drops empty entries, and indexes
max(0, count - trusted_proxy_hops) - counting from the right, one entry per trusted hop.
trusted_proxy_hops below 1 is clamped to 1. An entry that does not parse as an
address falls back to REMOTE_ADDR rather than being passed on; [2001:db8::1]:443 and
192.0.2.1:51234 are unwrapped first, because load balancers differ on whether they append
the source port.
Counting from the right is the whole point. X-Forwarded-For is appended to rather than
overwritten - nginx’s $proxy_add_x_forwarded_for tacks the peer onto whatever the client
sent - so with one proxy in front the rightmost entry is the one it added itself, which is
the peer it saw and cannot be forged. The leftmost is whatever the client typed.
With REMOTE_ADDR at 10.0.0.5 and X-Forwarded-For: 203.0.113.9, 198.51.100.7, 192.0.2.1:
trust off, XFF present => '10.0.0.5'trust on, hops 1 => '192.0.2.1'trust on, hops 2 => '198.51.100.7'trust on, hops 5 (longer than the chain) => '203.0.113.9'trust on, no XFF at all => '10.0.0.5'trust on, XFF entry with a port => '192.0.2.1'trust on, bracketed ipv6 with a port => '2001:db8::1'trust on, XFF is not an address => '10.0.0.5'no REMOTE_ADDR and no XFF (cli) => NULLThe bootstrap calls this once, straight after Config::load(['Config', 'Routing']) and
before debug is resolved, and only when $config['client_ip'] is null:
<?php
if (Config::get('client_ip', null) === null) { Config::$items['client_ip'] = Router::clientIp();}Config::get() decides with isset(), so a key set to null and a key never set both
count as “work it out”. An application that assigns $config['client_ip'] - including the
1.x = & $_SERVER['REMOTE_ADDR'] binding - keeps its own answer, and on a proxied
deployment that answer is the proxy.
requestIsSecure()
Section titled “requestIsSecure()”The single answer to “did this request arrive over tls”, in this order:
X-Forwarded-ProtothroughforwardedHeader(), so only when the header is trusted.https, case insensitively, is true; any other value it names is false.$_SERVER['HTTPS']set to anything other than the empty string oroff. php documents it as “a non-empty value” rather than asonspecifically - apache and nginx sayon, iis saysofffor a plain request, some setups say1.SERVER_PORT === '443', the last resort for a server that terminates tls without settingHTTPSat all. Only reached when no trusted proxy spoke, so the port is the one the client connected to.
nothing set => falseHTTPS=on => trueHTTPS=off (iis) => falseHTTPS=1 => trueSERVER_PORT=443, HTTPS unset => trueX-Forwarded-Proto=https, trust off => falseX-Forwarded-Proto=https, trust on => trueFour call sites read it, and before it existed they each decided separately: the scheme and
port Router::splitSegments() builds base_url from, the session cookie’s Secure flag in
Sessions, the url on the error page, and the url in
the logged exception. The error page could advertise https:// for a request whose session
cookie had just gone out without Secure.
The port in base_url
Section titled “The port in base_url”splitSegments() only builds $domain_url when $config['base_url'] is empty. When it
does, the port comes from X-Forwarded-Port if that is trusted and all digits. Failing
that, and only when a trusted X-Forwarded-Proto was present, it assumes that scheme’s
default port - 443 or 80 - rather than SERVER_PORT, because X-Forwarded-Proto
without X-Forwarded-Port is the common setup and falling back there advertised the
internal hop: https://example.com:8080, a port nothing outside can reach. With no trusted
proxy header at all it is SERVER_PORT. The port is then appended only when it is neither
80 on http nor 443 on https. See
splitSegments().