Skip to main content

๐Ÿ”Œ RESTful API Development

An API is the contract your app offers the outside world โ€” the set of URLs a mobile app, a React frontend, or a partner's server can call to read and change your data. In this lesson you'll build a clean, predictable REST API in Laravel 11, from the first route to a passing test.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the REST architectural style and map HTTP verbs to CRUD operations
  • Scaffold API routing with php artisan install:api and register an apiResource
  • Write an API controller that returns consistent JSON with correct HTTP status codes
  • Add filtering, sorting, and pagination safely with a whitelist
  • Convert exceptions to JSON in bootstrap/app.php and write a feature test for an endpoint

Estimated Time: 45โ€“60 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build and test a paginated, filterable /api/products endpoint.

In This Lesson

Why APIs Matter

An API (Application Programming Interface) is how one program talks to another. When you open a weather app, tap "refresh", and see today's forecast, your phone quietly sent an HTTP request to a server and got back a small bundle of JSON data. The screen you see is just a rendering of that data. The API is the part in the middle โ€” the agreed vocabulary of URLs and rules.

๐Ÿ’ก Analogy: A REST API is like a restaurant menu. The kitchen (your database and logic) is hidden. The menu lists exactly what you can order (the endpoints), how to order it (the HTTP method), and what you'll get back (JSON). You never walk into the kitchen โ€” you just use the menu.

Laravel has first-class support for APIs. One backend can serve a website, an iOS app, an Android app, and third-party integrations all at once, because they all speak the same JSON over HTTP.

graph TD B[Web Browser] -->|HTTP + JSON| API[Laravel API] M[Mobile App] -->|HTTP + JSON| API T[Third-party Service] -->|HTTP + JSON| API API <-->|Eloquent| DB[(Database)]

Understanding REST

REST (Representational State Transfer) is a set of conventions for designing web APIs. It isn't a library you install โ€” it's a style you follow. Two ideas do most of the work:

  • Everything is a resource identified by a URL: /api/products is the collection, /api/products/5 is one product.
  • The HTTP verb says what to do with that resource. The same URL behaves differently depending on the method.

Other REST principles: requests are stateless (each request carries everything the server needs โ€” no server-side session between calls), and the interface is uniform (the same patterns everywhere, so clients can predict behavior).

HTTP MethodCRUDExampleMeaning
GETReadGET /api/productsList products
POSTCreatePOST /api/productsCreate a product
GETReadGET /api/products/5Show product 5
PUT/PATCHUpdatePUT /api/products/5Update product 5
DELETEDeleteDELETE /api/products/5Delete product 5
REST resource URLs and verbs The collection URL slash api slash products responds to GET for listing and POST for creating; the item URL slash api slash products slash five responds to GET, PUT, PATCH and DELETE for a single product. /api/products GET โ†’ list all POST โ†’ create one /api/products/5 GET โ†’ show ยท DELETE โ†’ remove PUT / PATCH โ†’ update Nested resource GET /api/products/5/reviews โ†’ the reviews belonging to product 5
Figure 1 โ€” A resource has two URLs: the collection and the individual item. Nested URLs express "belongs to" relationships.

๐Ÿ“– Key Terms

Endpoint: a single URL + method combination your API responds to.

Resource: a type of thing your API exposes (products, users, orders).

Idempotent: a request you can safely repeat with the same effect โ€” GET, PUT, and DELETE are; POST is not.

API Routing in Laravel 11

In Laravel 11 the routes/api.php file is not created by default โ€” a fresh app is web-only until you opt in. One Artisan command scaffolds it and wires up Sanctum for token auth:

php artisan install:api

This creates routes/api.php, publishes the Sanctum migration, and registers the file in bootstrap/app.php. Every route you add there is automatically prefixed with /api and placed in the api middleware group (which includes rate limiting).

<?php
// routes/api.php
use App\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;

// Reachable at /api/products, /api/products/{id}, etc.
Route::get('/products', [ProductController::class, 'index']);
Route::post('/products', [ProductController::class, 'store']);
Route::get('/products/{product}', [ProductController::class, 'show']);
Route::put('/products/{product}', [ProductController::class, 'update']);
Route::delete('/products/{product}', [ProductController::class, 'destroy']);

The apiResource shortcut

Those five lines are so common that Laravel gives you a one-liner. First generate an API controller (the --api flag omits the create and edit form methods that a JSON API never needs):

