# routes/api.php — prefixed with /api automatically, stateless (no session)
use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\AuthController;
// Public routes (no auth needed)
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
// Protected routes (require Sanctum token)
Route::middleware('auth:sanctum')->group(function () {
Route::get('/auth/user', [AuthController::class, 'me']);
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
Route::apiResource('categories', CategoryController::class);
});
# apiResource generates 5 routes (no create/edit — no forms needed):
# GET /api/posts → index
# POST /api/posts → store
# GET /api/posts/{post} → show
# PUT /api/posts/{post} → update
# DELETE /api/posts/{post} → destroy
# See all routes:
php artisan route:list --path=api
2. Install Laravel Sanctum
# Sanctum = token-based auth for SPA and Mobile apps
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate # creates personal_access_tokens table
# User model must use HasApiTokens trait:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
3. Auth Controller (Register / Login / Logout)
// app/Http/Controllers/Api/AuthController.php
class AuthController extends Controller
{
public function register(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8', 'confirmed'],
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
], 201);
}
public function login(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (!Auth::attempt($request->only('email', 'password'))) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$user = Auth::user();
$user->tokens()->delete(); // revoke old tokens on login
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
]);
}
public function me(Request $request)
{
return new UserResource($request->user());
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
4. API Resource (Response Transformer)
php artisan make:resource PostResource
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
// Include body only on detail view
'body' => $this->when($request->routeIs('*.show'), $this->body),
'published' => $this->published,
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
// Only include if relationship was loaded (no N+1)
'author' => new UserResource($this->whenLoaded('user')),
'category' => new CategoryResource($this->whenLoaded('category')),
];
}
}
// Usage:
return new PostResource($post); // single
return PostResource::collection($posts); // collection
return PostResource::collection($posts)->response()->setStatusCode(200);
5. API Controller — Full CRUD
// app/Http/Controllers/Api/PostController.php
class PostController extends Controller
{
public function index(Request $request)
{
$posts = Post::with(['user', 'category'])
->when($request->search, fn($q, $s) =>
$q->where('title', 'like', "%{$s}%")
)
->when($request->category_id, fn($q, $id) =>
$q->where('category_id', $id)
)
->latest()
->paginate($request->per_page ?? 15);
return PostResource::collection($posts);
}
public function store(Request $request)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'category_id' => ['nullable', 'exists:categories,id'],
'published' => ['boolean'],
]);
$data['user_id'] = auth()->id();
$data['slug'] = Str::slug($data['title']);
$post = Post::create($data);
$post->load(['user', 'category']);
return (new PostResource($post))->response()->setStatusCode(201);
}
public function show(Post $post)
{
$post->load(['user', 'category', 'tags']);
return new PostResource($post);
}
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
$data = $request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'body' => ['sometimes', 'string'],
'published' => ['boolean'],
]);
if (isset($data['title'])) {
$data['slug'] = Str::slug($data['title']);
}
$post->update($data);
return new PostResource($post->fresh(['user', 'category']));
}
public function destroy(Post $post)
{
$this->authorize('delete', $post);
$post->delete();
return response()->noContent(); // HTTP 204
}
}
6. JSON Response Formats
# Single resource
{ "data": { "id": 1, "title": "Hello", "author": {...} } }
# Paginated collection
{
"data": [ { "id": 1, ... }, { "id": 2, ... } ],
"links": {
"first": "/api/posts?page=1",
"last": "/api/posts?page=5",
"prev": null,
"next": "/api/posts?page=2"
},
"meta": {
"current_page": 1,
"total": 73,
"per_page": 15,
"last_page": 5
}
}
# Validation error (422)
{
"message": "The email field is required.",
"errors": {
"email": ["The email field is required."],
"password": ["The password must be at least 8 characters."]
}
}
# Test with curl:
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email":"admin@test.com","password":"password"}'
# Use Bearer token on protected routes:
curl http://localhost:8000/api/posts \
-H "Authorization: Bearer 1|yourTokenHere" \
-H "Accept: application/json"
7. CORS Configuration
# config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'http://localhost:5173', // Vue dev server
'https://your-frontend.com',
],
'allowed_headers' => ['*'],
'supports_credentials'=> false, // true only for cookie-based SPA auth
];