Every Laravel route or controller method returns something that gets converted into an HTTP response — understanding the different ways to build that response, and when to reach for each, avoids a lot of trial and error.
The simplest case: returning a string
Route::get('/ping', fn () => 'pong');
Laravel automatically wraps a returned string in a 200 response with a text/html content type.
The response() helper
return response('Not allowed', 403);
response() takes the body as the first argument and the status code as the second — the most common way to return a specific status code with a simple body.
JSON responses
return response()->json(['status' => 'ok', 'id' => $order->id]);
This sets the Content-Type: application/json header and serializes the array automatically. Returning an Eloquent model or collection directly from a route also auto-converts to JSON — response()->json() is only necessary when you need to also set a custom status code or headers.
Setting headers and cookies fluently
return response('Created', 201)
->header('X-Request-Id', $requestId)
->cookie('last_visit', now()->toDateString());
Redirects
return redirect()->route('dashboard');
return redirect()->back()->withErrors(['email' => 'Already taken']);
The underlying Response class
Every one of these shortcuts ultimately produces an Illuminate\Http\Response (or a subclass like JsonResponse or RedirectResponse) — reaching for the class directly is rarely necessary in application code, but it's worth knowing it's there when a package or test needs to inspect exactly what a route returned.
Returning arrays, views, and response objects
A controller does not have to build an Illuminate\Http\Response manually for every request. Returning a view is normal for HTML endpoints:
return view('orders.show', ['order' => $order]);
Laravel turns that view into an HTTP response after rendering it. Arrays and objects that Laravel knows how to serialize may also become JSON responses automatically, but an explicit response()->json() call is clearer when the status code, headers, or serialization contract matters. That distinction becomes useful in APIs because "this happens to serialize" and "this endpoint deliberately returns this JSON contract" are not the same design decision.
Status codes should describe the outcome, not the controller branch
A response body can say "not found" while still carrying a 200 status, and clients, caches, monitoring tools, and tests will treat that as a successful request. Use the status code that matches the HTTP outcome: a created resource normally returns a 201, an empty successful response can use 204, validation failures are handled by Laravel as client errors, and missing route-model bindings become 404 responses automatically.
return response()->json([
'id' => $order->id,
], 201);
Use the specialized response helpers when the transport matters
Files, downloads, streams, redirects, and server-sent data have response-specific behavior that is easy to get wrong by manually setting headers. Laravel's response factory has dedicated methods for those cases; use them instead of treating every response as a string plus headers. The helper is not just shorter syntax — it preserves the semantics the browser or API client expects.
A useful testing habit
Feature tests should assert the response contract rather than only checking that the controller ran. assertStatus(), assertJson(), assertHeader(), and redirect assertions catch the exact class of mistake where the body looks right in a browser but the HTTP response itself is wrong.