Building RESTful APIs

Day 3: Laravel Advanced - RESTful APIs, Sanctum Auth & Frontend Testing

🚀 Day 3: Laravel Advanced – RESTful APIs, Sanctum & Frontend Testing

📅 July 29, 2026  •  🎓 3-Day UiTM Workshop  •  👨‍🏫 En. Razi

2. Building RESTful APIs

We will build a robust API structure using Resource Controllers, Eloquent Relationships, and API Resources.

2.1 The API Controller

// app/Http/Controllers/Api/V1/PostController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;
use App\Http\Resources\PostResource;

class PostController extends Controller
{
    public function index()
    {
        return PostResource::collection(
            Post::with('user', 'category')->paginate(15)
        );
    }

    public function show(Post $post)
    {
        return new PostResource($post->load('user', 'category'));
    }
    
    // store, update, destroy methods omitted for brevity
}

2.2 The Data Models

Our models include auto-slugging, custom casts, and reusable query scopes.

// app/Models/Category.php
class Category extends Model
{
    protected $fillable = ['name', 'slug'];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    protected static function booted(): void
    {
        static::creating(function (Category $category) {
            if (empty($category->slug)) {
                $category->slug = Str::slug($category->name);
            }
        });
    }
}
// app/Models/Post.php
class Post extends Model
{
    protected $fillable = [
        'user_id', 'category_id', 'title', 'body', 'price', 'is_published'
    ];

    protected function casts(): array
    {
        return [
            'price' => 'decimal:2',
            'is_published' => 'boolean',
        ];
    }

    public function user(): BelongsTo { return $this->belongsTo(User::class); }
    public function category(): BelongsTo { return $this->belongsTo(Category::class); }

    public function scopePublished(Builder $query): Builder
    {
        return $query->where('is_published', true);
    }

    public function scopeInCategory(Builder $query, string $slug): Builder
    {
        return $query->whereHas('category', fn ($q) => $q->where('slug', $slug));
    }
}
💡 Auto-Slug Generation: The booted() method intercepts the creating event to automatically generate a URL-friendly slug from the category name, saving API consumers from having to supply one manually.

3. Database Migrations

Ensure your database schema supports foreign keys and proper data types.

// database/migrations/xxxx_xx_xx_create_posts_table.php
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
        $table->string('title');
        $table->text('body');
        $table->decimal('price', 8, 2)->default(0);
        $table->boolean('is_published')->default(false);
        $table->timestamps();
    });
}

4. Tinker Data Seeding & cURL Testing

4.1 Seeding via Tinker

php artisan tinker

# 1. Create User
$user = App\Models\User::create([
    'name' => 'Bob',
    'email' => 'bob@example.com',
    'password' => bcrypt('password'),
]);

# 2. Create Categories (slug auto-generates!)
$laravel = App\Models\Category::create(['name' => 'Laravel']);

# 3. Create Post via Relationship (user_id assigned automatically)
$post = $user->posts()->create([
    'category_id' => $laravel->id,
    'title' => 'Introduction to Eloquent',
    'body' => 'Eloquent makes database operations easier.',
    'price' => 19.99, # Note: Pass 19.99, NOT 1999, due to decimal cast
    'is_published' => true,
]);

4.2 Testing the API with cURL

# Test GET /api/v1/posts (Index)
curl http://127.0.0.1:8000/api/v1/posts

# Test GET /api/v1/posts/1 (Show)
curl http://127.0.0.1:8000/api/v1/posts/1

# Test 404 Not Found for non-existent post
curl -i http://127.0.0.1:8000/api/v1/posts/999
✅ Price Cast Verification:
Running curl ... | grep price should return "price": 19.99 (a JSON number), confirming that the decimal:2 cast combined with the API Resource is formatting the output correctly, rather than returning a raw string like "19.99".

5. Authentication & Authorization (Sanctum)

Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.

5.1 User Model Setup

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
    
    // ... fillable, hidden, casts ...
    
    public function isAdmin(): bool { return $this->role === 'admin'; }
}

5.2 AuthController

