Requests, Input and Responses

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in the Bookshelf application used across this guide. Illuminate\Http\Request extends Symfony HttpFoundation’s Request, and the responses extend Symfony’s Response: the PHP superglobals and header() calls of HTTP Requests and Responses are wrapped in objects, which also makes them testable. Validation of that input is on Validation and Form Requests.

Getting the request

Type-hint Illuminate\Http\Request in a controller action or route closure; route parameters come after it, in any order. The request() helper returns the same object anywhere else. Information about the request itself:

<?php
// app/Http/Controllers/InputDemoController.php (excerpt)
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class InputDemoController extends Controller
{
    public function about(Request $request): array               // GET /about/{section}, named about.show
    {
        return [
            'path' => $request->path(),
            'is' => $request->is('about/*'),
            'routeIs' => $request->routeIs('about.*'),
            'url' => $request->url(),
            'fullUrl' => $request->fullUrl(),
            'method' => $request->method(),
            'ip' => $request->ip(),
            'bearer' => $request->bearerToken() !== null,
            'wantsJson' => $request->wantsJson(),
            'prefers' => $request->prefers(['text/html', 'application/json']),
            'accepts_csv' => $request->accepts(['text/csv']),
        ];
    }
}
$ curl -s -H 'Accept: application/json' -H 'Authorization: Bearer abc' 'localhost:8000/about/history?x=1'
{"path":"about\/history","is":true,"routeIs":true,"url":"http:\/\/localhost:8000\/about\/history",
 "fullUrl":"http:\/\/localhost:8000\/about\/history?x=1","method":"GET","ip":"127.0.0.1","bearer":true,
 "wantsJson":true,"prefers":"application\/json","accepts_csv":false}
$ curl -s -H 'Accept: text/html,application/json;q=0.9' localhost:8000/about/history
{... "bearer":false,"wantsJson":false,"prefers":"text\/html","accepts_csv":false}

header('X-Name', 'default'), hasHeader(), host(), schemeAndHttpHost(), isMethod('post') and ajax() complete the set. ip() and the scheme are only correct behind a load balancer when trusted proxies are configured (Middleware). wantsJson() and expectsJson() decide whether exceptions and validation errors render as JSON or as a redirect.

Retrieving input

input() reads the query string and the body (form fields or JSON) together, with dot notation for nested values. Typed accessors convert as they read, which removes most (int) casts and isset() checks:

<?php
// app/Http/Controllers/InputDemoController.php (excerpt), GET /find
use App\Enums\Format;                                          // enum Format: string { case Ebook = 'ebook'; ... }
use Illuminate\Http\Request;

function search(Request $request): array
{
    return [
        'q' => $request->string('q')->trim()->lower()->value(),   // Stringable
        'page' => $request->integer('page', 1),                  // "abc" -> 0, missing -> 1
        'in_stock' => $request->boolean('in_stock'),             // "1", "true", "on", "yes"
        'since' => $request->date('since', 'Y-m-d')?->toDateString(),
        'loan' => $request->interval('loan', 'day')?->forHumans(), // Laravel 13
        'format' => $request->enum('format', Format::class)?->name,
        'formats' => array_map(fn (Format $f) => $f->value, $request->enums('formats', Format::class)),
        'filters' => $request->only(['author', 'year']),
        'has_q' => $request->has('q'),
        'filled_q' => $request->filled('q'),
        'missing_year' => $request->missing('year'),
    ];
}
$ curl -s 'localhost:8000/find?q=%20%20Kindred%20&page=abc&in_stock=yes&since=2024-02-30&loan=14&format=ebook&formats[]=ebook&formats[]=vinyl&formats[]=paperback&author=Butler&year='
{"q":"kindred","page":0,"in_stock":true,"since":"2024-03-01","loan":"2 weeks","format":"Ebook",
 "formats":{"0":"ebook","2":"paperback"},"filters":{"author":"Butler","year":null},
 "has_q":true,"filled_q":true,"missing_year":false}
$ curl -s localhost:8000/find
{"q":"","page":1,"in_stock":false,"since":null,"loan":null,"format":null,"formats":[],"filters":[],
 "has_q":false,"filled_q":false,"missing_year":true}

The run shows why the typed accessors are conveniences, not validation:

  • integer('page', 1) returns the default only when the key is missing; page=abc gives 0.

  • date('since', 'Y-m-d') accepted the impossible 2024-02-30 and rolled it over to 1 March, PHP’s DateTime::createFromFormat() behaviour. Validate with date_format:Y-m-d first.

  • enum() returns null for an unknown value and enums() drops unknown values but keeps the original keys, so the list ["ebook","vinyl","paperback"] became the JSON object {"0":"ebook","2":"paperback"}. Wrap it in array_values().

  • year= became null (see input normalisation below), so has('year') is true but filled('year') false.

  • interval() is new in Laravel 13 and throws InvalidArgumentException on a malformed duration.

