Przejdź do treści
Backend

Building REST APIs in Laravel with Sanctum - a complete guide

Published on:
·4 min read·Author: MDS Software Solutions Group

Building REST APIs

backend

Building REST APIs in Laravel with Sanctum - a complete guide

Laravel is one of the most popular PHP frameworks, offering elegant tools for building modern REST APIs. Combined with Laravel Sanctum - a lightweight authentication system - you can create secure, performant, and scalable APIs in a short time. In this guide, we will walk you through the entire process: from project setup, through RESTful endpoint design, to authentication, validation, testing, and documentation.

Setting up a Laravel API project#

Start by creating a new Laravel project and configuring it for API development:

# Install a new Laravel project
composer create-project laravel/laravel my-api-project
cd my-api-project

# Configure .env with your database connection
# DB_CONNECTION=mysql
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=my_api
# DB_USERNAME=root
# DB_PASSWORD=secret

# Run migrations
php artisan migrate

Laravel ships with routes/api.php by default, where you define your API routes. All routes in this file automatically receive the /api prefix and use the api middleware group.

API project structure#

A well-organized Laravel API project should follow a clear directory structure:

app/
  Http/
    Controllers/
      Api/
        V1/
          ArticleController.php
          AuthController.php
          CommentController.php
    Requests/
      StoreArticleRequest.php
      UpdateArticleRequest.php
    Resources/
      ArticleResource.php
      ArticleCollection.php
  Models/
    Article.php
    Comment.php
  Policies/
    ArticlePolicy.php
routes/
  api.php
  api_v1.php
tests/
  Feature/
    Api/
      ArticleTest.php
      AuthTest.php

RESTful API design principles#

Before diving into implementation, it is important to understand the key principles of REST API design:

Resource naming conventions#

Use plural nouns for resource names and HTTP verbs to define actions:

| HTTP Method | Endpoint | Description | |-------------|----------------------|-----------------------| | GET | /api/articles | List articles | | GET | /api/articles/{id} | Get single article | | POST | /api/articles | Create article | | PUT/PATCH | /api/articles/{id} | Update article | | DELETE | /api/articles/{id} | Delete article |

HTTP response codes#

Use appropriate HTTP status codes consistently:

  • 200 OK - successful retrieval or update
  • 201 Created - successful resource creation
  • 204 No Content - successful deletion
  • 400 Bad Request - malformed input
  • 401 Unauthorized - missing authentication
  • 403 Forbidden - insufficient permissions
  • 404 Not Found - resource not found
  • 422 Unprocessable Entity - validation error
  • 429 Too Many Requests - rate limit exceeded
  • 500 Internal Server Error - server error

API Resource Controllers#

Laravel allows you to quickly scaffold controllers with a full set of CRUD methods using a single command:

php artisan make:controller Api/V1/ArticleController --api --model=Article