# Generate a controller with index/store/show/update/destroy only
php artisan make:controller Api/ProductController --api --model=Product
<?php
// routes/api.php
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\CategoryController;
use Illuminate\Support\Facades\Route;

Route::apiResource('products', ProductController::class);

// Register several at once
Route::apiResources([
    'products'   => ProductController::class,
    'categories' => CategoryController::class,
]);

A single apiResource registers all five RESTful routes:

VerbURIActionRoute Name
GET/api/productsindexproducts.index
POST/api/productsstoreproducts.store
GET/api/products/{product}showproducts.show
PUT/PATCH/api/products/{product}updateproducts.update
DELETE/api/products/{product}destroyproducts.destroy

Route model binding

Notice the {product} parameter and the Product $product argument in the controller. Laravel's route model binding automatically looks up the matching row and injects the model โ€” or returns 404 if it doesn't exist. No manual Product::findOrFail() needed.

<?php
// Laravel resolves {product} to a Product by its primary key
public function show(Product $product)
{
    return response()->json($product);
}

Want the URL to use a slug instead of the numeric ID? Add a colon in the route, or override the key on the model:

<?php
// Per-route: matches on the slug column
Route::get('/products/{product:slug}', [ProductController::class, 'show']);

// Or globally on the model
class Product extends Model
{
    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Building an API Controller

An API controller does three things per action: read validated input, perform the database work, and return JSON with the right status code. Here is a complete resource controller using Form Requests for validation (covered in the previous lesson) and route model binding:

<?php
// app/Http/Controllers/Api/ProductController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class ProductController extends Controller
{
    // GET /api/products
    public function index(): JsonResponse
    {
        return response()->json([
            'data' => Product::all(),
        ]);
    }

    // POST /api/products
    public function store(StoreProductRequest $request): JsonResponse
    {
        $product = Product::create($request->validated());

        return response()->json([
            'message' => 'Product created successfully.',
            'data'    => $product,
        ], Response::HTTP_CREATED); // 201
    }

    // GET /api/products/{product}
    public function show(Product $product): JsonResponse
    {
        return response()->json(['data' => $product]);
    }

    // PUT/PATCH /api/products/{product}
    public function update(UpdateProductRequest $request, Product $product): JsonResponse
    {
        $product->update($request->validated());

        return response()->json([
            'message' => 'Product updated successfully.',
            'data'    => $product,
        ]);
    }

    // DELETE /api/products/{product}
    public function destroy(Product $product): Response
    {
        $product->delete();

        return response()->noContent(); // 204
    }
}

โœ… What makes this clean