query('page') reads the query string only, post() the body only, all() everything, only()/except() a subset, has(['a', 'b']) and hasAny() test presence, and whenHas('q', fn ($q) => ...) / whenFilled() run a callback only when the value is there. merge() and mergeIfMissing() add values, typically in prepareForValidation().

JSON input and normalisation

With Content-Type: application/json, the decoded body is available through the same input() calls, and * wildcards collect array values. Two global middleware, TrimStrings and ConvertEmptyStringsToNull, rewrite every string of the input, JSON included:

<?php
// app/Http/Controllers/InputDemoController.php (excerpt), POST /inbox
use Illuminate\Http\Request;

function store(Request $request): array
{
    return [
        'all' => $request->all(),
        'json_title' => $request->input('book.title'),
        'tags' => $request->input('book.tags.*'),
        'isJson' => $request->isJson(),
        'except' => $request->except(['book.tags']),
    ];
}
$ curl -s -H 'Content-Type: application/json' -d '{"book":{"title":"  Kindred  ","tags":["sf","classic"],"note":""}}' localhost:8000/inbox
{"all":{"book":{"title":"Kindred","tags":["sf","classic"],"note":null}},"json_title":"Kindred",
 "tags":["sf","classic"],"isJson":true,"except":{"book":{"title":"Kindred","note":null}}}
$ curl -s -d 'book[title]=Dawn&book[tags][]=sf' localhost:8000/inbox
{"all":{"book":{"title":"Dawn","tags":["sf"]}},"json_title":"Dawn","tags":["sf"],"isJson":false,
 "except":{"book":{"title":"Dawn"}}}

The demo route uses withoutMiddleware(PreventRequestForgery::class) so that curl can post without a CSRF token; real forms send the token (Sessions, Cookies and Flash Data). Normalisation is configured in bootstrap/app.php with $middleware->trimStrings(except: [...]) and convertEmptyStringsToNull(except: [fn (Request $r) => $r->is('webhooks/*')]). Excluding fields matters for passwords and for signed webhook payloads, whose signature covers the raw body; read the raw body with $request->getContent() in that case.

Old input, cookies, files and PSR-7

back()->withInput() flashes the submitted input to the session, so the form can be refilled after a failed submission with old('title') (in Blade, value="{{ old('title') }}"). Validation does it automatically. Flashed data lives for exactly one more request, as the second call below shows:

$ curl -s -c jar -b jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Referer: http://localhost:8000/r/flashed' -d 'title=Dawn' localhost:8000/r/back
302 http://localhost:8000/r/flashed
$ curl -s -c jar -b jar localhost:8000/r/flashed
{"status":"Saved","old_title":"Dawn"}
$ curl -s -c jar -b jar localhost:8000/r/flashed
{"status":null,"old_title":null}

$request->cookie('name') returns a decrypted cookie value: the web group’s EncryptCookies encrypts every cookie the application sets and ignores (treats as absent) cookies it cannot decrypt, so a value set by JavaScript must be listed in $middleware->encryptCookies(except: [...]). $request->file('cover') returns an UploadedFile (isValid(), extension() from the MIME type, store('covers', 's3')); uploads are covered on File Uploads and Images and File Storage and Images.

Code written against PSR-7 can receive a PSR-7 request instead, once the bridge and an implementation are installed:

$ composer require symfony/psr-http-message-bridge nyholm/psr7
<?php
// routes/catalogue.php
use Illuminate\Support\Facades\Route;
use Psr\Http\Message\ServerRequestInterface;

Route::get('/psr7', function (ServerRequestInterface $request) {
    return ['class' => $request::class, 'query' => $request->getQueryParams(), 'ua' => $request->getHeaderLine('User-Agent')];
});
$ curl -s -A 'curl-demo' 'localhost:8000/psr7?shelf=A3'
{"class":"Nyholm\\Psr7\\ServerRequest","query":{"shelf":"A3"},"ua":"curl-demo"}

Responses: strings, arrays, JSON, headers and cookies

A route returns a string (sent as text/html), an array, an Eloquent model or collection (sent as JSON), a view, or a response object built with response(). The object API sets status, headers and cookies:

