HSHimanshu Sharma
PHP

How to create a REST API in Laravel: resources, validation and Sanctum tokens

How to create a REST API in Laravel: resources, validation and Sanctum tokens Advertisement

A REST API lets other programs (a mobile app, a JavaScript front end, a partner's server) read and change your data over HTTP, exchanging JSON instead of HTML pages. Laravel makes this quick, but a good API needs more than a route that returns a collection: consistent JSON, proper status codes, validation, pagination and authentication. This tutorial builds a small books API with all of them and shows the real request and response for each step. It was built and tested on a fresh Laravel 13.38 project with PHP 8.4 and SQLite; the same code works on Laravel 10, 11 and 12, with notes where they differ.

What we are building

Method URL What it does Needs a token?
GET /api/books list books (paginated, filter by author) no
GET /api/books/{id} one book no
POST /api/books create a book yes
PUT/PATCH /api/books/{id} update a book yes
DELETE /api/books/{id} delete a book yes

Reading is public; writing needs a Sanctum token.

Step 1: create the project and API scaffolding

composer create-project laravel/laravel books-api
cd books-api
php artisan install:api

In Laravel 11 and newer, install:api creates routes/api.php, installs Laravel Sanctum (token authentication) and runs its migration. On older versions, routes/api.php already exists and Sanctum is installed with composer require laravel/sanctum. Everything in routes/api.php is served under the /api prefix, is stateless (no sessions) and has rate limiting. Add the trait to your User model so users can create tokens:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Step 2: the model, migration and factory

php artisan make:model Book -mf

Edit the migration:

Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('author');
    $table->string('isbn', 20)->unique();
    $table->decimal('price', 8, 2);
    $table->timestamps();
});

Then define the model. $fillable lists the fields that may be mass-assigned, which stops a client from setting fields you did not intend (for example id or an is_admin flag):

class Book extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'author', 'isbn', 'price'];

    protected $casts = ['price' => 'decimal:2'];
}

And a factory for test data:

public function definition(): array
{
    return [
        'title'  => fake()->sentence(3),
        'author' => fake()->name(),
        'isbn'   => fake()->unique()->isbn13(),
        'price'  => fake()->randomFloat(2, 100, 900),
    ];
}

Run php artisan migrate.

Step 3: validation with a Form Request

Never trust incoming data. A Form Request moves the rules out of the controller and, for API requests, automatically returns a 422 JSON response with the errors when validation fails.

php artisan make:request StoreBookRequest
class StoreBookRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;   // access is handled by route middleware
    }

    public function rules(): array
    {
        $book = $this->route('book');   // null when creating

        return [
            'title'  => ['required', 'string', 'max:150'],
            'author' => ['required', 'string', 'max:100'],
            'isbn'   => ['required', 'string', 'max:20', Rule::unique('books', 'isbn')->ignore($book)],
            'price'  => ['required', 'numeric', 'min:0'],
        ];
    }
}

The same class serves create and update. Rule::unique(...)->ignore($book) stops an update from failing because the book's own ISBN "already exists".

Step 4: an API Resource for the response shape

Returning the Eloquent model directly exposes every column, including ones you add later. An API Resource is a transformer that defines exactly what the JSON contains and keeps it stable:

php artisan make:resource BookResource
class BookResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'title'      => $this->title,
            'author'     => $this->author,
            'isbn'       => $this->isbn,
            'price'      => (float) $this->price,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

The (float) cast turns the string "299.00" into the JSON number 299; decide on one representation of money and keep to it (many teams send prices in the smallest unit, e.g. paise, as integers).

Step 5: the controller

php artisan make:controller BookController --api
class BookController extends Controller
{
    public function index(Request $request): AnonymousResourceCollection
    {
        $books = Book::query()
            ->when($request->query('author'), fn ($q, $a) => $q->where('author', 'like', "%{$a}%"))
            ->orderBy('id')
            ->paginate(min((int) $request->query('per_page', 10), 50));

        return BookResource::collection($books);
    }

