API Resources & Building a REST API
A huge number of Laravel applications aren't just serving HTML pages — they're also (or entirely) serving JSON to a mobile app, a JavaScript frontend like Vue or React, or a third-party integration. Laravel makes building a JSON API pleasant, but the naive approach of just returning an Eloquent model directly has real problems once your app is more than a toy. API Resources are Laravel's answer: a clean, explicit transformation layer between your database columns and the JSON your API actually promises to clients. This page builds a small but complete Articles API from scratch, and covers the HTTP status codes and authentication concepts that go with it.
routes/api.php vs. routes/web.php
Every Laravel project ships with two separate route files, and understanding why matters. You already
met routes/web.php in Routing Basics — those
routes go through the web middleware group, which includes session handling, CSRF
protection, and cookie encryption, because they're built to serve a browser rendering HTML.
routes/api.php is different on purpose: it's stateless (no sessions, no CSRF token
requirement) and every route defined there is automatically prefixed with /api. That's
exactly what you want for routes meant to be called by code — a mobile app, a single-page app's
fetch() calls, or another server — rather than by a browser following links and
submitting forms.
<?php
// routes/api.php
use App\Http\Controllers\ArticleController;
Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/{article}', [ArticleController::class, 'show']);
// Both of the routes above are actually reachable at:
// GET /api/articles
// GET /api/articles/5
The Problem With Returning a Model Directly
Laravel is convenient enough that this actually "works" the very first time you try it:
<?php
public function show(Article $article)
{
return $article;
}
When a controller method returns an Eloquent model (or a collection of models), Laravel automatically
converts it to JSON using the model's toArray() method. That's genuinely useful for a
quick prototype, but it has a real downside in anything beyond one: it exposes every single
column on the table, exactly as stored. If your articles table has an internal
editor_notes column, a views_count field you don't want to commit to as a
public API contract, or timestamp columns you'd rather rename in the output, none of that is under
your control. Worse, if a related model like the article's author is loaded, its own hidden or
sensitive columns (like a hashed password) can leak into the response unless you remembered to mark
them $hidden on that model too. Relying on "whatever the table happens to contain" as
your public API shape is fragile — a database migration that adds a column can silently change your
API's response for every client.
Generating and Writing a Resource
An API Resource is a plain PHP class whose only job is to describe how one model (or a collection of them) should be turned into an array before it becomes JSON. Generate one with Artisan:
php artisan make:resource ArticleResource
This creates app/Http/Resources/ArticleResource.php with a single method to fill in,
toArray($request), which returns the exact array you want serialized to JSON:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class ArticleResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'excerpt' => str($this->body)->limit(120),
'author' => $this->author->name,
'published_at' => $this->created_at->toDateString(),
];
}
}
Notice exactly what's happening here: the resource picks id and title
straight from the model, reshapes body into a short excerpt, reaches through
the author relationship to pull out just the author's name (not the whole
related user record), and formats the timestamp. Every column not explicitly listed — including
anything sensitive or internal — simply never appears in the response. The resource is the single
source of truth for what your API's JSON contract looks like, completely decoupled from what your
database table happens to contain.
Using a Resource in a Controller
Wrap a single model in the resource class to return one item, or call the static
::collection() helper to return many — it handles wrapping every item in the collection
through the same resource automatically:
<?php
namespace App\Http\Controllers;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
class ArticleController extends Controller
{
public function index()
{
$articles = Article::with('author')->latest()->paginate(15);
return ArticleResource::collection($articles);
}
public function show(Article $article)
{
return new ArticleResource($article);
}
}
author with with('author') before the resource ever runs matters — the resource's toArray() above reaches into $this->author->name, and without eager loading that would fire one extra query per article (the classic N+1 problem).HTTP Status Codes
Returning a resource directly from a controller defaults to a 200 OK status, which is
correct for reading data — but a real API needs to communicate more than just "success" or "here's
some JSON." Use response()->json() when you need to set the status explicitly, most
commonly after creating a resource or when something couldn't be found:
<?php
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]);
$article = Article::create($validated);
return response()->json(new ArticleResource($article), 201);
}
public function show($id)
{
$article = Article::find($id);
if (!$article) {
return response()->json(['message' => 'Article not found.'], 404);
}
return new ArticleResource($article);
}
| Status | Meaning | Typical use |
|---|---|---|
| 200 OK | Success | A normal GET request that found data |
| 201 Created | Resource created | A successful POST that made a new record |
| 204 No Content | Success, nothing to return | A successful DELETE |
| 401 Unauthorized | Not authenticated | Missing or invalid API token |
| 403 Forbidden | Authenticated, but not allowed | A Policy denies the action |
| 404 Not Found | No such resource | Requested ID doesn't exist |
| 422 Unprocessable Entity | Validation failed | Laravel's default for failed validate() calls on an API request |
| 500 Internal Server Error | Unhandled server error | An uncaught exception |
A Complete Worked Example
Putting the route, controller, and resource together gives you a small, real JSON API for reading articles:
<?php
// routes/api.php
Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/{article}', [ArticleController::class, 'show']);
// A client calling GET /api/articles gets back:
// {
// "data": [
// { "id": 1, "title": "Hello Laravel", "excerpt": "...", "author": "Sonu", "published_at": "2026-08-01" }
// ]
// }
Note the automatic "data" wrapper key around the collection — JsonResource
adds that by default, which is a widely used convention that leaves room to add sibling keys later
(like pagination metadata) without breaking clients already reading data.
Authenticating an API: a Word on Sanctum
Everything above assumes anyone can call the endpoint, which is fine for public read-only data but not
for anything that mutates state or is user-specific. Laravel Sanctum is the
framework's lightweight, official solution for simple token-based API authentication — a mobile app
or SPA logs in once, receives a personal access token, and then sends it on every subsequent request
in an Authorization: Bearer <token> header. Laravel checks that token against the
personal_access_tokens table and, if it's valid, authenticates the request as that user
— no cookies, no sessions. Protecting a route then becomes as simple as attaching the
auth:sanctum middleware, conceptually similar to the middleware you saw in
Middleware. Sanctum's full setup (installing it, issuing tokens,
configuring SPA authentication) is its own topic — this page is only meant to make sure the concept
and the vocabulary are familiar when you go looking for it.