  • Thin actions. Validation lives in Form Requests, not the controller.
  • Consistent shape. Every response wraps data in a data key.
  • Right status codes. 201 on create, 204 on delete, 200 otherwise.
  • No manual lookups. Route model binding handles fetch-or-404.

Because store and update type-hint StoreProductRequest/UpdateProductRequest, validation runs before the method body. If it fails, Laravel automatically returns a 422 JSON response with the errors โ€” as long as the request expects JSON, which API clients signal with an Accept: application/json header.

Responses & Status Codes

Laravel makes JSON responses trivial. Any array, model, or collection you pass to response()->json() is serialized automatically:

<?php
return response()->json($data);                 // 200 by default
return response()->json($data, 201);            // custom status
return response()->json($data, 200, [           // custom headers
    'X-API-Version' => '1.0',
]);
return response()->json(Product::all());        // a collection
return response()->noContent();                 // 204, empty body

Pick the right status code

The status code is the first thing a client checks. Returning 200 OK for everything โ€” even errors โ€” is a classic beginner mistake that breaks clients. Use the standard codes:

CodeMeaningWhen to use
200 OKSuccessGET, PUT, PATCH succeeded
201 CreatedResource createdPOST created a new record
204 No ContentSuccess, empty bodyDELETE succeeded
400 Bad RequestMalformed requestThe client sent nonsense
401 UnauthorizedNot authenticatedMissing or invalid token
403 ForbiddenNot authorizedLogged in but not allowed
404 Not FoundMissing resourceNo such record
422 Unprocessable EntityValidation failedInput didn't pass the rules
429 Too Many RequestsRate limitedClient exceeded the throttle
500 Server ErrorSomething brokeUnhandled server-side error

Rather than memorizing numbers, use the readable constants from Symfony's Response class (Laravel re-exports it):

<?php
use Symfony\Component\HttpFoundation\Response;

return response()->json($data,  Response::HTTP_CREATED);       // 201
return response()->json($error, Response::HTTP_NOT_FOUND);     // 404
return response()->json($error, Response::HTTP_UNPROCESSABLE_ENTITY); // 422

โš ๏ธ One consistent envelope

Decide on a response shape early and stick to it across every endpoint. A common convention wraps data under a data key and puts errors under message/errors. The next lesson (API Resources) gives you a dedicated, reusable layer for exactly this so you don't hand-write the shape in every controller.

Filtering, Sorting & Pagination

Real collections are large. Returning 50,000 products in one response is slow and wasteful, so APIs let clients narrow and page through data with query-string parameters like ?category=5&sort=price&page=2.

Pagination

Swap all() for paginate() and Laravel does the rest:

<?php
public function index(): JsonResponse
{
    // 15 per page; Laravel reads ?page=N from the query string
    return response()->json(Product::paginate(15));
}

The JSON now includes navigation metadata alongside the rows:

Response (abbreviated)

{
  "current_page": 1,
  "data": [ /* up to 15 products */ ],
  "per_page": 15,
  "last_page": 5,
  "total": 73,
  "next_page_url": "http://localhost/api/products?page=2",
  "prev_page_url": null
}

Filtering, searching, and sorting โ€” safely

Build the query conditionally from the request. The important detail is the whitelist on the sort field: never pass a raw client value straight into orderBy(), or a malicious client can sort by columns you never intended to expose.

<?php
use Illuminate\Http\Request;

public function index(Request $request): JsonResponse
{
    $query = Product::query();

    // Filter by category
    $query->when($request->filled('category_id'), fn ($q) =>
        $q->where('category_id', $request->integer('category_id'))
    );

    // Price range
    $query->when($request->filled('min_price'), fn ($q) =>
        $q->where('price', '>=', $request->float('min_price'))
    );
    $query->when($request->filled('max_price'), fn ($q) =>
        $q->where('price', '<=', $request->float('max_price'))
    );

    // Search name OR description
    $query->when($request->filled('search'), function ($q) use ($request) {
        $term = $request->string('search');
        $q->where(fn ($sub) => $sub
            ->where('name', 'like', "%{$term}%")
            ->orWhere('description', 'like', "%{$term}%")
        );
    });

    // Sort โ€” whitelist the allowed columns
    $sort      = $request->string('sort_by', 'created_at');
    $direction = $request->string('sort_dir') === 'asc' ? 'asc' : 'desc';
    $allowed   = ['name', 'price', 'created_at'];

    $query->orderBy(in_array($sort, $allowed) ? $sort : 'created_at', $direction);

    // Cap page size so nobody can request 100,000 rows
    $perPage = min($request->integer('per_page', 15), 50);

    return response()->json($query->paginate($perPage));
}

Clients can now compose expressive requests:

  • /api/products?category_id=5&min_price=50&max_price=100
  • /api/products?search=wireless&sort_by=price&sort_dir=asc
  • /api/products?per_page=50&page=3
flowchart LR A[Request + query params] --> B[Build base query] B --> C[Apply filters] C --> D[Apply search] D --> E[Apply whitelisted sort] E --> F[Paginate] F --> G[JSON response]

JSON Error Handling

An API should never return an HTML error page. In Laravel 11 there is no app/Exceptions/Handler.php anymore โ€” exception handling is configured in bootstrap/app.php. Good news: for requests that send Accept: application/json, Laravel already renders 404, 422, and auth errors as JSON automatically. You only need to customize when you want a specific shape:

<?php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withExceptions(function (Exceptions $exceptions) {
        // Return a tidy JSON 404 for missing models on API routes
        $exceptions->render(function (ModelNotFoundException $e, Request $request) {
            if ($request->is('api/*') || $request->expectsJson()) {
                return response()->json([
                    'message' => 'Resource not found.',
                ], 404);
            }
        });
    })->create();

๐Ÿ’ก Force JSON everywhere on API routes

If some clients forget the Accept header, you can guarantee JSON responses by telling the exception handler that api/* requests should always be treated as expecting JSON โ€” add $exceptions->shouldRenderJsonWhen(fn ($request) => $request->is('api/*')); inside the same withExceptions closure.

Testing Your API

Laravel's HTTP testing helpers make API tests quick to write and fast to run. getJson, postJson, etc. send requests with the JSON headers already set, and the fluent assertions read almost like English:

<?php
// tests/Feature/Api/ProductApiTest.php
namespace Tests\Feature\Api;

use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_lists_products(): void
    {
        Product::factory()->count(3)->create();

        $response = $this->getJson('/api/products');

        $response
            ->assertOk() // 200
            ->assertJsonCount(3, 'data');
    }

    public function test_it_creates_a_product(): void
    {
        $payload = [
            'name'        => 'Wireless Mouse',
            'description' => 'Ergonomic and quiet.',
            'price'       => 29.99,
        ];

        $response = $this->postJson('/api/products', $payload);

        $response
            ->assertCreated() // 201
            ->assertJsonPath('data.name', 'Wireless Mouse');

        $this->assertDatabaseHas('products', ['name' => 'Wireless Mouse']);
    }

    public function test_it_validates_the_payload(): void
    {
        $response = $this->postJson('/api/products', ['price' => -5]);

        $response
            ->assertStatus(422)
            ->assertJsonValidationErrors(['name', 'price']);
    }

    public function test_it_returns_404_for_a_missing_product(): void
    {
        $this->getJson('/api/products/9999')->assertNotFound(); // 404
    }
}

Run them with php artisan test. Because RefreshDatabase resets the schema between tests, each test starts from a clean, predictable state.

Hands-on Exercise

๐Ÿ‹๏ธ Build a Filterable Products Endpoint

Objective: Ship a working GET /api/products that supports search, category filtering, sorting, and pagination โ€” then prove it with a test.

Steps

  1. In a fresh Laravel 11 app, run php artisan install:api.
  2. Create the model, migration, factory, and controller: php artisan make:model Product -mfc --api. Give products a name, description, price, and category_id.
  3. Register Route::apiResource('products', ProductController::class); in routes/api.php.
  4. Implement index() with the whitelist-guarded filtering/sorting/pagination from this lesson.
  5. Seed 30 products with the factory and hit /api/products?search=pro&sort_by=price&sort_dir=asc in your browser or Postman.
  6. Write a feature test asserting the search filter returns only matching rows.
๐Ÿ’ก Hint

Use $query->when($request->filled('search'), ...) so the filter only applies when the parameter is present. To test the search, create two products with distinct names, request with ?search= one of them, and assert assertJsonCount(1, 'data').

โœ… Sample solution (the test)
<?php
public function test_search_filters_products_by_name(): void
{
    Product::factory()->create(['name' => 'Gaming Keyboard']);
    Product::factory()->create(['name' => 'Office Chair']);

    $response = $this->getJson('/api/products?search=Keyboard');

    $response
        ->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.name', 'Gaming Keyboard');
}

๐ŸŽฏ Quick Quiz

Question 1: Which command creates routes/api.php and installs Sanctum in a fresh Laravel 11 app?

Question 2: A POST request successfully creates a new product. Which status code should the API return?

Question 3: Why should you whitelist the sort_by value before passing it to orderBy()?

Best Practices

โœ… Do

  • Match HTTP verbs to intent: GET reads, POST creates, PUT/PATCH updates, DELETE removes.
  • Return accurate status codes โ€” 201 on create, 204 on delete, 422 on validation failure.
  • Validate every write with Form Requests, and let route model binding fetch-or-404.
  • Always paginate collections and cap the maximum page size.
  • Keep one consistent response envelope across all endpoints.

โŒ Don't

  • Return 200 for errors โ€” clients rely on the status code.
  • Feed raw request values into orderBy() or where() column names.
  • Return HTML error pages to API clients โ€” force JSON on api/*.
  • Ship endpoints without at least a happy-path feature test.

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • REST models everything as resources with two URLs (collection + item); the HTTP verb chooses the action.
  • install:api scaffolds API routing in Laravel 11; apiResource registers all five routes at once.
  • Keep controllers thin: Form Requests validate, route model binding fetches, and you return JSON with correct status codes.
  • Add filtering, sorting, and pagination โ€” always whitelisting sort columns and capping page size.
  • Configure JSON error handling in bootstrap/app.php, and cover endpoints with feature tests.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

Right now our controllers hand-build the JSON shape, and every endpoint repeats that logic. In the next lesson, API Resources and Transformations, you'll move that shaping into a dedicated, reusable layer โ€” renaming fields, formatting values, and controlling exactly what each client sees.

๐ŸŽ‰ Well done!

You can now design, build, and test a real RESTful API in Laravel. Let's make its responses beautiful.