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:
indexpaginates and capsper_pageat 50, so a client cannot ask for a million rows. Thewhen(...)adds a filter only if?author=is present.storereturns 201 Created, not 200.destroyreturns 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
- Use nouns and plural names for resources (
/books, not/getBooks). - 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.
- Be consistent in the JSON shape (
data,errors,message) for success and failure. - Paginate every list and cap the page size.
- Version the API (
/api/v1/...) before external clients depend on it. In Laravel, change the prefix inbootstrap/app.php'swithRouting(apiPrefix: ...)or group routes withRoute::prefix('v1'). - Never expose internals: use resources, turn
APP_DEBUGoff in production, and hide errors. - Rate limit (Laravel's
throttlemiddleware) and use HTTPS only. - Eager load relations (
Book::with('publisher')) in lists to avoid the N+1 query problem, as discussed in SQL performance optimisation. - Document the endpoints with examples, or generate an OpenAPI description.
- 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.