<?php
// app/Http/Controllers/ResponseDemoController.php (excerpt)
namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class ResponseDemoController extends Controller
{
    public function headers(): Response                       // GET /r/headers
    {
        return response('Shelved', 201)
            ->header('Content-Type', 'text/plain; charset=UTF-8')
            ->withHeaders(['X-Shelf' => 'A3', 'Cache-Control' => 'no-store'])
            ->cookie('last_shelf', 'A3', 60);                  // minutes; encrypted by EncryptCookies
    }

    public function json(): JsonResponse                      // GET /r/json
    {
        return response()->json(['title' => 'Kindred', 'price' => 12.0], 200, [], JSON_PRESERVE_ZERO_FRACTION);
    }
}
$ curl -s -D - localhost:8000/r/headers
HTTP/1.1 201 Created
Cache-Control: no-store, private
Content-Type: text/plain; charset=UTF-8
X-Shelf: A3
Set-Cookie: last_shelf=eyJ…; expires=Mon, 05 Oct 2026 21:03:59 GMT; Max-Age=3600; path=/; httponly; samesite=lax
Shelved
$ curl -s localhost:8000/r/json
{"title":"Kindred","price":12.0}

Three details from the run: Cache-Control gains private (Symfony adds it unless public is set); the cookie value is encrypted, not A3; and ->cookie('last_shelf', 'A3', minutes: 60) with a named argument fails with a 500 ("Unknown named parameter $minutes"), because the response’s cookie() method forwards func_get_args(). Pass the arguments positionally, or build the cookie with the global cookie() helper, which does accept named arguments, and pass that object. Without JSON_PRESERVE_ZERO_FRACTION, 12.0 is sent as 12. The cache.headers middleware alias sets Cache-Control and ETag on a route group (->middleware('cache.headers:public;max_age=3600;etag')).

Redirects

redirect('/books'), redirect()->route('books.show', $book), to_route('books.show', $book), redirect()->action([BookController::class, 'show'], $book), back() and redirect()->away('https://…') all return a 302 by default (pass 301 for a permanent one). with('status', 'Saved') flashes data and withInput() flashes the input, as in the old-input example above, where the POST answered:

<?php
// app/Http/Controllers/ResponseDemoController.php (excerpt), POST /r/back
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

function back(Request $request): RedirectResponse
{
    return back()->withInput()->with('status', 'Saved');   // flashed for the next request only
}

back() uses the Referer header (falling back to the previous URL stored in the session), which is why the curl call above sent one. A redirect target taken from user input (?next=) is an open redirect: allow only relative paths or a list of hosts.

Views, files and downloads

view('books.show', ['book' => $book]) or response()->view(...) renders Blade (Blade Templates). For files:

Call Sends

response()->file($path)

The file inline (Content-Type guessed from its content), e.g. an image or PDF to display.

response()->download($path, 'name.pdf')

The file as an attachment (Content-Disposition: attachment), with an optional download name.

Storage::download('covers/1.jpg')

The same from a filesystem disk, including S3.

response()->streamDownload(fn () => ..., 'books.csv')

Generated content as an attachment, written as it is produced, never held in memory.

response()->file(public_path('favicon.ico')) in a new application returned Content-Type: application/x-empty: the skeleton’s favicon.ico is a zero-byte placeholder, and the type is detected from the content, not the extension. Files served this way pass through PHP; static and large files are better served by the web server or a CDN (X-Sendfile/X-Accel-Redirect, or a signed S3 URL).

Responsable objects and response macros

A class implementing Illuminate\Contracts\Support\Responsable can be returned from a controller, and Laravel calls its toResponse(). It packages a response format for reuse, here a streamed CSV export:

<?php
// app/Http/Responses/BookCsv.php
namespace App\Http\Responses;

use Illuminate\Contracts\Support\Responsable;
use Symfony\Component\HttpFoundation\StreamedResponse;

/** A Responsable: a controller returns it, Laravel calls toResponse(). */
class BookCsv implements Responsable
{
    public function __construct(private iterable $books) {}

    public function toResponse($request): StreamedResponse
    {
        return response()->streamDownload(function () {
            $out = fopen('php://output', 'w');
            fputcsv($out, ['isbn', 'title'], escape: '');
            foreach ($this->books as $book) {
                fputcsv($out, [$book->isbn, $book->title], escape: '');
            }
        }, 'books.csv', ['Content-Type' => 'text/csv']);
    }
}

// in ResponseDemoController: public function export(): BookCsv
// { return new BookCsv(Book::orderBy('id')->limit(3)->cursor()); }
$ curl -s -D - localhost:8000/r/export
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename=books.csv
isbn,title
978-0-8070-8305-4,Kindred
978-1-5387-3218-7,"Parable of the Sower"
978-0-06-051275-6,"The Dispossessed"

