REST API Design: Stop Making It Complicated
Building a REST API? Here's how to design endpoints that make sense without overthinking it.
Table of Contents
- Resource Naming
- Use Nouns, Not Verbs
- Plural Names
- Nested Resources
- HTTP Methods
- Status Codes That Matter
- Success Codes
- Client Error Codes
- Server Error Codes
- Response Format
- Consistent Structure
- Pagination
- Filtering and Sorting
- Versioning
- URL Versioning
- Header Versioning
- Real API Example
- Error Handling
- Documentation
- Security
- 1. Always Validate
- 2. Rate Limiting
- 3. Authentication
- Common Mistakes
- Mistake 1: Inconsistent Naming
- Mistake 2: Wrong Status Codes
- Mistake 3: No Versioning
- Bottom Line
REST API Design: Stop Making It Complicated
Seen APIs like this?
GET /getUserById?id=123
POST /createNewOrder
GET /get-all-products-list
Painful.
Here's how to design APIs that don't make developers cry.
Resource Naming
Use Nouns, Not Verbs
❌ /getUsers
❌ /createOrder
❌ /deleteProduct
✅ GET /users
✅ POST /orders
✅ DELETE /products/{id}
The HTTP method is the verb. The URL is the noun.
Plural Names
❌ /user
❌ /order
❌ /product
✅ /users
✅ /orders
✅ /products
Consistency matters. Always plural.
Nested Resources
✅ GET /users/123/orders
✅ GET /orders/456/items
✅ POST /products/789/reviews
Shows relationship clearly.
HTTP Methods
Use them correctly:
// GET: Retrieve data (no side effects)
GET /products
GET /products/123
// POST: Create new resource
POST /products
// PUT: Replace entire resource
PUT /products/123
// PATCH: Update partial resource
PATCH /products/123
// DELETE: Remove resource
DELETE /products/123
Status Codes That Matter
Success Codes
// 200 OK: Request succeeded
return response()->json($data, 200);
// 201 Created: Resource created
return response()->json($order, 201);
// 204 No Content: Success, no data to return
return response()->noContent();
Client Error Codes
// 400 Bad Request: Invalid data
return response()->json(['error' => 'Invalid input'], 400);
// 401 Unauthorized: Not authenticated
return response()->json(['error' => 'Login required'], 401);
// 403 Forbidden: Authenticated but no permission
return response()->json(['error' => 'Access denied'], 403);
// 404 Not Found: Resource doesn't exist
return response()->json(['error' => 'Product not found'], 404);
// 422 Unprocessable Entity: Validation failed
return response()->json(['errors' => $validator->errors()], 422);
Server Error Codes
// 500 Internal Server Error: Something broke
return response()->json(['error' => 'Server error'], 500);
// 503 Service Unavailable: Maintenance mode
return response()->json(['error' => 'Under maintenance'], 503);
Response Format
Consistent Structure
// Success response
{
"success": true,
"data": {
"id": 123,
"name": "Product Name"
}
}
// Error response
{
"success": false,
"error": {
"message": "Product not found",
"code": "PRODUCT_NOT_FOUND"
}
}
// Validation error
{
"success": false,
"errors": {
"email": ["Email is required"],
"password": ["Password must be at least 8 characters"]
}
}
Pagination
{
"data": [...],
"meta": {
"current_page": 1,
"last_page": 10,
"per_page": 20,
"total": 200
},
"links": {
"first": "/api/products?page=1",
"last": "/api/products?page=10",
"prev": null,
"next": "/api/products?page=2"
}
}
Laravel does this automatically:
return Product::paginate(20);
Filtering and Sorting
// GET /products?category=electronics&sort=-price&limit=20
public function index(Request $request)
{
$query = Product::query();
// Filter by category
if ($request->has('category')) {
$query->where('category', $request->category);
}
// Sort
$sortField = ltrim($request->get('sort', 'id'), '-');
$sortDirection = str_starts_with($request->sort, '-') ? 'desc' : 'asc';
$query->orderBy($sortField, $sortDirection);
// Limit
$limit = min($request->get('limit', 20), 100);
return $query->paginate($limit);
}
Versioning
URL Versioning (Simplest)
Route::prefix('v1')->group(function () {
Route::get('/products', [ProductController::class, 'index']);
});
Route::prefix('v2')->group(function () {
Route::get('/products', [ProductControllerV2::class, 'index']);
});
GET /api/v1/products
GET /api/v2/products
Clear. Easy to maintain.
Header Versioning
// Accept: application/vnd.api.v1+json
if ($request->header('Accept') === 'application/vnd.api.v2+json') {
// Use v2 logic
}
Cleaner URLs, but more complex.
Our take: Use URL versioning. Simpler for clients.
Real API Example
Client: School management system API
Endpoints:
// Students
GET /api/v1/students
GET /api/v1/students/{id}
POST /api/v1/students
PATCH /api/v1/students/{id}
DELETE /api/v1/students/{id}
// Student's courses
GET /api/v1/students/{id}/courses
POST /api/v1/students/{id}/courses
// Courses
GET /api/v1/courses
GET /api/v1/courses/{id}
POST /api/v1/courses
PATCH /api/v1/courses/{id}
DELETE /api/v1/courses/{id}
Clean. Predictable. Easy to understand.
Error Handling
// app/Exceptions/Handler.php
public function render($request, Throwable $exception)
{
if ($request->is('api/*')) {
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'success' => false,
'error' => [
'message' => 'Resource not found',
'code' => 'RESOURCE_NOT_FOUND'
]
], 404);
}
if ($exception instanceof ValidationException) {
return response()->json([
'success' => false,
'errors' => $exception->errors()
], 422);
}
// Generic error
return response()->json([
'success' => false,
'error' => [
'message' => 'Internal server error',
'code' => 'SERVER_ERROR'
]
], 500);
}
return parent::render($request, $exception);
}
Documentation
Use OpenAPI/Swagger:
/**
* @OA\Get(
* path="/api/products",
* summary="Get all products",
* @OA\Parameter(
* name="category",
* in="query",
* description="Filter by category"
* ),
* @OA\Response(response="200", description="Success")
* )
*/
public function index(Request $request)
{
// Implementation
}
Generates interactive documentation automatically.
Security
1. Always Validate
$request->validate([
'email' => 'required|email',
'password' => 'required|min:8',
]);
2. Rate Limiting
Route::middleware('throttle:60,1')->group(function () {
// API routes
});
3. Authentication
Route::middleware('auth:sanctum')->group(function () {
// Protected routes
});
Common Mistakes
Mistake 1: Inconsistent Naming
/users
/getOrders
/product-list
Pick one style. Stick to it.
Mistake 2: Wrong Status Codes
// Wrong: Returns 200 for errors
return response()->json(['error' => 'Not found'], 200);
// Right: Use proper status code
return response()->json(['error' => 'Not found'], 404);
Mistake 3: No Versioning
// Breaking change affects all clients
Route::get('/products', function () {
// Changed response format
});
Always version from day one.
Bottom Line
Good API design is about consistency and predictability.
Use standard HTTP methods. Return proper status codes. Version your API.
Make it easy for developers to use. They'll thank you.
Building APIs?
We design and build REST APIs for Nigerian businesses. Clean, documented, scalable.
📞 WhatsApp: +234 708 711 0468
📧 info@raspibtech.com
📍 Lagos Island
Related:
Need Help with Your Project?
Let's discuss how Raspib Technology can help transform your business
Related Articles
Laravel 11: What Changed and Why You Should Care
Laravel 11 is out. Slimmer structure, better performance, and features that actually save time. Here's what matters.
Read more →Laravel 12: The Upgrade You've Been Waiting For
Laravel 12 brings major improvements. Here's what changed and why it matters for your projects.
Read more →Next.js 15: The Features That Actually Matter
Next.js 15 changed a lot. Here's what affects your projects, what breaks, and when to upgrade.
Read more →