Sooner or later, most web projects need an API. A mobile app wants to read the same data as the website, a client wants their ERP to push orders in automatically, or a React or Next.js frontend needs something to talk to. Laravel is one of the most productive ways to build that API in PHP, and Laravel Sanctum handles authentication without the complexity of a full OAuth server.
In this guide I’ll build a small but production-shaped API for managing projects, step by step: installing the API scaffolding, designing the database, shaping JSON with API resources, issuing and checking tokens with Sanctum, validating input, paginating results, rate limiting, versioning and, finally, testing it all automatically. Every snippet is code you can copy into a fresh Laravel project.
In this article
The anatomy of a Laravel API endpoint
Before writing code, it helps to see the layers a request passes through. Each layer has one job, which keeps controllers short and makes every part easy to test and change.
The pleasant surprise is how many of those status codes you get for free. Laravel returns JSON errors with the correct code (401, 403, 404, 422 and 429) when a request asks for JSON, so your controller only handles the happy path.
Setting up the API and Sanctum
Since Laravel 11, a new project doesn’t include API routes until you ask for them. One Artisan command creates routes/api.php, installs Laravel Sanctum and publishes its migration:
php artisan install:api
Routes in routes/api.php are automatically prefixed with /api and are stateless (no sessions or cookies). Next, give the User model the ability to issue tokens:
<?php
// app/Models/User.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;
// ...the rest of the default User model
}
The data model
Our API manages projects that belong to users. Generate the model with its migration, factory and form request classes, then an API controller in its own Api folder:
php artisan make:model Project --migration --factory --requests
php artisan make:controller Api/ProjectController --api --model=Project
<?php
// database/migrations/xxxx_xx_xx_create_projects_table.php
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active')->index();
$table->decimal('budget', 10, 2)->nullable();
$table->timestamps();
});
}
<?php
// app/Models/Project.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'description', 'status', 'budget'];
protected function casts(): array
{
return ['budget' => 'decimal:2'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Add the inverse relationship to User (return $this->hasMany(Project::class); in a projects() method) and run php artisan migrate. If relationships are new to you, my guide to Eloquent relationships covers them with diagrams.
Token authentication with Sanctum
Sanctum offers two modes. For a first-party single-page app on the same domain, it can use Laravel’s normal cookie-based session. For mobile apps, other servers and third-party integrations, it issues API tokens. We’ll use tokens here. This is how the flow works:
Here is the endpoint that exchanges credentials for a token, following the approach in the Sanctum documentation. The abilities array scopes what the token may do:
<?php
// app/Http/Controllers/Api/TokenController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class TokenController extends Controller
{
public function store(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required', 'string', 'max:100'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken($request->device_name, ['projects:read', 'projects:write']);
return response()->json(['token' => $token->plainTextToken], 201);
}
public function destroy(Request $request)
{
// Log out: revoke the token used for this request.
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
}
Treat tokens like passwords
Only send tokens over HTTPS, never log them, and give each device or integration its own token so you can revoke one without breaking the others. Sanctum can also expire tokens: set expiration (in minutes) in config/sanctum.php.
Controllers, validation and API resources
Validation with form requests
The --requests flag generated StoreProjectRequest and UpdateProjectRequest. Rules live there, not in the controller, and the request also decides whether this user may perform the action:
<?php
// app/Http/Requests/StoreProjectRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->tokenCan('projects:write');
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'status' => ['sometimes', Rule::in(['active', 'paused', 'done'])],
'budget' => ['nullable', 'numeric', 'min:0'],
];
}
}
UpdateProjectRequest looks the same, with 'sometimes' added to each rule so clients can send only the fields they change. One gotcha: generated form requests start with authorize() returning false, which rejects every request with a 403 until you change it.
I go deeper into custom rules, error messages and authorization in Laravel form requests and validation done right.
Shaping JSON with API resources
Never return models straight from the database. An API resource decides exactly which fields leave your server and in what format, so adding a column never accidentally exposes it:
php artisan make:resource ProjectResource
<?php
// app/Http/Resources/ProjectResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'status' => $this->status,
'budget' => $this->budget,
'created_at' => $this->created_at->toIso8601String(),
'links' => [
'self' => route('projects.show', $this->resource),
],
];
}
}
The controller
With validation and formatting handled elsewhere, the controller is short. Every query is scoped to the authenticated user, so one customer can never read another’s projects:
<?php
// app/Http/Controllers/Api/ProjectController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Http\Request;
class ProjectController extends Controller
{
public function index(Request $request)
{
$projects = $request->user()->projects()
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
->latest()
->paginate(15);
return ProjectResource::collection($projects);
}
public function store(StoreProjectRequest $request)
{
$project = $request->user()->projects()->create($request->validated());
return (new ProjectResource($project))->response()->setStatusCode(201);
}
public function show(Request $request, Project $project)
{
abort_unless($project->user_id === $request->user()->id, 404);
return new ProjectResource($project);
}
public function update(UpdateProjectRequest $request, Project $project)
{
abort_unless($project->user_id === $request->user()->id, 404);
$project->update($request->validated());
return new ProjectResource($project);
}
public function destroy(Request $request, Project $project)
{
abort_unless($project->user_id === $request->user()->id, 404);
$project->delete();
return response()->noContent();
}
}
Returning 404 rather than 403 for someone else’s project is deliberate: it doesn’t confirm that the record exists. In larger apps, I move these ownership checks into a policy class and call Gate::authorize('update', $project), which keeps the rule in one place.
Pagination and the response shape
Passing a paginator to ProjectResource::collection() produces a consistent envelope with the records, navigation links and metadata. Clients never have to guess how to fetch the next page:
For very large tables, swap paginate() for cursorPaginate(). It skips the expensive total count and stays fast deep into the dataset, at the cost of not knowing the total number of pages.
Rate limiting and versioning
Define named rate limiters in AppServiceProvider. Here, authenticated users get 60 requests a minute, guests 20, and the login endpoint a strict 5 per minute per email and IP address to slow down password guessing:
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(60)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
}
Then put the routes together under a version prefix. Versioning from day one costs nothing, and when you need a breaking change later you can add v2 without breaking existing apps:
<?php
// routes/api.php
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TokenController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::post('tokens', [TokenController::class, 'store'])->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::delete('tokens/current', [TokenController::class, 'destroy']);
Route::apiResource('projects', ProjectController::class);
});
});
Route::apiResource registers the five JSON endpoints (index, store, show, update, destroy) without the HTML create and edit forms. When a client exceeds a limit, Laravel answers 429 Too Many Requests with a Retry-After header.
| Method | Endpoint | Purpose | Success |
|---|---|---|---|
| POST | /api/v1/tokens | Exchange credentials for a token | 201 |
| GET | /api/v1/projects | List projects (paginated, filter by status) | 200 |
| POST | /api/v1/projects | Create a project | 201 |
| GET | /api/v1/projects/{id} | Show one project | 200 |
| PUT/PATCH | /api/v1/projects/{id} | Update a project | 200 |
| DELETE | /api/v1/projects/{id} | Delete a project | 204 |
| DELETE | /api/v1/tokens/current | Revoke the current token | 204 |
CORS, errors and documentation
Three finishing touches separate an API that works on your machine from one other developers enjoy using.
- CORS: if a browser-based frontend on another domain calls your API, the browser checks CORS headers first. Laravel handles CORS through built-in middleware; publish the config with
php artisan config:publish corsand list your frontend’s exact origin inallowed_originsrather than*. - Consistent errors: clients should always send
Accept: application/json, so validation failures and exceptions come back as JSON with the right status code instead of an HTML error page. Never expose stack traces: keepAPP_DEBUG=falsein production. - Documentation: an API without docs gets used wrongly. At minimum, document each endpoint’s URL, required fields, example request and example response. Packages such as Scribe can generate this documentation from your routes and form requests, so it stays in sync with the code.
Testing the API
An API is a contract with other people’s code, so it deserves automated tests. Laravel supports both Pest and PHPUnit. These Pest feature tests cover the most important promises: guests are rejected, users see only their own projects, and invalid input returns field errors:
<?php
// tests/Feature/ProjectApiTest.php
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('rejects requests without a token', function () {
$this->getJson('/api/v1/projects')->assertUnauthorized();
});
it('lists only the projects of the authenticated user', function () {
$user = User::factory()->create();
Project::factory()->count(3)->for($user)->create();
Project::factory()->count(2)->create(); // Someone else's.
Sanctum::actingAs($user, ['projects:read']);
$this->getJson('/api/v1/projects')
->assertOk()
->assertJsonCount(3, 'data');
});
it('validates new projects', function () {
Sanctum::actingAs(User::factory()->create(), ['projects:write']);
$this->postJson('/api/v1/projects', ['budget' => -5])
->assertUnprocessable()
->assertJsonValidationErrors(['name', 'budget']);
});
Run them with php artisan test. For the factory to work, define fake values in database/factories/ProjectFactory.php (for example 'name' => fake()->words(3, true) and 'user_id' => User::factory()). Tests like these take seconds to run and catch the kind of regression (a missing ownership check, a renamed field) that would otherwise reach your users first.
Key takeaways
php artisan install:apisets up API routes and Sanctum in one step.- Use Sanctum tokens with abilities for mobile apps and integrations, and cookie auth for first-party SPAs.
- Keep controllers thin: form requests validate, API resources shape the JSON, and policies or ownership checks protect data.
- Paginate every list, rate limit every route (strictly on login), and version the API from the start.
- Feature tests with
Sanctum::actingAs()make the API contract something you can verify in seconds.
An API like this is a natural backend for a React or Next.js frontend, a mobile app or another business system. If you need one designed and built, or want an existing API reviewed for security and performance, get in touch. For heavier work such as imports and webhooks, pair it with Laravel queues so responses stay fast.