cursor() reads one row at a time, so the export stays flat in memory for any table size; escape: '' avoids the PHP 8.4 deprecation of fputcsv()'s default escape character (Files, Streams and I/O). A response macro adds a method to the response() factory, registered in a service provider’s boot():

<?php
// app/Providers/AppServiceProvider.php, in boot()
use Illuminate\Support\Facades\Response;

Response::macro('caps', fn (string $value) => Response::make(strtoupper($value)));

// routes: Route::get('/r/caps', fn () => response()->caps('shelved'));   ->  SHELVED

Streamed JSON and server-sent events

response()->streamJson() encodes a JSON document while iterating over its lazy parts, and response()->eventStream() turns a generator into a text/event-stream response for the browser’s EventSource (both documented since Laravel 11):

<?php
// app/Http/Controllers/ResponseDemoController.php (excerpt)
use App\Models\Book;
use Illuminate\Http\StreamedEvent;

function streamJson()                                     // GET /r/stream-json
{
    return response()->streamJson(['books' => Book::orderBy('id')->limit(3)->cursor()->map->only('id', 'title')]);
}

function events()                                         // GET /r/events
{
    return response()->eventStream(function () {
        foreach (['queued', 'importing', 'done'] as $step) {
            yield new StreamedEvent(event: 'import', data: ['step' => $step]);
            usleep(200_000);
        }
    });
}
$ curl -sN localhost:8000/r/stream-json
{"books":[{"id":1,"title":"Kindred"},{"id":2,"title":"Parable of the Sower"},{"id":3,"title":"The Dispossessed"}]}
$ curl -sN -D - localhost:8000/r/events
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, private
X-Accel-Buffering: no

event: import
data: {"step":"queued"}

event: import
data: {"step":"importing"}

event: import
data: {"step":"done"}

event: update
data: </stream>

The stream ends with an update event whose data is </stream>, which the @laravel/stream-react/-vue hooks (useEventStream) use to know the stream is complete; a hand-written EventSource client should close on it rather than let the browser reconnect. X-Accel-Buffering: no stops Nginx from buffering the stream. Each open stream holds a PHP worker for its whole duration, so long-lived streams suit FrankenPHP or Octane better than a small PHP-FPM pool; for many clients, broadcasting with Reverb (Broadcasting and Reverb) scales further.

Testing requests and responses

<?php
// tests/Feature/RequestResponseTest.php
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('casts query input to types', function () {
    $this->getJson('/find?q=%20Kindred%20&page=abc&in_stock=yes&format=ebook')
        ->assertJson(['q' => 'kindred', 'page' => 0, 'in_stock' => true, 'format' => 'Ebook']);
});

it('flashes status and old input for exactly one request', function () {
    $this->from('/r/flashed')->post('/r/back', ['title' => 'Dawn'])
        ->assertRedirect('/r/flashed')
        ->assertSessionHas('status', 'Saved')
        ->assertSessionHasInput('title', 'Dawn');
});

it('streams a CSV download from a Responsable', function () {
    $this->seed();

    $response = $this->get('/r/export')->assertDownload('books.csv');

    expect($response->streamedContent())->toStartWith("isbn,title\n");
});
$ php artisan test --filter=RequestResponse
   PASS  Tests\Feature\RequestResponseTest
  ✓ it casts query input to types                                        0.16s
  ✓ it flashes status and old input for exactly one request              0.01s
  ✓ it streams a CSV download from a Responsable                         0.03s

  Tests:    3 passed (7 assertions)
  Duration: 0.26s

from() sets the previous URL that back() uses, and streamedContent() reads a streamed body that getContent() would return as false.

Where older books and tutorials are outdated

  • The Input facade (Input::get('title')) was removed in Laravel 6; use the injected Request or request().

  • Casting by hand ((int) $request->input('page'), $request->input('flag') === 'on') (Stauffer ch. 7) is replaced by string(), boolean(), date() and enum() (Laravel 9), integer() and enums() (11) and interval() (13), though validation still has to come first.

  • Hand-rolled SSE loops with ob_flush() and flush() in response()->stream() are replaced by eventStream(), and large JSON exports by streamJson() (both documented since Laravel 11).

  • A service provider per concern (RouteServiceProvider, EventServiceProvider, a provider for response macros) is no longer the starting point: a new application has only AppServiceProvider, where macros are registered in boot().

  • redirect()->route() everywhere can be shortened to to_route() (Laravel 9).