symfony/http-foundation
Symfony HttpFoundation provides an object-oriented layer for the HTTP specification in PHP, offering clean abstractions for requests, responses, headers, cookies, files, and sessions that work standalone or with any framework.
Installation — you almost never do this directly.
composer require symfony/http-foundation
Laravel already ships this component as a core dependency. Check what you actually have before writing code against a specific API:
composer show symfony/http-foundation
php artisan about
Version note — this is a multi-branch package. The newest line is
v8.1.x(latest patchv8.1.8, requires PHP 8.4+), but Symfony maintains several branches in parallel and patches and security fixes are released per branch. The current security release landed asv5.4.54(plus the corresponding6.4.x/7.x/8.1.xpatches). Laravel's constraint decides which branch you are on — a Laravel 10-era app runs 6.x, a Laravel 8-era app runs 5.4, and each has its own patched version number. Runcomposer show symfony/http-foundationfirst, then read the changelog for your branch (https://github.com/symfony/http-foundation/compare/<your-current>...<your-target>). Treat Laravel's constraint as authoritative — don'tcomposer requirea newer HttpFoundation than your Laravel release allows, and don't assume an8.1.xfix is only for8.1.x.
First use case — reach for the Symfony layer only when Laravel's helpers don't cover the case (raw header access, trusted-proxy/IP handling, binary/streamed responses, custom UploadedFile logic). In a controller:
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
public function show(Request $request): JsonResponse
{
$method = $request->getMethod(); // 'GET', 'POST', ...
$query = $request->query->all(); // ParameterBag from HttpFoundation
$ip = $request->getClientIp(); // proxy-aware via TrustProxies
return new JsonResponse([
'method' => $method,
'query' => $query,
'ip' => $ip,
], 200);
}
Illuminate\Http\Request extends Symfony\Component\HttpFoundation\Request, so query, request, headers, files, attributes, cookies and server bags are all available directly on it. Type-hint Illuminate\Http\Request in app code; type-hint the Symfony class only in framework-agnostic code (packages, jobs, services you want to reuse).
Key classes to explore first
Request — parameter bags, trusted proxies, client IP, path validation.Response — status, headers, cache headers.JsonResponse — API payloads.BinaryFileResponse — downloads, X-Sendfile / X-Accel-Redirect.HeaderBag — case-insensitive header access.HeaderUtils — header splitting and cookie path/domain validation (both touched by the latest security release).UploadedFile — upload validation and move().Session + SessionHandlerInterface — only when you go below Laravel's session driver.SodiumMarshaller — only if you touch cookie signing/encryption outside Laravel's EncryptCookies.Where to look next
Illuminate\Http\Request / Illuminate\Http\Response source — that's the actual seam you're programming against.Illuminate\Foundation\Http\Middleware\TrustProxies and config/trustedproxy.php-style config — most "wrong IP" bugs live here.// Accessing bags directly
$input = $request->request->all(); // POST/PUT/PATCH body
$headers = $request->headers->all(); // all headers, normalized keys
$routeId = $request->attributes->get('id'); // route params live here, not in query
// Scalar access with defaults
$page = $request->query->getInt('page', 1);
$sort = $request->query->getAlpha('sort', 'created_at');
// Client IP (respects TrustProxies)
$ip = $request->getClientIp();
// Plain
return new Response('Content', 200, ['X-Custom-Header' => 'Value']);
// JSON — second arg is status, third headers, fourth is "already encoded?"
return new JsonResponse(['data' => 'value'], 200, [], false);
// Download (PHP-served)
$response = new BinaryFileResponse($path);
$response->setContentDisposition('attachment', 'invoice.pdf');
$response->deleteFileAfterSend(true); // for temp/generated files
return $response;
// Download (nginx offload)
BinaryFileResponse::trustXSendfileTypeHeader();
$response = new BinaryFileResponse($path);
$response->headers->set('X-Sendfile-Type', 'X-Accel-Redirect');
$response->headers->set('X-Accel-Redirect', '/protected/invoices/123.pdf');
return $response;
// Cacheable
$response = new Response($html);
$response->setPublic();
$response->setMaxAge(3600);
$response->setSharedMaxAge(3600); // CDN / reverse proxy
return $response;
Prefer Laravel's response() helper for the 90% case; drop to BinaryFileResponse / StreamedResponse when you need X-Sendfile, deleteFileAfterSend, or a generator-backed body. If you offload downloads to nginx, re-test after every HttpFoundation patch — X-Sendfile-Type is now allow-listed and X-Accel-Redirect paths are now encoded (see Gotchas).
In Laravel you should almost always stay on session() / the Session facade. The HttpFoundation layer matters when you write a custom driver or a package that must be framework-agnostic:
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
// Real
$session = new Session(new NativeSessionStorage($options, $handler));
$session->set('key', 'value');
$session->getFlashBag()->add('success', 'Operation completed!');
// Tests: in-memory storage, no headers sent
$session = new Session(new MockArraySessionStorage());
Custom handler = implement SessionHandlerInterface (or extend AbstractSessionHandler) and bind it via session()->extend() / Session::extend() in a service provider. Note RedisSessionHandler lives in symfony/http-foundation only in recent versions — verify with composer show before importing.
HttpFoundation ships no middleware interface. Middleware is Laravel's (Illuminate\Contracts\Http\Kernel pipeline), it just happens to pass HttpFoundation objects through:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TagResponses
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Processed-By', 'TagResponses');
return $response;
}
}
Register in bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php (10 and below). Never call $kernel->pushMiddleware() on a real HTTP kernel — middleware order matters and it will bite you at boot.
use Symfony\Component\HttpFoundation\Request;
$request = Request::create(
'/api/orders?page=2',
'GET',
[], [], [],
['HTTP_AUTHORIZATION' => 'Bearer token', 'HTTP_ACCEPT' => 'application/json']
);
$request->headers->set('X-API-Version', 'v1');
$response = (new OrderController)->index($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertJson($response->getContent());
For feature tests prefer $this->getJson(...) — it exercises the full kernel including TrustProxies and session middleware, which Request::create() does not.
Mix Laravel and Symfony APIs freely — same object:
$request->merge(['custom' => 'value']); // Laravel
$request->headers->get('accept-language'); // HttpFoundation, case-insensitive
Custom request class — useful for API packages, but in Laravel prefer FormRequest unless you need framework independence.
class ApiRequest extends \Symfony\Component\HttpFoundation\Request
{
public function apiVersion(): string
{
return $this->headers->get('X-API-Version', 'v1');
}
}
Response factories for consistent API envelopes:
final class ApiResponse
{
public static function ok(array $data, int $status = 200): JsonResponse
{
return new JsonResponse(['success' => true, 'data' => $data], $status);
}
public static function error(string $message, int $status = 400): JsonResponse
{
return new JsonResponse(['success' => false, 'error' => $message], $status);
}
}
Uploads — Laravel wraps Symfony's UploadedFile (Illuminate\Http\UploadedFile extends it). Use $request->file('avatar') plus Laravel validation rules; only drop to $file->getError(), $file->getClientMimeType() and move() when you're outside the validator's reach (queues, raw PSR-ish flows).
Trusted proxies — this is where getClientIp() gets correct or incorrect. Configure Laravel's trustProxies(at: ...) / TrustProxies middleware; don't call Request::setTrustedProxies() globally in a service provider, or you'll leak spoofable IPs into rate limiting and audit logs.
Trusted hosts — TrustHosts middleware supports many patterns; they are now compiled into a single regexp (see Gotchas 11). Keep patterns anchored and non-overlapping, and prefer a short explicit list over wildcard sprawl.
CORS — prefer config/cors.php + HandleCors. Manually setting Access-Control-Allow-Origin on a Response bypasses preflight handling and will silently drop OPTIONS in some setups.
Rate limiting — use RateLimiter::for() in AppServiceProvider; the Request::getClientIp() value feeding it depends on #5 above.
Cookie config — config/session.php domain and path are now validated. Keep them free of reserved characters (;, ,, and other separators); prefer '/'` and a bare hostname, no scheme, no trailing colon.
Request::createFromGlobals() in CLI — returns empty/meaningless data under Artisan or queue workers because there are no real globals. Use Request::create() with explicit args, or, better, inject/capture the request data before dispatching to a job.
Session handlers are non-serializable (fix #66251, now in every branch) — don't serialize() a Session, cache it, or put anything holding a handler into a queued job payload; you'll get a serialization error rather than a subtly broken session. Re-resolve the session inside the job instead. This is backported to 5.4.x, so older Laravel apps get it too.
Client IP on IPv4-mapped IPv6 (fix #66078, backported) — forwarded addresses like ::ffff:192.0.2.1 previously kept/dropped the port incorrectly, producing malformed getClientIp() output. If you are on an unpatched patch release and parse IPs manually (rate limits, geo lookups, allow-lists), upgrade — and defensively normalize:
$ip = $request->getClientIp();
$ip = $ip && str_starts_with($ip, '::ffff:') ? substr($ip, 7) : $ip;
Session in tests — new Session() with no storage throws. Use MockArraySessionStorage, or just use Laravel's testing helpers ($this->withSession([...])). Also note MockFileSessionStorage now ignores session ids that aren't usable as filenames — a test that hard-codes a weird id will silently get a fresh session instead of reusing state.
UploadedFile::getError() — UPLOAD_ERR_OK === 0. Any non-zero value is a failure; UPLOAD_ERR_OK is falsy, so if (!$file->getError()) is the bug shape that hides real failures. Always compare to the constant.
Header case — HTTP header names are case-insensitive; HeaderBag normalizes keys to lowercase, so headers->all() will not preserve your original casing. Access via get()/has() with any casing; never index the array with 'Content-Type'.
$request->query->all() is not Laravel's $request->all() — it returns only the query string. attributes holds route params, request holds the body. Reaching for the wrong bag is the most common "why is my value null" bug when mixing the two APIs.
JsonResponse double encoding — passing true as the fourth argument on already-encoded JSON string content is fine, but passing true with a PHP array produces broken output. Default (false) unless you're handing it a pre-encoded blob.
BinaryFileResponse and output buffering — if you've echoed anything earlier (debug dump(), stray whitespace before <?php), the binary response corrupts. Check for BOM/whitespace when downloads fail intermittently.
Version drift across branches — symfony/http-foundation ships patches weekly on each maintained branch, and the same fix gets four different version numbers. v5.4.54 and v8.1.8 are the same set of fixes on different branches. Keep composer update symfony/http-foundation --with-dependencies inside Laravel's allowed range and re-run your IP/cookie/download tests after upgrading. Read the changelog for your branch, not the latest tag.
Many trusted-host patterns (fix #65836 / #65743 / #65775) — patterns are now combined into one regexp to avoid PCRE limits, and path matching uses the DOTALL modifier. If you previously worked around "pcre.backtrack_limit exhausted" by splitting TrustHosts, you can consolidate. If you rely on . not matching newlines in host/path patterns, re-check.
Malformed paths and cookies are now rejected (fixes #65741, #65729, #65725) — invalid request paths and cookie path/domain values containing reserved characters raise InvalidArgumentException instead of being passed through. Legacy or fuzzing clients that used to get a 200 may now get a 400, and a bad config/session.php value becomes a hard failure at boot/first response rather than a silently dropped cookie.
HeaderUtils::split() behaviour (fixes #66491, #66048, #66078-adjacent) — splitting on a whitespace separator now works correctly and the parser no longer backtracks ambiguously. If you wrote manual explode/preg_split workarounds for Accept* or Cache-Control, delete them and use HeaderUtils::split().
SodiumMarshaller no longer unmarshals undecryptable payloads (fix #65684) — with a rotated APP_KEY, stale encrypted cookies and signed payloads are now dropped rather than handed to the unmarshaller. Expect a wave of "everyone got logged out" instead of 500s after a key rotation; that's the intended behaviour, but warn your users.
The v5.4.54 / v8.1.8 pair is flagged [SECURITY]. Treat it as a mandatory upgrade, not an optional patch:
composer show symfony/http-foundation # which branch am I on?
composer update symfony/http-foundation --with-dependencies
composer show symfony/http-foundation # confirm the patched version for my branch
php artisan about && php artisan test
Then grep your codebase for the affected surfaces and re-test each:
grep -rn "X-Sendfile-Type\|X-Accel-Redirect" app/ config/
grep -rn "trustXSendfileTypeHeader" app/
grep -rn "domain\s*=>\|'path'\s*=>" config/session.php
grep -rn "HeaderUtils::split\|SodiumMarshaller" app/
X-Sendfile-Type is allow-listed (fix #65683) — only the standard values are honoured. A custom type you set will be ignored and the file will be streamed through PHP: silent performance regression, not an error. Verify X-Sendfile/X-Accel-Redirect still appear on the wire with $response->headers->allPreserveCase().X-Accel-Redirect paths are encoded (fix #65743) — internal redirect targets with spaces or UTF-8 characters now arrive percent-encoded. Confirm nginx resolves them (error_page/internal locations) — filenames with spaces are the first thing to test.path/domain validation (fixes #65729, #65725) — cheap to audit, hard to debug in production.dd($request->headers->all()) and dd($request->server->all()) to see exactly what the proxy/CDN sent before blaming your code.$request->getClientIp() and $request->server->get('REMOTE_ADDR') plus X-Forwarded-For side by side.$response->headers->allPreserveCase() when you need to see what actually goes on the wire (useful with X-Sendfile/Content-Disposition/X-Accel-Redirect).SessionHandlerInterface — custom session storage.Response::setCallback() / StreamedResponse — progress or chunked output.BinaryFileResponse::trustXSendfileTypeHeader() — nginx offload (subject to the new allow-list).TrustedProxy configuration — client IP resolution.TrustHosts patterns — host allow-listing; now compiled to a single regexp, so large lists are safe.MimeTypeGuesser / MimeTypeExtensionGuesser — swap when relying on UploadedFile::getMimeType() for security decisions (it uses file content, not the client-supplied type).Marshaller / SodiumMarshaller — swap only if you control both ends; decrypt failures are now swallowed.How can I help you explore Laravel packages today?