    public function show(Book $book): BookResource
    {
        return new BookResource($book);
    }

    public function store(StoreBookRequest $request): JsonResponse
    {
        $book = Book::create($request->validated());

        return (new BookResource($book))->response()->setStatusCode(201);
    }

    public function update(StoreBookRequest $request, Book $book): BookResource
    {
        $book->update($request->validated());

        return new BookResource($book);
    }

    public function destroy(Book $book): JsonResponse
    {
        $book->delete();

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

A few details that separate a tidy API from a sloppy one:

  • index paginates and caps per_page at 50, so a client cannot ask for a million rows. The when(...) adds a filter only if ?author= is present.
  • store returns 201 Created, not 200. destroy returns 204 No Content with an empty body.
  • Route model binding (Book $book) loads the book from the URL and returns a 404 automatically if it does not exist.
  • $request->validated() returns only the fields that passed validation, which is safer than $request->all().

Step 6: the routes

// routes/api.php
use App\Http\Controllers\BookController;

Route::apiResource('books', BookController::class)->only(['index', 'show']);

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('books', BookController::class)->only(['store', 'update', 'destroy']);
});

apiResource creates the five standard routes (without the create and edit form pages that a web resource has). php artisan route:list --path=api shows them:

GET|HEAD    api/books ............ books.index
POST        api/books ............ books.store
GET|HEAD    api/books/{book} ..... books.show
PUT|PATCH   api/books/{book} ..... books.update
DELETE      api/books/{book} ..... books.destroy

Step 7: see it work

I seeded 14 books and called the API from a feature test. Here is what came back.

List with pagination (GET /api/books?per_page=2) returned 200 and:

{
  "data": [
    {"id":1,"title":"Learn PHP","author":"Asha Rao","isbn":"978-1","price":299,"created_at":"2026-10-24T08:10:26+00:00"},
    {"id":2,"title":"Learn SQL","author":"Ravi Das","isbn":"978-2","price":199.5,"created_at":"2026-10-24T08:10:26+00:00"}
  ],
  "links": {"first":"http://localhost:8000/api/books?page=1","last":"http://localhost:8000/api/books?page=7","prev":null,"next":"http://localhost:8000/api/books?page=2"},
  "meta": {"current_page":1,"last_page":7,"per_page":2,"total":14, "...": "..."}
}

The data, links and meta structure comes from the paginator wrapped by the resource collection. Filter (?author=Ravi) returned just the one matching book. One book (GET /api/books/1) returned {"data":{...}}.

Missing book (GET /api/books/999) returned 404 with a JSON body whose message is No query results for model [App\Models\Book] 999. With APP_DEBUG=true (the default in local development) the JSON also contains the exception class and a stack trace; set APP_DEBUG=false in production so that internals never leak.

Writing without a token (POST /api/books) returned 401:

{"message":"Unauthenticated."}

Invalid data (empty title, negative price) with a valid token returned 422:

{
  "message": "The title field is required. (and 3 more errors)",
  "errors": {
    "title":  ["The title field is required."],
    "author": ["The author field is required."],
    "isbn":   ["The isbn field is required."],
    "price":  ["The price field must be at least 0."]
  }
}

A valid create returned 201 with the new book (id 15), and sending the same ISBN again returned 422 with "The isbn has already been taken.". An update (PUT /api/books/1) returned 200 with the new title and price. A delete returned 204 with an empty body, and fetching the deleted book afterwards returned 404.

Step 8: authentication with Sanctum tokens

Sanctum issues simple API tokens. Create one for a user, for example in php artisan tinker or in your own login endpoint:

$token = $user->createToken('mobile-app')->plainTextToken;

The plain-text token is shown once; only a hash is stored in the personal_access_tokens table, so store it safely on the client. The client then sends it on each request:

curl -X POST http://localhost:8000/api/books \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"title":"Laravel APIs","author":"Meena K","isbn":"978-99","price":450}'