The --api flag generates a controller without the create and edit methods (HTML forms), which are unnecessary for APIs:

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreArticleRequest;
use App\Http\Requests\UpdateArticleRequest;
use App\Http\Resources\ArticleResource;
use App\Http\Resources\ArticleCollection;
use App\Models\Article;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    /**
     * Display a paginated list of articles.
     */
    public function index(Request $request): ArticleCollection
    {
        $articles = Article::query()
            ->with(['author', 'tags'])
            ->when($request->query('search'), function ($query, $search) {
                $query->where('title', 'like', "%{$search}%")
                      ->orWhere('content', 'like', "%{$search}%");
            })
            ->when($request->query('tag'), function ($query, $tag) {
                $query->whereHas('tags', fn ($q) => $q->where('slug', $tag));
            })
            ->when($request->query('sort'), function ($query, $sort) {
                $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
                $column = ltrim($sort, '-');
                $query->orderBy($column, $direction);
            }, function ($query) {
                $query->latest();
            })
            ->paginate($request->query('per_page', 15));

        return new ArticleCollection($articles);
    }

    /**
     * Store a new article.
     */
    public function store(StoreArticleRequest $request): JsonResponse
    {
        $article = Article::create([
            ...$request->validated(),
            'user_id' => $request->user()->id,
            'slug' => str()->slug($request->title),
        ]);

        $article->tags()->sync($request->tag_ids ?? []);

        return (new ArticleResource($article->load(['author', 'tags'])))
            ->response()
            ->setStatusCode(201);
    }

    /**
     * Display a single article.
     */
    public function show(Article $article): ArticleResource
    {
        return new ArticleResource(
            $article->load(['author', 'tags', 'comments.user'])
        );
    }

    /**
     * Update the specified article.
     */
    public function update(UpdateArticleRequest $request, Article $article): ArticleResource
    {
        $article->update($request->validated());

        if ($request->has('tag_ids')) {
            $article->tags()->sync($request->tag_ids);
        }

        return new ArticleResource($article->fresh(['author', 'tags']));
    }

    /**
     * Remove the specified article.
     */
    public function destroy(Article $article): JsonResponse
    {
        $this->authorize('delete', $article);

        $article->delete();

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

Route registration#

Register the controller in routes/api.php:

use App\Http\Controllers\Api\V1\ArticleController;
use App\Http\Controllers\Api\V1\AuthController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::prefix('v1')->group(function () {
    Route::post('/register', [AuthController::class, 'register']);
    Route::post('/login', [AuthController::class, 'login']);

    Route::get('/articles', [ArticleController::class, 'index']);
    Route::get('/articles/{article}', [ArticleController::class, 'show']);
});

// Protected routes
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);

    Route::post('/articles', [ArticleController::class, 'store']);
    Route::put('/articles/{article}', [ArticleController::class, 'update']);
    Route::delete('/articles/{article}', [ArticleController::class, 'destroy']);
});

Eloquent API Resources - data transformation#

API Resources provide a controlled way to transform Eloquent models into JSON responses. They serve as an abstraction layer between your models and the API output:

php artisan make:resource ArticleResource
php artisan make:resource ArticleCollection --collection

Defining a resource#

<?php

namespace App\Http\Resources;

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

class ArticleResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'type' => 'article',
            'attributes' => [
                'title' => $this->title,
                'slug' => $this->slug,
                'excerpt' => str()->limit($this->content, 200),
                'content' => $this->when(
                    $request->routeIs('articles.show'),
                    $this->content
                ),
                'reading_time' => ceil(str_word_count($this->content) / 200) . ' min',
                'published_at' => $this->published_at?->toISOString(),
                'created_at' => $this->created_at->toISOString(),
                'updated_at' => $this->updated_at->toISOString(),
            ],
            'relationships' => [
                'author' => [
                    'id' => $this->author->id,
                    'name' => $this->author->name,
                ],
                'tags' => $this->whenLoaded('tags', function () {
                    return $this->tags->map(fn ($tag) => [
                        'id' => $tag->id,
                        'name' => $tag->name,
                        'slug' => $tag->slug,
                    ]);
                }),
                'comments_count' => $this->whenCounted('comments'),
            ],
            'links' => [
                'self' => route('api.v1.articles.show', $this->id),
            ],
        ];
    }
}

Resource collection with metadata#

<?php

namespace App\Http\Resources;

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

class ArticleCollection extends ResourceCollection
{
    public $collects = ArticleResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
                'has_more_pages' => $this->hasMorePages(),
            ],
            'links' => [
                'first' => $this->url(1),
                'last' => $this->url($this->lastPage()),
                'prev' => $this->previousPageUrl(),
                'next' => $this->nextPageUrl(),
            ],
        ];
    }
}

Laravel Sanctum - setup and authentication#

Laravel Sanctum provides two authentication modes: API tokens for mobile apps and external clients, and session-based (cookie) authentication for SPAs.

Installation and configuration#

