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
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 $requestto read input;input()works for query strings, forms and JSON alike. - Use typed getters (
integer(),boolean(),enum()) for raw values, andvalidate()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)andfirstOrFail()stop the request with the right status code.- Middleware is the place for headers that apply to every response in a group.