// app/Http/Controllers/Api/V1/AuthController.php
public function login(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 response()->json([
        'token' => $user->createToken('api-token')->plainTextToken,
    ]);
}

public function logout(Request $request)
{
    // Revoke ONLY the current token, keeping other devices logged in
    $request->user()->currentAccessToken()->delete();
    return response()->json(['message' => 'Logged out successfully']);
}

5.3 Protected Routes

// routes/api.php
// Public routes
Route::prefix('v1')->group(function () {
    Route::post('/register', [AuthController::class, 'register']);
    Route::post(<002f>'/login', [AuthController::class, 'login']);
});

// Protected routes
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::apiResource('posts', PostController::class);
});

5.4 Testing Sanctum via cURL (PowerShell)

# 1. Login and extract token
$response = curl.exe -s -X POST http://127.0.0.1:8000/api/login `
  -H "Content-Type: application/json" `
  -H "Accept: application/json" `
  -d '{"email": "bob@example.com", "password": "password"}'
$TOKEN = ($response | ConvertFrom-Json).token

# 2. Access protected route using the Bearer token
curl -i http://127.0.0.1:8000/api/v1/posts `
  -H "Authorization: Bearer $TOKEN" `
  -H "Accept: application/json"

6. HTML5 Frontend API Tester

To test the API visually without Postman, you can use the following standalone HTML/JS snippet. Paste this into CodePen or save it as an .html file.

⚠️ CORS Note: Ensure your Laravel app has CORS enabled for your frontend origin. Update config/cors.php to allow your CodePen or local file origin, and ensure the API Base URL points to your running Laravel server (e.g., http://127.0.0.1:8000/api/v1).
<!-- Laravel Sanctum API Tester (Paste into CodePen HTML) -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Laravel Sanctum API Tester</title>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
    <style>
        body { background: #f4f6f9; }
        .card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
        .token-badge { font-family: monospace; font-size: .75rem; word-break: break-all; }
        .post-card { border-left: 4px solid #6366f1; }
        #logArea { font-family: monospace; font-size: .8rem; max-height: 220px; overflow-y: auto; background: #0f172a; color: #a3e635; padding: .75rem; border-radius: .375rem; }
        .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; }
        .status-dot.online { background: #22c55e; }
        .status-dot.offline { background: #ef4444; }
    </style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark mb-4">
    <div class="container">
        <span class="navbar-brand"><i class="fa-solid fa-shield-halved"></i> Laravel Sanctum API Tester</span>
        <span id="authStatus" class="text-white-50 small">
            <span class="status-dot offline" id="statusDot"></span> Not logged in
        </span>
    </div>
</nav>
<div class="container pb-5">
    <div class="card mb-4">
        <div class="card-body">
            <label class="form-label fw-semibold mb-1"><i class="fa-solid fa-server me-1"></i> API Base URL</label>
            <div class="input-group">
                <input type="text" id="apiBase" class="form-control" value="http://127.0.0.1:8000/api/v1">
                <button class="btn btn-outline-secondary" id="saveBaseBtn"><i class="fa-solid fa-check"></i> Set</button>
            </div>
        </div>
    </div>
    <div class="row g-4">
        <!-- Register -->
        <div class="col-md-6">
            <div class="card h-100">
                <div class="card-body">
                    <h5 class="card-title"><i class="fa-solid fa-user-plus me-2 text-primary"></i> Register</h5>
                    <form id="registerForm">
                        <div class="mb-2"><label class="form-label">Name</label><input type="text" class="form-control" id="regName" required></div>
                        <div class="mb-2"><label class="form-label">Email</label><input type="email" class="form-control" id="regEmail" required></div>
                        <div class="mb-2"><label class="form-label">Password</label><input type="password" class="form-control" id="regPassword" required minlength="8"></div>
                        <div class="mb-3"><label class="form-label">Confirm Password</label><input type="password" class="form-control" id="regPasswordConfirm" required minlength="8"></div>
                        <button type="submit" class="btn btn-primary w-100"><i class="fa-solid fa-user-plus me-1"></i> Register</button>
                    </form>
                </div>
            </div>
        </div>
        <!-- Login -->
        <div class="col-md-6">
            <div class="card h-100">
                <div class="card-body">
                    <h5 class="card-title"><i class="fa-solid fa-right-to-bracket me-2 text-success"></i> Login</h5>
                    <form id="loginForm">
                        <div class="mb-2"><label class="form-label">Email</label><input type="email" class="form-control" id="loginEmail" required></div>
                        <div class="mb-3"><label class="form-label">Password</label><input type="password" class="form-control" id="loginPassword" required></div>
                        <button type="submit" class="btn btn-success w-100"><i class="fa-solid fa-right-to-bracket me-1"></i> Login</button>
                    </form>
                    <div id="tokenBox" class="mt-3 d-none">
                        <div class="alert alert-success py-2 mb-2">
                            <div class="d-flex justify-content-between align-items-center">
                                <strong class="small"><i class="fa-solid fa-key me-1"></i> Token</strong>
                                <button class="btn btn-sm btn-outline-danger" id="logoutBtn"><i class="fa-solid fa-right-from-bracket"></i> Logout</button>
                            </div>
                            <div class="token-badge mt-1" id="tokenDisplay"></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <!-- Posts -->
    <div class="card mt-4">
        <div class="card-body">
            <div class="d-flex justify-content-between align-items-center mb-3">
                <h5 class="card-title mb-0"><i class="fa-solid fa-newspaper me-2 text-warning"></i> Posts</h5>
                <button class="btn btn-outline-primary btn-sm" id="loadPostsBtn"><i class="fa-solid fa-arrows-rotate me-1"></i> Load Posts</button>
            </div>
            <div id="postsAuthNote" class="alert alert-secondary py-2 small d-none">
                <i class="fa-solid fa-lock me-1"></i> This endpoint requires a token. Login first.
            </div>
            <div id="postsList" class="row g-3"></div>
        </div>
    </div>
    <!-- Log -->
    <div class="card mt-4">
        <div class="card-body">
            <div class="d-flex justify-content-between align-items-center mb-2">
                <h6 class="mb-0"><i class="fa-solid fa-terminal me-2"></i> Request Log</h6>
                <button class="btn btn-sm btn-outline-secondary" id="clearLogBtn">Clear</button>
            </div>
            <div id="logArea">Ready.</div>
        </div>
    </div>
</div>
<script>
    let API_BASE = document.getElementById('apiBase').value.trim();
    let authToken = null;

    const log = (msg, type = 'info') => {
        const el = document.getElementById('logArea');
        const time = new Date().toLocaleTimeString();
        const color = type === 'error' ? '#f87171' : type === 'success' ? '#4ade80' : '#a3e635';
        el.innerHTML += `<div style="color:${color}">[${time}] ${msg}</div>`;
        el.scrollTop = el.scrollHeight;
    };

    document.getElementById('clearLogBtn').addEventListener('click', () => {
        document.getElementById('logArea').innerHTML = 'Ready.';
    });

    document.getElementById('saveBaseBtn').addEventListener('click', () => {
        API_BASE = document.getElementById('apiBase').value.trim().replace(/\/+$/, '');
        log(`API base set to <strong>${API_BASE}</strong>`);
    });

    function setAuthUI(loggedIn) {
        const dot = document.getElementById('statusDot');
        const status = document.getElementById('authStatus');
        const tokenBox = document.getElementById('tokenBox');
        const postsNote = document.getElementById('postsAuthNote');
        if (loggedIn) {
            dot.classList.remove('offline'); dot.classList.add('online');
            status.innerHTML = '<span class="status-dot online" id="statusDot"></span> Logged in';
            tokenBox.classList.remove('d-none');
            postsNote.classList.add('d-none');
        } else {
            dot.classList.remove('online'); dot.classList.add('offline');
            status.innerHTML = '<span class="status-dot offline" id="statusDot"></span> Not logged in';
            tokenBox.classList.add('d-none');
            postsNote.classList.remove('d-none');
        }
    }

    async function apiRequest(path, options = {}) {
        const url = `${API_BASE}${path}`;
        const headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}),
            ...(options.headers || {})
        };
        log(`→ ${options.method || 'GET'} ${url}`);
        try {
            const res = await fetch(url, { ...options, headers });
            let body = null;
            try { body = await res.json(); } catch (_) { }
            if (!res.ok) {
                log(`← ${res.status} ${res.statusText}: ${JSON.stringify(body)}`, 'error');
            } else {
                log(`← ${res.status} OK`, 'success');
            }
            return { ok: res.ok, status: res.status, body };
        } catch (err) {
            log(`← Network error: ${err.message}. Check CORS and server.`, 'error');
            return { ok: false, status: 0, body: null, error: err };
        }
    }

    document.getElementById('registerForm').addEventListener('submit', async (e) => {
        e.preventDefault();
        const name = document.getElementById('regName').value;
        const email = document.getElementById('regEmail').value;
        const password = document.getElementById('regPassword').value;
        const password_confirmation = document.getElementById('regPasswordConfirm').value;
        
        const { ok, body } = await apiRequest('/register', {
            method: 'POST',
            body: JSON.stringify({ name, email, password, password_confirmation })
        });
        if (ok && body?.token) {
            authToken = body.token;
            document.getElementById('tokenDisplay').textContent = authToken;
            document.getElementById('loginEmail').value = email;
            setAuthUI(true);
            alert('Registered! You are now logged in automatically.');
        } else if (body?.errors) {
            alert('Validation error:\n' + Object.values(body.errors).flat().join('\n'));
        }
    });

    document.getElementById('loginForm').addEventListener('submit', async (e) => {
        e.preventDefault();
        const email = document.getElementById('loginEmail').value;
        const password = document.getElementById('loginPassword').value;
        
        const { ok, body } = await apiRequest('/login', {
            method: 'POST',
            body: JSON.stringify({ email, password })
        });
        if (ok && body?.token) {
            authToken = body.token;
            document.getElementById('tokenDisplay').textContent = authToken;
            setAuthUI(true);
        } else if (body?.message) {
            alert(body.message);
        }
    });

    document.getElementById('logoutBtn').addEventListener('click', async () => {
        await apiRequest('/logout', { method: 'POST' });
        authToken = null;
        document.getElementById('postsList').innerHTML = '';
        setAuthUI(false);
    });

    document.getElementById('loadPostsBtn').addEventListener('click', async () => {
        const { ok, body } = await apiRequest('/posts', { method: 'GET' });
        const list = document.getElementById('postsList');
        list.innerHTML = '';
        if (!ok) {
            list.innerHTML = `<div class="col-12"><div class="alert alert-warning">Could not load posts. Are you logged in?</div></div>`;
            return;
        }
        const posts = body?.data || [];
        if (posts.length === 0) {
            list.innerHTML = `<div class="col-12 text-muted">No posts found.</div>`;
            return;
        }
        posts.forEach(post => {
            const col = document.createElement('div');
            col.className = 'col-md-6';
            col.innerHTML = `
                <div class="card post-card h-100">
                    <div class="card-body">
                        <h6 class="card-title mb-1">${escapeHtml(post.title || '')}</h6>
                        <p class="card-text small text-muted mb-2">
                            by ${escapeHtml(post.author || 'unknown')} · ${escapeHtml(post.category || 'uncategorized')}
                        </p>
                        <p class="card-text small">${escapeHtml((post.body || '').replace(/<[^>]*>/g, '').slice(0, 140))}...</p>
                        <span class="badge ${post.is_published ? 'bg-success' : 'bg-secondary'}">
                            ${post.is_published ? 'Published' : 'Draft'}
                        </span>
                        ${post.price != null ? `<span class="badge bg-light text-dark ms-1">$${post.price}</span>` : ''}
                    </div>
                </div>`;
            list.appendChild(col);
        });
    });

    function escapeHtml(str) {
        const div = document.createElement('div');
        div.textContent = str;
        return div.innerHTML;
    }
    setAuthUI(false);
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
</body>
</html>