# Sanctum is included by default since Laravel 11
# For older versions:
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Ensure your User model uses the HasApiTokens trait:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Authentication controller with tokens#

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    /**
     * Register a new user.
     */
    public function register(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        $token = $user->createToken('auth-token', ['*'], now()->addDays(30));

        return response()->json([
            'message' => 'Account created successfully.',
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
            'token' => $token->plainTextToken,
            'token_type' => 'Bearer',
            'expires_at' => $token->accessToken->expires_at->toISOString(),
        ], 201);
    }

    /**
     * Log in and generate a token.
     */
    public function login(Request $request): JsonResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        // Optionally: revoke old tokens
        // $user->tokens()->delete();

        $token = $user->createToken(
            'auth-token',
            $this->getAbilitiesForUser($user),
            now()->addDays(30)
        );

        return response()->json([
            'message' => 'Logged in successfully.',
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
            'token' => $token->plainTextToken,
            'token_type' => 'Bearer',
        ]);
    }

    /**
     * Logout - revoke the current token.
     */
    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Logged out successfully.',
        ]);
    }

    /**
     * Get abilities based on user role.
     */
    private function getAbilitiesForUser(User $user): array
    {
        return match ($user->role) {
            'admin' => ['*'],
            'editor' => ['articles:create', 'articles:update', 'articles:delete', 'comments:manage'],
            'author' => ['articles:create', 'articles:update', 'comments:create'],
            default => ['articles:read', 'comments:create'],
        };
    }
}

Checking token abilities#

// In a controller or middleware
public function store(StoreArticleRequest $request): JsonResponse
{
    if (! $request->user()->tokenCan('articles:create')) {
        abort(403, 'You do not have permission to create articles.');
    }

    // ... creation logic
}

SPA authentication with Sanctum#

For Single Page Applications (React, Vue, Angular), Sanctum offers cookie-based authentication:

CORS and Sanctum configuration#

In config/sanctum.php:

'stateful' => explode(',', env(
    'SANCTUM_STATEFUL_DOMAINS',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000'
)),

In config/cors.php:

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
    'supports_credentials' => true,
];

SPA authentication flow#

// 1. Fetch the CSRF cookie
await fetch('/sanctum/csrf-cookie', {
    method: 'GET',
    credentials: 'include',
});

// 2. Log in
const response = await fetch('/api/v1/login', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-XSRF-TOKEN': getCookie('XSRF-TOKEN'),
    },
    credentials: 'include',
    body: JSON.stringify({
        email: 'user@example.com',
        password: 'password',
    }),
});

// 3. Subsequent requests are automatically authenticated via cookies
const articles = await fetch('/api/v1/articles', {
    headers: { 'Accept': 'application/json' },
    credentials: 'include',
});

API rate limiting#

Laravel provides a flexible rate limiting system defined in AppServiceProvider:

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Global API limit
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by(
                $request->user()?->id ?: $request->ip()
            )->response(function () {
                return response()->json([
                    'message' => 'Too many requests. Please try again later.',
                    'retry_after' => 60,
                ], 429);
            });
        });

        // Strict limit for authentication endpoints
        RateLimiter::for('auth', function (Request $request) {
            return [
                Limit::perMinute(5)->by($request->ip()),
                Limit::perMinute(10)->by($request->input('email')),
            ];
        });

        // Higher limit for authenticated users
        RateLimiter::for('authenticated', function (Request $request) {
            return $request->user()?->is_premium
                ? Limit::perMinute(120)->by($request->user()->id)
                : Limit::perMinute(60)->by($request->user()->id);
        });
    }
}

Applying rate limiters to routes:

Route::middleware(['throttle:auth'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/register', [AuthController::class, 'register']);
});

Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function () {
    Route::apiResource('articles', ArticleController::class);
});

API versioning#

Versioning allows you to introduce changes without breaking existing clients:

URL prefix strategy#

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

Route::prefix('v2')->group(function () {
    Route::apiResource('articles', Api\V2\ArticleController::class);
});

Header-based strategy#

// app/Http/Middleware/ApiVersionMiddleware.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiVersionMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $version = $request->header('X-API-Version', 'v1');

        config(['app.api_version' => $version]);

        return $next($request);
    }
}

Using an abstract base controller#

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;

abstract class BaseApiController extends Controller
{
    protected function successResponse(mixed $data, int $statusCode = 200)
    {
        return response()->json([
            'success' => true,
            'data' => $data,
            'api_version' => config('app.api_version', 'v1'),
        ], $statusCode);
    }

    protected function errorResponse(string $message, int $statusCode, array $errors = [])
    {
        return response()->json([
            'success' => false,
            'message' => $message,
            'errors' => $errors ?: null,
            'api_version' => config('app.api_version', 'v1'),
        ], $statusCode);
    }
}

Request validation with Form Requests#

Form Requests are an elegant way to extract validation logic from your controllers:

php artisan make:request StoreArticleRequest
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->tokenCan('articles:create');
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255', 'unique:articles,title'],
            'content' => ['required', 'string', 'min:100'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'category_id' => ['required', 'exists:categories,id'],
            'tag_ids' => ['nullable', 'array'],
            'tag_ids.*' => ['exists:tags,id'],
            'published_at' => ['nullable', 'date', 'after_or_equal:today'],
            'featured_image' => ['nullable', 'url', 'max:2048'],
            'status' => ['sometimes', 'in:draft,published,scheduled'],
        ];
    }

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

    /**
     * Return a JSON response when validation fails.
     */
    protected function failedValidation(Validator $validator): void
    {
        throw new HttpResponseException(
            response()->json([
                'success' => false,
                'message' => 'Validation error.',
                'errors' => $validator->errors(),
            ], 422)
        );
    }
}

Error handling and API responses#

Configure global error handling in bootstrap/app.php (Laravel 11+):

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ThrottleRequestsException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->statefulApi();
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->shouldRenderJsonWhen(function ($request) {
            return $request->is('api/*') || $request->expectsJson();
        });

        $exceptions->render(function (AuthenticationException $e) {
            return response()->json([
                'success' => false,
                'message' => 'Authentication required.',
            ], 401);
        });

        $exceptions->render(function (ModelNotFoundException $e) {
            $model = class_basename($e->getModel());
            return response()->json([
                'success' => false,
                'message' => "Resource not found: {$model}.",
            ], 404);
        });

        $exceptions->render(function (NotFoundHttpException $e) {
            return response()->json([
                'success' => false,
                'message' => 'The specified endpoint does not exist.',
            ], 404);
        });

        $exceptions->render(function (ThrottleRequestsException $e) {
            return response()->json([
                'success' => false,
                'message' => 'Too many requests. Please try again later.',
                'retry_after' => $e->getHeaders()['Retry-After'] ?? null,
            ], 429);
        });
    })
    ->create();

Testing APIs with PHPUnit#

Testing is an essential part of building professional APIs. Laravel provides powerful HTTP testing tools:

php artisan make:test Api/ArticleTest
<?php

namespace Tests\Feature\Api;

use App\Models\Article;
use App\Models\Category;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ArticleTest extends TestCase
{
    use RefreshDatabase;

    private User $user;
    private Category $category;

    protected function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->create();
        $this->category = Category::factory()->create();
    }

    /** @test */
    public function guest_can_list_published_articles(): void
    {
        Article::factory()->count(5)->create(['status' => 'published']);
        Article::factory()->count(2)->create(['status' => 'draft']);

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

        $response->assertOk()
            ->assertJsonCount(5, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => [
                        'id',
                        'type',
                        'attributes' => ['title', 'slug', 'excerpt', 'created_at'],
                        'relationships' => ['author'],
                        'links' => ['self'],
                    ],
                ],
                'meta' => ['total', 'per_page', 'current_page'],
                'links' => ['first', 'last', 'prev', 'next'],
            ]);
    }

    /** @test */
    public function authenticated_user_can_create_article(): void
    {
        Sanctum::actingAs($this->user, ['articles:create']);

        $tag = Tag::factory()->create();

        $payload = [
            'title' => 'My new article about Laravel',
            'content' => str_repeat('Test article content paragraph. ', 10),
            'category_id' => $this->category->id,
            'tag_ids' => [$tag->id],
            'status' => 'published',
        ];

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

        $response->assertCreated()
            ->assertJsonPath('data.attributes.title', 'My new article about Laravel')
            ->assertJsonPath('data.relationships.author.id', $this->user->id);

        $this->assertDatabaseHas('articles', [
            'title' => 'My new article about Laravel',
            'user_id' => $this->user->id,
        ]);
    }

    /** @test */
    public function unauthenticated_user_cannot_create_article(): void
    {
        $payload = [
            'title' => 'Unauthorized article',
            'content' => str_repeat('Article content. ', 10),
            'category_id' => $this->category->id,
        ];

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

        $response->assertUnauthorized();
    }

    /** @test */
    public function it_validates_article_creation_data(): void
    {
        Sanctum::actingAs($this->user, ['articles:create']);

        $response = $this->postJson('/api/v1/articles', [
            'title' => '',
            'content' => 'Short',
        ]);

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

    /** @test */
    public function user_can_update_own_article(): void
    {
        Sanctum::actingAs($this->user, ['articles:update']);

        $article = Article::factory()->create(['user_id' => $this->user->id]);

        $response = $this->putJson("/api/v1/articles/{$article->id}", [
            'title' => 'Updated title',
        ]);

        $response->assertOk()
            ->assertJsonPath('data.attributes.title', 'Updated title');
    }

    /** @test */
    public function user_cannot_delete_others_article(): void
    {
        $otherUser = User::factory()->create();
        Sanctum::actingAs($this->user, ['articles:delete']);

        $article = Article::factory()->create(['user_id' => $otherUser->id]);

        $response = $this->deleteJson("/api/v1/articles/{$article->id}");

        $response->assertForbidden();
    }

    /** @test */
    public function it_paginates_articles(): void
    {
        Article::factory()->count(25)->create(['status' => 'published']);

        $response = $this->getJson('/api/v1/articles?per_page=10&page=2');

        $response->assertOk()
            ->assertJsonCount(10, 'data')
            ->assertJsonPath('meta.current_page', 2)
            ->assertJsonPath('meta.per_page', 10);
    }
}

