🏠 Home / Hub

📡 Laravel 07 — API Resources & Sanctum

← Laravel Menu · ← Prev: Auth

1. API Routes Setup

# 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
];

8. API Endpoints Quick Reference

MethodEndpointAuthResponse
POST/api/auth/registerNo201 + user + token
POST/api/auth/loginNo200 + user + token
GET/api/auth/userBearer200 + user
POST/api/auth/logoutBearer200
GET/api/postsBearer200 + paginated list
POST/api/postsBearer201 + post
GET/api/posts/{id}Bearer200 + post
PUT/api/posts/{id}Bearer200 + post
DELETE/api/posts/{id}Bearer204 No Content

📌 Study Checklist