Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Requests & Responses

Reading input, query strings and headers; returning JSON, redirects, downloads and status codes.

Every controller method does two jobs: read something from the request and return a response. This lesson covers the everyday versions of both: query strings, form and JSON input, route parameters and headers going in; views, JSON, redirects, status codes, headers and downloads going out.

Most examples use this app's Projects & Tasks API, so you can send real requests from the page. Code captioned with a file path is copied from this repository; blocks marked Illustrative — not in this app show patterns it doesn't use.

The request and response round trip

Browser / JS GET /api/demo/ tasks?priority= high Middleware throttle:demo AddQueryCount- Header Controller $request->validate() Task::query()… return Resource Response 200 + JSON X-Query-Count X-RateLimit-* headers added on the way out
Input is read in the controller; middleware can change both the request on the way in and the response on the way out.

Getting the request

Type-hint Illuminate\Http\Request in a controller method and Laravel injects the current request:

app/Http/Controllers/Api/TaskController.php

public function index(Request $request): AnonymousResourceCollection
{
    $filters = $request->validate([
        'priority' => ['nullable', Rule::enum(TaskPriority::class)],
        'status' => ['nullable', Rule::in(['open', 'completed'])],
        'project' => ['nullable', 'string', 'exists:projects,slug'],
        'search' => ['nullable', 'string', 'max:100'],
    ]);
    // ...
}

Outside a controller, the request() helper returns the same object.

Reading input

input() reads from the query string, form body or JSON body; you don't need to care which. The methods you'll use most:

Illustrative — not in this app. Given GET /api/demo/tasks?priority=high&status=open&page=2:

$request->input('priority');            // 'high'
$request->input('missing', 'medium');   // default when absent: 'medium'
$request->query('page');                // query string only: '2'
$request->integer('page');              // cast: 2
$request->boolean('archived');          // '1', 'true', 'on', 'yes' → true
$request->has('status');                // true: key is present
$request->filled('project');            // false: missing or empty
$request->only(['priority', 'status']); // ['priority' => 'high', 'status' => 'open']
$request->except(['page']);
$request->enum('priority', TaskPriority::class); // TaskPriority::High or null

For anything you're going to save or query with, prefer validated input. $request->validate([...]) returns only the keys it checked, and stops with a 422 response if they fail. That's why TaskController::index works with $filters, not raw input. The rules themselves are covered in Forms & Validation.

Change the query string and press Send. Try priority=urgent, status=done or project=nope to see validation reject it:

Route parameters and model binding

Values in the URL path arrive as method arguments. Type-hint a model and Laravel fetches it for you, or returns a 404. The Project model uses its slug in URLs:

routes/api.php

Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');

app/Models/Project.php

public function getRouteKeyName(): string
{
    return 'slug';
}

app/Http/Controllers/Api/ProjectController.php

public function show(Project $project): ProjectResource
{
    $project->load(['owner', 'tasks' => fn ($query) => $query->orderBy('id'), 'tasks.assignee', 'tasks.tags'])
        ->loadCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()]);

    return new ProjectResource($project);
}

Change website-relaunch to a slug that doesn't exist to get the automatic 404:

Headers, JSON detection and the client

Illustrative — not in this app.

$request->header('Accept');       // 'application/json'
$request->bearerToken();          // from "Authorization: Bearer …"
$request->ip();                   // client IP (used by the rate limiter)
$request->expectsJson();          // true for AJAX/JSON clients
$request->is('api/*');            // path matches a pattern
$request->routeIs('api.demo.*');  // named route matches
$request->method();               // 'GET'

This app uses two of them to decide how errors are rendered: anything under /api gets JSON errors even if the client forgot the Accept header.

bootstrap/app.php

->withExceptions(function (Exceptions $exceptions): void {
    // API routes always answer with JSON (422 errors, 404s), even without an Accept header
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request) => $request->is('api/*') || $request->expectsJson()
    );
})->create();

JSON body input

input() reads JSON bodies too, so the same controller code handles forms and API clients. The validation endpoint accepts a JSON task and runs the real StoreTaskRequest rules without saving. Edit the body and send it:

The response is a 422 with one message per field. That's Laravel's standard error format, and Vue's form lessons show how a frontend maps it onto inputs.

Returning responses

Whatever a controller returns is converted to a response:

You return Laravel sends
A string 200, text/html
An array or Eloquent model/collection 200, JSON
view('name', $data) Rendered Blade HTML
An API Resource JSON shaped by toArray()
redirect(...) 302 with a Location header
response(...) Full control: body, status, headers

The CMS returns a view wrapped in response():

app/Http/Controllers/PageController.php

public function show(string $slug): Response
{
    $page = Page::where('slug', $slug)->firstOrFail();

    return response(view('pages.show', compact('page')));
}

compact('page') builds ['page' => $page]. firstOrFail() throws a ModelNotFoundException, which Laravel turns into a 404 page.

JSON responses

response()->json() sets the content type and encodes the data. The validation endpoint returns a small custom payload:

app/Http/Controllers/Api/TaskController.php

public function validateOnly(StoreTaskRequest $request): JsonResponse
{
    return response()->json([
        'valid' => true,
        'data' => $request->validated(),
    ]);
}

For model data, return an API Resource instead. It controls exactly which fields are exposed and adds pagination metadata automatically. See Building APIs.

Illustrative — not in this app. Status codes and shortcuts:

return response()->json($task, 201);          // Created
return response()->noContent();               // 204, empty body
return response()->json(['error' => 'Nope'], 403);

Status codes and abort()

abort() stops the request immediately with an HTTP error. The tutorial controller uses it for unknown tracks and lessons:

app/Http/Controllers/TutorialController.php

public function show(string $track, string $slug): Response
{
    $trackData = $this->tutorials->track($track) ?? abort(404);
    $found = $this->tutorials->find($track, $slug) ?? abort(404);
    // ...
}

The codes you'll return most often:

Code Meaning Typical cause
200 OK Normal response
201 Created After store() in an API
204 No Content After destroy() in an API
302 Found (redirect) After a form post
401 / 403 Unauthenticated / Forbidden Not logged in / policy denied
404 Not Found Missing model or route
422 Unprocessable Validation failed
429 Too Many Requests Rate limit hit

Response headers

Add headers with ->header() or, for every response in a group, in middleware. This app's demo API adds two debugging headers to each response. Look for them in the Live request results above.

app/Http/Middleware/AddQueryCountHeader.php

public function handle(Request $request, Closure $next): Response
{
    $startedAt = microtime(true);
    $queryCount = 0;

    DB::listen(function () use (&$queryCount) {
        $queryCount++;
    });

    $response = $next($request);

    $response->headers->set('X-Query-Count', (string) $queryCount);
    $response->headers->set('X-Response-Time', round((microtime(true) - $startedAt) * 1000, 1).'ms');

    return $response;
}

The demo below builds a request from the form controls, shows the PHP that would read each value, and sends it so you can compare.

Redirects

After a form POST, redirect instead of rendering a view, so refreshing the page doesn't resubmit the form (the Post/Redirect/Get pattern).

Illustrative — not in this app.

return redirect('/tasks');                                   // to a URL
return redirect()->route('tasks.show', $task);               // to a named route
return back();                                               // to the previous page
return back()->with('status', 'Task saved.');                // + flash message
return redirect()->away('https://matt-9.com');               // external

The Sessions, Redirects & Flash Messages lesson covers with() and showing the message on the next page.

Downloads and streamed responses

Illustrative — not in this app.

// Send a file that exists on disk
return response()->download(storage_path('exports/tasks.csv'), 'tasks.csv');

// Show a file inline (PDFs, images)
return response()->file(storage_path('reports/summary.pdf'));

// Generate a CSV row by row without loading everything into memory
return response()->streamDownload(function () {
    $out = fopen('php://output', 'w');
    fputcsv($out, ['id', 'title', 'priority']);
    Task::query()->orderBy('id')->each(function (Task $task) use ($out) {
        fputcsv($out, [$task->id, $task->title, $task->priority->value]);
    });
    fclose($out);
}, 'tasks.csv');

Key takeaways

  • Type-hint Request $request to read input; input() works for query strings, forms and JSON alike.
  • Use typed getters (integer(), boolean(), enum()) for raw values, and validate() for anything you save or query with.
  • Route parameters with a model type-hint give you route model binding and free 404s.
  • Return views for pages, API Resources for JSON, and redirect()/back() after form posts.
  • abort(404) and firstOrFail() stop the request with the right status code.
  • Middleware is the place for headers that apply to every response in a group.