Back
Laravel

RESTful API with Laravel: Best Practices 2026

Learn how to design and build professional RESTful APIs with Laravel. We cover API Resources, Form Requests, versioning, rate limiting, pagination, filtering, and testing to create robust and scalable APIs.

Francisco ZapataWritten by Francisco Zapata
December 18, 202511 min read
RESTful API with Laravel: Best Practices 2026

Building professional-grade RESTful APIs is a core skill for any modern backend developer. Laravel provides a comprehensive ecosystem of tools that streamline the creation of robust, secure, and well-documented APIs. In this article, we will explore the best practices for designing APIs that are easy to maintain and scale.

API Project Structure

The first step toward a well-organized API is establishing a clear structure. Laravel allows us to separate API routes in the routes/api.php file, which automatically applies the /api prefix and the appropriate middleware.

// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\UserController;

Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('users', UserController::class);
    Route::get('users/{user}/posts', [UserController::class, 'posts']);
});

The apiResource method automatically generates index, store, show, update, and destroy routes while excluding the create and edit routes that are unnecessary for an API.

API Controllers

API controllers should be concise and delegate business logic to dedicated services or actions. Use the --api flag when generating controllers to get only the relevant methods.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Models\Post;
use Illuminate\Http\JsonResponse;

class PostController extends Controller
{
    public function index(): PostCollection
    {
        $posts = Post::query()
            ->with(['author', 'category'])
            ->published()
            ->latest()
            ->paginate(15);

        return new PostCollection($posts);
    }

    public function store(StorePostRequest $request): JsonResponse
    {
        $post = Post::create($request->validated());

        return (new PostResource($post))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Post $post): PostResource
    {
        $post->load(['author', 'comments.user', 'tags']);

        return new PostResource($post);
    }

    public function update(UpdatePostRequest $request, Post $post): PostResource
    {
        $post->update($request->validated());

        return new PostResource($post);
    }

    public function destroy(Post $post): JsonResponse
    {
        $post->delete();

        return response()->json(null, 204);
    }
}

API Resources: Shaping Your Responses

API Resources serve as the transformation layer between your Eloquent models and the JSON responses your clients receive. They give you fine-grained control over what data gets exposed and how it is formatted.

<?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,
            'slug' => $this->slug,
            'title' => $this->title,
            'excerpt' => $this->excerpt,
            'content' => $this->when(
                $request->routeIs('posts.show'),
                $this->content
            ),
            'featured_image' => $this->featured_image,
            'reading_time' => $this->reading_time,
            'published_at' => $this->published_at?->toIso8601String(),
            'author' => new UserResource($this->whenLoaded('author')),
            'category' => new CategoryResource($this->whenLoaded('category')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->whenCounted('comments'),
        ];
    }
}

For collections, you can attach additional metadata that gives context to the response.

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class PostCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_posts' => Post::count(),
                'api_version' => 'v1',
            ],
        ];
    }
}

Form Requests: Bulletproof Validation

Form Requests encapsulate validation and authorization logic outside the controller, keeping your code clean and reusable.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'unique:posts,slug'],
            'content' => ['required', 'string', 'min:100'],
            'category_id' => ['required', 'exists:categories,id'],
            'tags' => ['sometimes', 'array'],
            'tags.*' => ['exists:tags,id'],
            'featured_image' => ['nullable', 'url', 'max:500'],
            'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'The post title is required.',
            'content.min' => 'The content must be at least 100 characters.',
            'category_id.exists' => 'The selected category does not exist.',
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->title),
            'author_id' => $this->user()->id,
        ]);
    }
}

API Versioning

URL prefix versioning is the most straightforward and widely adopted approach. Organize your controllers and resources by version.

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('posts', V1\PostController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('posts', V2\PostController::class);
});

Rate Limiting

Laravel lets you define flexible rate limits to protect your API from abuse and ensure fair usage across all consumers.

// bootstrap/app.php or AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(30)->by($request->ip());
});

// Dedicated limit for sensitive endpoints
RateLimiter::for('uploads', function (Request $request) {
    return Limit::perMinute(5)->by($request->user()->id);
});

Apply these limits to the appropriate routes:

Route::middleware('throttle:uploads')->group(function () {
    Route::post('posts/{post}/images', [PostImageController::class, 'store']);
});

Consistent Error Handling

Uniform error handling is essential for a good developer experience when consuming your API.

// bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'error' => 'Resource not found.',
                'code' => 'RESOURCE_NOT_FOUND',
            ], 404);
        }
    });
})

Filtering and Pagination

Implementing flexible filtering allows clients to fetch exactly the data they need without over-fetching.

// app/Models/Post.php
public function scopeFilter(Builder $query, array $filters): Builder
{
    return $query
        ->when($filters['search'] ?? null, fn ($q, $search) =>
            $q->where('title', 'like', "%{$search}%")
        )
        ->when($filters['category'] ?? null, fn ($q, $category) =>
            $q->where('category_id', $category)
        )
        ->when($filters['status'] ?? null, fn ($q, $status) =>
            $q->where('status', $status)
        )
        ->when($filters['from'] ?? null, fn ($q, $from) =>
            $q->where('published_at', '>=', $from)
        );
}

// In the controller
public function index(Request $request): PostCollection
{
    $posts = Post::query()
        ->filter($request->only(['search', 'category', 'status', 'from']))
        ->with(['author', 'category'])
        ->latest()
        ->paginate($request->input('per_page', 15));

    return new PostCollection($posts);
}

API Testing

Automated tests ensure your API works correctly as the codebase evolves over time.

<?php

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    public function test_can_list_posts(): void
    {
        Post::factory()->count(5)->create();

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

        $response->assertOk()
            ->assertJsonCount(5, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'slug', 'excerpt', 'author'],
                ],
                'links',
                'meta',
            ]);
    }

    public function test_can_create_post(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->postJson('/api/v1/posts', [
                'title' => 'My new post',
                'content' => str_repeat('Sample content. ', 20),
                'category_id' => Category::factory()->create()->id,
                'status' => 'published',
            ]);

        $response->assertCreated()
            ->assertJsonPath('data.title', 'My new post');
    }

    public function test_validates_required_fields(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->postJson('/api/v1/posts', []);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['title', 'content']);
    }
}

Conclusion

Building professional RESTful APIs with Laravel demands attention to detail at every layer: from route structure and controllers to data transformation with Resources, validation with Form Requests, and protection with rate limiting. By following these practices, your APIs will be consistent, secure, and easy for any frontend or mobile client to consume. The Laravel ecosystem greatly simplifies this process, letting you focus on business logic while the framework handles the repetitive patterns.

Comments (0)

Leave a comment

Be the first to comment