Building RESTful APIs
🚀 Day 3: Laravel Advanced – RESTful APIs, Sanctum & Frontend Testing
📑 Table of Contents
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
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>