๐ 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:apiand register anapiResource - 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.phpand 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.
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/productsis the collection,/api/products/5is 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 Method | CRUD | Example | Meaning |
|---|---|---|---|
GET | Read | GET /api/products | List products |
POST | Create | POST /api/products | Create a product |
GET | Read | GET /api/products/5 | Show product 5 |
PUT/PATCH | Update | PUT /api/products/5 | Update product 5 |
DELETE | Delete | DELETE /api/products/5 | Delete product 5 |
๐ 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:
| Verb | URI | Action | Route Name |
|---|---|---|---|
| GET | /api/products | index | products.index |
| POST | /api/products | store | products.store |
| GET | /api/products/{product} | show | products.show |
| PUT/PATCH | /api/products/{product} | update | products.update |
| DELETE | /api/products/{product} | destroy | products.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
datakey. - 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:
| Code | Meaning | When to use |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH succeeded |
| 201 Created | Resource created | POST created a new record |
| 204 No Content | Success, empty body | DELETE succeeded |
| 400 Bad Request | Malformed request | The client sent nonsense |
| 401 Unauthorized | Not authenticated | Missing or invalid token |
| 403 Forbidden | Not authorized | Logged in but not allowed |
| 404 Not Found | Missing resource | No such record |
| 422 Unprocessable Entity | Validation failed | Input didn't pass the rules |
| 429 Too Many Requests | Rate limited | Client exceeded the throttle |
| 500 Server Error | Something broke | Unhandled 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
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
- In a fresh Laravel 11 app, run
php artisan install:api. - Create the model, migration, factory, and controller:
php artisan make:model Product -mfc --api. Give products aname,description,price, andcategory_id. - Register
Route::apiResource('products', ProductController::class);inroutes/api.php. - Implement
index()with the whitelist-guarded filtering/sorting/pagination from this lesson. - Seed 30 products with the factory and hit
/api/products?search=pro&sort_by=price&sort_dir=ascin your browser or Postman. - 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
200for errors โ clients rely on the status code. - Feed raw request values into
orderBy()orwhere()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:apiscaffolds API routing in Laravel 11;apiResourceregisters 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
- Laravel Docs โ Routing
- Laravel Docs โ API Resource Routes
- Laravel Docs โ HTTP Tests
- MDN โ HTTP response status codes
๐ 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.