In my test, a real token created with createToken was accepted (201), and a made-up one was rejected with 401 and {"message":"Unauthenticated."}. Always send Accept: application/json, otherwise Laravel may answer errors with a redirect to a login page or an HTML error page instead of JSON.

A typical login endpoint checks the credentials and returns a token:

Route::post('/login', function (Request $request) {
    $request->validate(['email' => 'required|email', 'password' => 'required']);
    $user = User::where('email', $request->email)->first();
    if (! $user || ! Hash::check($request->password, $user->password)) {
        return response()->json(['message' => 'Invalid credentials'], 401);
    }
    return ['token' => $user->createToken('api')->plainTextToken];
});

Add rate limiting to it (->middleware('throttle:5,1')), and give tokens only the abilities they need: createToken('reports', ['read']), then check with $user->tokenCan('read'). For who may do what once logged in, see roles and permissions in Laravel.

Step 9: write tests

An API is a contract, so test it. Laravel's HTTP testing helpers make that short:

use Laravel\Sanctum\Sanctum;

public function test_guest_cannot_create_books(): void
{
    $this->postJson('/api/books', ['title' => 'X'])->assertUnauthorized();
}

public function test_user_can_create_a_book(): void
{
    Sanctum::actingAs(User::factory()->create());

    $this->postJson('/api/books', [
        'title' => 'Laravel APIs', 'author' => 'Meena K', 'isbn' => '978-99', 'price' => 450,
    ])->assertCreated()
      ->assertJsonPath('data.title', 'Laravel APIs');

    $this->assertDatabaseHas('books', ['isbn' => '978-99']);
}

public function test_validation_errors_are_returned(): void
{
    Sanctum::actingAs(User::factory()->create());

    $this->postJson('/api/books', ['price' => -5])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['title', 'author', 'isbn', 'price']);
}

Use use RefreshDatabase; in the test class so every test starts with a clean database, and run them with php artisan test.

API design checklist

  1. Use nouns and plural names for resources (/books, not /getBooks).
  2. Use the right status codes: 200 OK, 201 Created, 204 No Content, 401 Unauthenticated, 403 Forbidden, 404 Not Found, 422 Validation failed, 429 Too Many Requests.
  3. Be consistent in the JSON shape (data, errors, message) for success and failure.
  4. Paginate every list and cap the page size.
  5. Version the API (/api/v1/...) before external clients depend on it. In Laravel, change the prefix in bootstrap/app.php's withRouting(apiPrefix: ...) or group routes with Route::prefix('v1').
  6. Never expose internals: use resources, turn APP_DEBUG off in production, and hide errors.
  7. Rate limit (Laravel's throttle middleware) and use HTTPS only.
  8. Eager load relations (Book::with('publisher')) in lists to avoid the N+1 query problem, as discussed in SQL performance optimisation.
  9. Document the endpoints with examples, or generate an OpenAPI description.
  10. Handle CORS (config/cors.php) if a browser app on another domain will call the API.

Common problems

Symptom Likely cause
Errors come back as an HTML login page the client did not send Accept: application/json
401 even with a token missing Bearer prefix, a token for a different user, or the HasApiTokens trait is missing
419 or CSRF errors the route is in routes/web.php; API routes belong in routes/api.php
MassAssignmentException the field is not in $fillable
404 for /api/books routes/api.php is not loaded (run php artisan install:api) or the web server rewrites /api wrongly
Price shows as "299.00" no cast in the resource; decide your money format
500 in production only check storage/logs/laravel.log, and make sure .env and php artisan config:cache are in order

Next steps

Take the API live with Git-based deployment, which includes a Laravel checklist. If you consume other people's APIs from PHP, see the worked examples in the eBay Browse API guide and the Google Translation API guide, and test yourself with the PHP interview questions for experienced developers, which include a plain-PHP JSON API. The JSON Formatter helps when you inspect responses by eye.

Advertisement