In one sentence

API routes give your JSON endpoints a home, and API Resources decide exactly which fields each model sends to the outside world.

๐ŸชŸAn API Resource is like a shop window. The storeroom (your database row) holds everything, including things customers must never see, but the window shows only what you carefully arrange.

Why it matters

Your JSON is a contract

๐Ÿ”’

Don't leak secrets

Returning a raw model can expose columns you forgot about. A Resource lists fields explicitly.

๐Ÿงฑ

Stable shape

Rename a database column and the JSON can stay the same, so mobile apps don't break.

โšก

Five routes in one line

Route::apiResource registers the full set of CRUD endpoints for a controller.

The big picture

From model to JSON

๐Ÿงญapi.php/api/posts
๐ŸŽฎPostControllerloads models
๐ŸชŸPostResourcepicks fields
๐Ÿ“ฆJSON{"data": ...}
The controller fetches data; the Resource is the final filter before anything leaves your app.
app/
โ”œโ”€โ”€ Http/
โ”‚   โ”œโ”€โ”€ Controllers/Api/
โ”‚   โ”‚   โ””โ”€โ”€ PostController.php   โ† --api controller
โ”‚   โ””โ”€โ”€ Resources/
โ”‚       โ”œโ”€โ”€ PostResource.php     โ† one post
โ”‚       โ””โ”€โ”€ PostCollection.php   โ† optional custom list
routes/
โ””โ”€โ”€ api.php                      โ† created by install:api
Step by step

Build a posts API

We'll expose the blog's Post model as a clean JSON API.

  1. Install the API scaffolding

    New Laravel 11/12 apps don't include routes/api.php by default. One command creates it, installs Sanctum (for tokens, next lesson) and registers the file in bootstrap/app.php.

    
              php artisan install:api
            
    
              ->withRouting(
                  web: __DIR__.'/../routes/web.php',
                  api: __DIR__.'/../routes/api.php',   // added for you
                  commands: __DIR__.'/../routes/console.php',
                  health: '/up',
              )
            

    Every route in api.php automatically starts with /api and is stateless (no sessions or CSRF).

  2. Register all CRUD routes at once

    Create a controller with the --api flag. It skips create and edit, because APIs don't serve HTML forms. Then wire it up with Route::apiResource.

    
              php artisan make:controller Api/PostController --api --model=Post
            
    
              use App\Http\Controllers\Api\PostController;
    
              Route::apiResource('posts', PostController::class);
            
    
              GET|HEAD   api/posts ........... posts.index
              POST       api/posts ........... posts.store
              GET|HEAD   api/posts/{post} .... posts.show
              PUT|PATCH  api/posts/{post} .... posts.update
              DELETE     api/posts/{post} .... posts.destroy
            
  3. Shape the output with an API Resource

    An API Resource is a class that converts one model into an array. Only the keys you return end up in the JSON.

    
              php artisan make:resource PostResource
            
    
              <?php
    
              namespace App\Http\Resources;
    
              use Illuminate\Http\Request;
              use Illuminate\Http\Resources\Json\JsonResource;
    
              class PostResource extends JsonResource
              {
                  public function toArray(Request $request): array
                  {
                      return [
                          'id'         => $this->id,
                          'title'      => $this->title,
                          'excerpt'    => str($this->body)->limit(80),
                          'author'     => $this->whenLoaded('user', fn () => $this->user->name),
                          'comments'   => $this->whenCounted('comments'),
                          'created_at' => $this->created_at->toIso8601String(),
                      ];
                  }
              }
            

    whenLoaded only includes the relation if you eager-loaded it, which helps avoid the N+1 problem.

  4. Return resources with the right status codes

    Use PostResource::collection() for lists and new PostResource() for one item. Set 201 on create and 204 on delete.

    
              public function index()
              {
                  $posts = Post::with('user')->withCount('comments')->latest()->paginate(10);
    
                  return PostResource::collection($posts);
              }
    
              public function store(Request $request)
              {
                  $data = $request->validate([
                      'title' => ['required', 'string', 'max:255'],
                      'body'  => ['required', 'string'],
                  ]);
    
                  $post = $request->user()->posts()->create($data);
    
                  return (new PostResource($post))->response()->setStatusCode(201);
              }
    
              public function show(Post $post)
              {
                  return new PostResource($post->load('user'));   // 404 if not found
              }
    
              public function destroy(Post $post)
              {
                  $post->delete();
                  return response()->noContent();                  // 204
              }
            
    โ„น๏ธ Validation is automatic

    If validate() fails on a JSON request, Laravel returns 422 with the errors. You'll see the exact shape in Versioning & Error Handling.

  5. Read the JSON, including pagination meta

    Every resource response wraps data in a data key. When you pass a paginator, Laravel adds links and meta so clients can page through results.

    
              curl "http://127.0.0.1:8000/api/posts?page=2" -H "Accept: application/json"
            
    
              {
                  "data": [
                      { "id": 11, "title": "Queues 101", "excerpt": "Send emails later...",
                        "author": "Ada", "comments": 4, "created_at": "2026-10-01T08:00:00+00:00" }
                  ],
                  "links": {
                      "first": "http://127.0.0.1:8000/api/posts?page=1",
                      "last": "http://127.0.0.1:8000/api/posts?page=3",
                      "prev": "http://127.0.0.1:8000/api/posts?page=1",
                      "next": "http://127.0.0.1:8000/api/posts?page=3"
                  },
                  "meta": { "current_page": 2, "last_page": 3, "per_page": 10, "total": 27 }
              }
            
  6. Customise the list with a ResourceCollection

    Need extra top-level data on a list? Create a ResourceCollection. It wraps many PostResource items and lets you add your own keys.

    
              php artisan make:resource PostCollection
            
    
              class PostCollection extends ResourceCollection
              {
                  public function toArray(Request $request): array
                  {
                      return [
                          'data'    => $this->collection,          // each item uses PostResource
                          'summary' => ['count_on_page' => $this->collection->count()],
                      ];
                  }
              }
            
    
              return new PostCollection(Post::latest()->paginate(10));
            
Common mistakes

Avoid this, do that

โŒ Avoid

          public function show(User $user)
          {
              return $user;   // every column!
          }
        

Raw models expose whatever is in the table today, and anything you add tomorrow.

โœ… Do

          public function show(User $user)
          {
              return new UserResource($user);
          }
        

The Resource is an allow-list: new columns stay private until you add them on purpose.

โŒ Avoid

          return PostResource::collection(Post::all());
        

Loading every row will get slower and heavier as the blog grows.

โœ… Do

          return PostResource::collection(Post::paginate(10));
        

Paginate lists. You get links and meta for free.

๐Ÿ’ก Pro tip: try requests without curl

Tools like Postman, Insomnia, or the HTTP client built into many editors let you save and replay API requests. Remember to set the Accept: application/json header there too.

๐Ÿ‹๏ธ Try it yourself
  1. Run php artisan install:api in your blog project and confirm routes/api.php exists.
  2. Create Api/CategoryController with --api and register it with Route::apiResource.
  3. Make a CategoryResource that returns only id, name and slug.
  4. Paginate the index at 5 per page and check the meta.total value with curl.
  5. Make store return 201 and destroy return 204. Verify with curl -i.
Recap

Key takeaways

Check yourself

Mini quiz