home / blog / RESTful API Design: The Decisions That A...
Software Engineering Feb 08, 2026

RESTful API Design: The Decisions That Actually Matter

Stop debating plurals vs singulars. Here are the API design decisions that genuinely impact developer experience — versioning, pagination, error formats, and filtering.

RESTful API Design: The Decisions That Actually Matter

API Design Is UX for Developers

Your API's consumers are developers. Every design decision affects their experience: how quickly they integrate, how often they hit bugs, how many support tickets they file. A well-designed API pays for itself in reduced support overhead and faster adoption.

Let's focus on the decisions that genuinely matter, not the bike-shedding.

Consistent Error Responses

This is the single most impactful API design decision. When something goes wrong, the developer needs to know: what failed, why it failed, and how to fix it.

// Bad: inconsistent error formats
{ "error": "Not found" }                           // string
{ "errors": { "email": "is required" } }            // object
{ "message": "Server error", "code": 500 }          // different shape
{ "success": false }                                // boolean with no info

// Good: one consistent format for ALL errors
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "The given data was invalid.",
        "details": [
            {
                "field": "email",
                "message": "The email field is required."
            },
            {
                "field": "password",
                "message": "The password must be at least 8 characters."
            }
        ]
    }
}

// 404
{
    "error": {
        "code": "RESOURCE_NOT_FOUND",
        "message": "The requested invoice was not found.",
        "details": []
    }
}

// 500
{
    "error": {
        "code": "INTERNAL_ERROR",
        "message": "An unexpected error occurred. Please try again.",
        "details": []
    }
}

In Laravel, you can standardize this with an exception handler:

// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
    public function render($request, Throwable $e)
    {
        if ($request->expectsJson()) {
            return $this->renderApiError($e);
        }

        return parent::render($request, $e);
    }

    private function renderApiError(Throwable $e): JsonResponse
    {
        return match(true) {
            $e instanceof ValidationException => response()->json([
                'error' => [
                    'code' => 'VALIDATION_ERROR',
                    'message' => $e->getMessage(),
                    'details' => collect($e->errors())->map(fn ($messages, $field) => [
                        'field' => $field,
                        'message' => $messages[0],
                    ])->values(),
                ],
            ], 422),

            $e instanceof ModelNotFoundException => response()->json([
                'error' => [
                    'code' => 'RESOURCE_NOT_FOUND',
                    'message' => 'The requested resource was not found.',
                    'details' => [],
                ],
            ], 404),

            default => response()->json([
                'error' => [
                    'code' => 'INTERNAL_ERROR',
                    'message' => app()->isProduction()
                        ? 'An unexpected error occurred.'
                        : $e->getMessage(),
                    'details' => [],
                ],
            ], 500),
        };
    }
}

Pagination: Cursor vs Offset

Offset pagination is familiar but breaks with large datasets (page drift when items are inserted/deleted). Cursor pagination is more reliable:

// Offset: simple but fragile
GET /api/posts?page=5&per_page=20

// Response includes pagination meta
{
    "data": [...],
    "meta": {
        "current_page": 5,
        "per_page": 20,
        "total": 1847,
        "last_page": 93
    },
    "links": {
        "next": "/api/posts?page=6&per_page=20",
        "prev": "/api/posts?page=4&per_page=20"
    }
}

// Cursor: consistent for real-time data
GET /api/posts?cursor=eyJpZCI6MTAwfQ&limit=20

// Laravel supports this natively
$posts = Post::orderBy('id')->cursorPaginate(20);
return PostResource::collection($posts);

Filtering and Sorting

A pragmatic approach that scales:

// Filtering with query parameters
GET /api/orders?status=paid&customer_id=42&created_after=2026-01-01

// Sorting
GET /api/orders?sort=-created_at,total
// Prefix with - for descending

// In Laravel, a simple filter pipeline
class OrderController extends Controller
{
    public function index(Request $request)
    {
        $query = Order::query();

        $query->when($request->status, fn ($q, $status) =>
            $q->where('status', $status)
        );

        $query->when($request->customer_id, fn ($q, $id) =>
            $q->where('customer_id', $id)
        );

        $query->when($request->created_after, fn ($q, $date) =>
            $q->where('created_at', '>=', $date)
        );

        $query->when($request->sort, function ($q, $sort) {
            foreach (explode(',', $sort) as $field) {
                $direction = str_starts_with($field, '-') ? 'desc' : 'asc';
                $q->orderBy(ltrim($field, '-'), $direction);
            }
        });

        return OrderResource::collection($query->cursorPaginate(20));
    }
}

Versioning: Keep It Simple

URL-based versioning is the most explicit and easiest to understand:

// URL prefix (recommended for most teams)
GET /api/v1/users
GET /api/v2/users

// In Laravel routes
Route::prefix('api/v1')->group(function () {
    Route::apiResource('users', V1\UserController::class);
});

Route::prefix('api/v2')->group(function () {
    Route::apiResource('users', V2\UserController::class);
});

API Resource Design Tips

  • Use plural nouns for collections: /api/users, not /api/user
  • Nest where ownership is clear: /api/users/42/orders but not 3+ levels deep
  • Use HTTP methods correctly: GET reads, POST creates, PUT/PATCH updates, DELETE removes
  • Return 201 for creation with a Location header
  • Return 204 for deletion (no content)
  • Include the created/updated resource in the response to save clients a follow-up request
  • Always wrap responses in a data key: { "data": { ... } } — this leaves room for metadata

The best API documentation is an API that doesn't need documentation. Use clear naming, consistent patterns, and helpful error messages. Write docs for the complex parts, not the obvious ones.

back to all posts