Running tests:

# All tests
php artisan test

# Only API tests
php artisan test --filter=ArticleTest

# With code coverage
php artisan test --coverage --min=80

API documentation with Scribe#

Automated API documentation greatly facilitates working with your API for both your development team and external consumers:

composer require knuckleswtf/scribe
php artisan vendor:publish --tag=scribe-config

Configure in config/scribe.php:

return [
    'title' => 'My API Documentation',
    'description' => 'REST API Documentation',
    'base_url' => env('APP_URL'),
    'type' => 'external_static', // or 'laravel'
    'auth' => [
        'enabled' => true,
        'default' => true,
        'in' => 'bearer',
        'name' => 'Authorization',
        'use_value' => 'Bearer {TOKEN}',
        'placeholder' => '{TOKEN}',
    ],
];

Add annotations to your controllers:

/**
 * @group Articles
 *
 * Manage articles in the system.
 */
class ArticleController extends Controller
{
    /**
     * List articles
     *
     * Retrieve a paginated list of published articles.
     * Supports filtering, sorting, and search.
     *
     * @queryParam search string Search articles by title or content. Example: Laravel
     * @queryParam tag string Filter by tag slug. Example: php
     * @queryParam sort string Sort results. Prefix '-' for descending. Example: -created_at
     * @queryParam per_page integer Results per page (max 100). Example: 15
     * @queryParam page integer Page number. Example: 1
     *
     * @response 200 scenario="Success" {
     *   "data": [
     *     {
     *       "id": 1,
     *       "type": "article",
     *       "attributes": {
     *         "title": "Getting started with Laravel",
     *         "slug": "getting-started-with-laravel",
     *         "excerpt": "Laravel is a modern PHP framework..."
     *       }
     *     }
     *   ],
     *   "meta": {"total": 50, "per_page": 15, "current_page": 1}
     * }
     */
    public function index(Request $request): ArticleCollection
    {
        // ...
    }
}

Generate the documentation:

php artisan scribe:generate

Summary#

Building REST APIs in Laravel with Sanctum is a process that covers many aspects - from proper endpoint design, through validation and authentication, to testing and documentation. The key elements are:

  • Consistent API Resources for controlled data transformation
  • Laravel Sanctum for secure token and session-based authentication
  • Form Requests for clean, reusable validation logic
  • Rate limiting to protect your API from abuse
  • Versioning to ensure backward compatibility
  • PHPUnit tests to verify correct behavior
  • Automated documentation to simplify integration

By following these practices, you will build APIs that are secure, performant, and easy to maintain.


Need a professional API for your project?#

At MDS Software Solutions Group, we specialize in designing and building scalable REST APIs using Laravel, .NET, and Node.js. Our team of experienced developers will help you create secure, performant, and well-documented APIs that meet your business requirements.

Contact us to discuss your project. We offer a free consultation where we will assess your needs and propose an optimal solution.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Building REST APIs in Laravel with Sanctum - a complete guide | MDS Software Solutions Group | MDS Software Solutions Group