Kontrak sudah di tangan; sekarang kita bangun pintu masuknya. API tidak punya form login dan session cookie, jadi autentikasi berputar pada satu artefak: personal access token. Bagian ini memasang fondasi Sanctum, membuat tiga endpoint (register, login, logout), dan memastikan endpoint privat benar-benar menolak tamu dengan status 401.

Mendaftarkan Route API #

Aplikasi Laravel baru hanya memuat routes/web.php. File routes/api.php beserta paket Sanctum bisa dipasang sekaligus lewat satu perintah:

bash
php artisan install:api

Perintah tersebut menginstal Sanctum, menerbitkan migration personal_access_tokens, dan mendaftarkan file route API. Pada sandbox proyek ini Sanctum sudah terinstal, jadi kita cukup menambahkan parameter api pada bootstrap/app.php:

php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    // ...

Semua route dalam file ini otomatis mendapat prefix /api dan berjalan tanpa state session. Selanjutnya, model User memakai trait HasApiTokens supaya bisa menerbitkan dan memiliki token:

php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Dua route publik dan satu route privat didefinisikan di routes/api.php:

php
use App\Http\Controllers\AuthApiController;
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

// Endpoint publik: mendaftar akun baru dan menukar kredensial dengan token.
Route::post('/register', [AuthApiController::class, 'register']);
Route::post('/login', [AuthApiController::class, 'login']);

// Endpoint privat: butuh header Authorization: Bearer <token> Sanctum.
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthApiController::class, 'logout']);
    Route::apiResource('tasks', TaskController::class);
});

Middleware auth:sanctum membaca header Authorization, mencocokkan token dengan hash di tabel personal_access_tokens, lalu menempelkan user pemilik token pada request. Cara kerja pencocokannya efisien: Sanctum memecah token pada karakter |, memakai angka di depan untuk mencari baris token lewat primary key, lalu membandingkan hash dari sisa karakternya. Satu request hanya melakukan satu query pencarian — tidak ada iterasi seluruh tabel.

Tabel personal_access_tokens yang diterbitkan Sanctum menyimpan kolom tokenable_type dan tokenable_id (pemilik token, polymorphic), name (label seperti api), token (hash SHA-256), abilities (daftar izin), dan last_used_at. Kolom inilah yang membuat pencabutan token semudah menghapus satu baris.

Register dan Login #

AuthApiController::register() memvalidasi input, membuat user, lalu langsung menerbitkan token pertama:

php
public function register(Request $request): JsonResponse
{
    $credentials = $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
        'password' => ['required', 'string', 'min:8'],
    ]);

    $user = User::create([
        'name' => $credentials['name'],
        'email' => $credentials['email'],
        'password' => Hash::make($credentials['password']),
    ]);

    $token = $user->createToken('api')->plainTextToken;

    return response()->json([
        'user' => [
            'id' => $user->id,
            'name' => $user->name,
            'email' => $user->email,
        ],
        'token' => $token,
    ], 201);
}

Register memakai status 201 Created karena request-nya memunculkan resource baru (akun user beserta tokennya), sedangkan login cukup 200 OK karena tidak menciptakan apa pun di sisi data user. Panggilan Hash::make() di atas sebenarnya berjalan ganda dengan aman: cast 'password' => 'hashed' pada model User juga meng-hash nilai teks biasa secara otomatis. Memilih salah satu cukup — kita pertahankan bentuk eksplisit Hash::make() agar niat kode terbaca jelas tanpa bergantung pada cast.

Method login() hampir sama, hanya ia tidak membuat user baru melainkan memeriksa kredensial. Bila email tidak ditemukan atau Hash::check() gagal, kita melempar ValidationException::withMessages() — di route API, exception ini otomatis menjadi JSON 422. Berikut respons nyata dari tiga skenario tersebut:

text
POST /api/register {"name":"Budi","email":"[email protected]","password":"rahasia-kuat"}
HTTP 201
{
    "user": {
        "id": 1,
        "name": "Budi",
        "email": "[email protected]"
    },
    "token": "1|<nilai-token-acak>"
}
text
POST /api/login {"email":"[email protected]","password":"password-salah"}
HTTP 422
{
    "message": "Email atau kata sandi salah.",
    "errors": {
        "email": [
            "Email atau kata sandi salah."
        ]
    }
}
text
GET /api/tasks (tanpa header Authorization)
HTTP 401
{
    "message": "Unauthenticated."
}

Logout dan Pencabutan Token #

Logout pada API berarti mencabut token yang sedang dipakai, bukan menghapus sesi. currentAccessToken() mengembalikan baris token yang mengautentikasi request ini, lalu delete() menghapusnya:

php
public function logout(Request $request): JsonResponse
{
    $request->user()->currentAccessToken()->delete();

    return response()->json([
        'message' => 'Logout berhasil.',
    ]);
}

Respons 200 dengan pesan sukses keluar, dan percobaan memakai token yang sama setelahnya ditolak:

text
POST /api/logout (dengan token Budi)
HTTP 200
{
    "message": "Logout berhasil."
}

GET /api/tasks (token lama, setelah logout)
HTTP 401
{
    "message": "Unauthenticated."
}

Karena yang dicabut hanya baris token yang aktif pada request tersebut, perangkat lain milik user yang sama tetap bisa bekerja — setiap login menerbitkan token barunya sendiri, sehingga logout di satu perangkat tidak memaksa logout di perangkat lain. Bila suatu saat kita ingin mencabut semuanya sekaligus, Sanctum menyediakan $user->tokens()->delete().

Pintu masuk kini berfungsi, tetapi belum ada resource untuk dilindungi. Bagian 3 membangun CRUD tugas yang berada di balik middleware tersebut.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

app/Http/Controllers/AuthApiController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthApiController extends Controller
{
    /** Daftar akun baru lalu langsung terbitkan token pertama. */
    public function register(Request $request): JsonResponse
    {
        $credentials = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'string', 'min:8'],
        ]);

        $user = User::create([
            'name' => $credentials['name'],
            'email' => $credentials['email'],
            'password' => Hash::make($credentials['password']),
        ]);

        $token = $user->createToken('api')->plainTextToken;

        return response()->json([
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
            'token' => $token,
        ], 201);
    }

    /** Tukar email dan kata sandi dengan personal access token. */
    public function login(Request $request): JsonResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'string', 'email'],
            'password' => ['required', 'string'],
        ]);

        $user = User::where('email', $credentials['email'])->first();

        if ($user === null || ! Hash::check($credentials['password'], $user->password)) {
            throw ValidationException::withMessages([
                'email' => 'Email atau kata sandi salah.',
            ]);
        }

        $token = $user->createToken('api')->plainTextToken;

        return response()->json([
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
            'token' => $token,
        ]);
    }

    /** Cabut token yang dipakai request ini; token lain milik pengguna tetap hidup. */
    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Logout berhasil.',
        ]);
    }
}

app/Http/Controllers/Controller.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Controllers/TaskController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskCollection;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;

class TaskController extends Controller
{
    /** Daftar tugas milik pengguna, dengan filter status/priority dan pagination. */
    public function index(Request $request): TaskCollection
    {
        $filters = $request->validate([
            'status' => ['nullable', Rule::in(Task::STATUSES)],
            'priority' => ['nullable', Rule::in(Task::PRIORITIES)],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $tasks = $request->user()->tasks()
            ->when($filters['status'] ?? null, fn ($query, $status) => $query->status($status))
            ->when($filters['priority'] ?? null, fn ($query, $priority) => $query->priority($priority))
            ->latest('id')
            ->paginate($filters['per_page'] ?? 15)
            ->appends(array_filter($filters));

        return new TaskCollection($tasks);
    }

    /** Simpan tugas baru milik pengguna yang terautentikasi. */
    public function store(StoreTaskRequest $request): JsonResponse
    {
        $data = array_merge([
            'status' => 'pending',
            'priority' => 'medium',
        ], $request->validated());

        $task = $request->user()->tasks()->create($data);

        return (new TaskResource($task))
            ->response()
            ->setStatusCode(201);
    }

    /** Tampilkan satu tugas milik pengguna. */
    public function show(Request $request, Task $task): TaskResource
    {
        // 404, bukan 403, agar keberadaan tugas milik orang lain tidak bocor.
        abort_unless($task->user_id === $request->user()->id, 404);

        return new TaskResource($task);
    }

    /** Ubah sebagian atau seluruh field tugas. */
    public function update(UpdateTaskRequest $request, Task $task): TaskResource
    {
        abort_unless($task->user_id === $request->user()->id, 404);

        $task->update($request->validated());

        return new TaskResource($task->refresh());
    }

    /** Hapus tugas; respons 204 tanpa body. */
    public function destroy(Request $request, Task $task): Response
    {
        abort_unless($task->user_id === $request->user()->id, 404);

        $task->delete();

        return response()->noContent();
    }
}

app/Http/Requests/StoreTaskRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'status' => ['sometimes', Rule::in(Task::STATUSES)],
            'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
            'due_date' => ['nullable', 'date'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Judul tugas wajib diisi.',
            'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
            'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
        ];
    }
}

app/Http/Requests/UpdateTaskRequest.php

Unduh berkas
php
<?php

namespace App\Http\Requests;

use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /** Semua field bersifat partial: hanya field yang dikirim yang divalidasi. */
    public function rules(): array
    {
        return [
            'title' => ['sometimes', 'required', 'string', 'max:255'],
            'description' => ['sometimes', 'nullable', 'string'],
            'status' => ['sometimes', Rule::in(Task::STATUSES)],
            'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
            'due_date' => ['sometimes', 'nullable', 'date'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Judul tugas wajib diisi.',
            'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
            'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
        ];
    }
}

app/Http/Resources/TaskCollection.php

Unduh berkas
php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

/** @extends ResourceCollection<\App\Http\Resources\TaskResource> */
class TaskCollection extends ResourceCollection
{
    /** Setiap item dikonversi lewat TaskResource. */
    public $collects = TaskResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
        ];
    }

    /** Meta pagination ringkas: hanya field yang dipakai klien. */
    public function paginationInformation(Request $request, array $paginated, array $default): array
    {
        return [
            'links' => [
                'first' => $paginated['first_page_url'],
                'last' => $paginated['last_page_url'],
                'next' => $paginated['next_page_url'],
                'prev' => $paginated['prev_page_url'],
            ],
            'meta' => [
                'current_page' => $paginated['current_page'],
                'from' => $paginated['from'],
                'last_page' => $paginated['last_page'],
                'per_page' => $paginated['per_page'],
                'to' => $paginated['to'],
                'total' => $paginated['total'],
            ],
        ];
    }
}

app/Http/Resources/TaskResource.php

Unduh berkas
php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/** @mixin \App\Models\Task */
class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'status' => $this->status,
            'priority' => $this->priority,
            'due_date' => $this->due_date?->format('Y-m-d'),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}

app/Models/Task.php

Unduh berkas
php
<?php

namespace App\Models;

use Database\Factories\TaskFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    /** @use HasFactory<TaskFactory> */
    use HasFactory;

    /** Nilai status yang diizinkan kolom tasks.status. */
    public const STATUSES = ['pending', 'in_progress', 'done'];

    /** Nilai priority yang diizinkan kolom tasks.priority. */
    public const PRIORITIES = ['low', 'medium', 'high'];

    protected $fillable = [
        'title',
        'description',
        'status',
        'priority',
        'due_date',
    ];

    protected function casts(): array
    {
        return [
            'due_date' => 'date',
        ];
    }

    /** Tugas selalu dimiliki satu pengguna. */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /** Batasi query pada tugas ber-status tertentu, mis. ->status('done'). */
    public function scopeStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }

    /** Batasi query pada tugas berprioritas tertentu, mis. ->priority('high'). */
    public function scopePriority(Builder $query, string $priority): Builder
    {
        return $query->where('priority', $priority);
    }
}

app/Models/User.php

Unduh berkas
php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    /** Semua tugas milik pengguna. */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

database/factories/TaskFactory.php

Unduh berkas
php
<?php

namespace Database\Factories;

use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Task> */
class TaskFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => 'Belajar Laravel',
            'description' => 'Membaca dokumentasi dan berlatih.',
            'status' => 'pending',
            'priority' => 'medium',
            'due_date' => null,
        ];
    }
}

database/factories/UserFactory.php

Unduh berkas
php
<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

/**
 * @extends Factory<User>
 */
class UserFactory extends Factory
{
    /**
     * The current password being used by the factory.
     */
    protected static ?string $password;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'password' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
        ];
    }

    /**
     * Indicate that the model's email address should be unverified.
     */
    public function unverified(): static
    {
        return $this->state(fn (array $attributes) => [
            'email_verified_at' => null,
        ]);
    }
}

database/migrations/0001_01_01_000000_create_users_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
        Schema::dropIfExists('password_reset_tokens');
        Schema::dropIfExists('sessions');
    }
};

database/migrations/0001_01_01_000001_create_cache_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('cache', function (Blueprint $table) {
            $table->string('key')->primary();
            $table->mediumText('value');
            $table->bigInteger('expiration')->index();
        });

        Schema::create('cache_locks', function (Blueprint $table) {
            $table->string('key')->primary();
            $table->string('owner');
            $table->bigInteger('expiration')->index();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('cache');
        Schema::dropIfExists('cache_locks');
    }
};

database/migrations/0001_01_01_000002_create_jobs_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('jobs', function (Blueprint $table) {
            $table->id();
            $table->string('queue')->index();
            $table->longText('payload');
            $table->unsignedSmallInteger('attempts');
            $table->unsignedInteger('reserved_at')->nullable();
            $table->unsignedInteger('available_at');
            $table->unsignedInteger('created_at');
        });

        Schema::create('job_batches', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->string('name');
            $table->integer('total_jobs');
            $table->integer('pending_jobs');
            $table->integer('failed_jobs');
            $table->longText('failed_job_ids');
            $table->mediumText('options')->nullable();
            $table->integer('cancelled_at')->nullable();
            $table->integer('created_at');
            $table->integer('finished_at')->nullable();
        });

        Schema::create('failed_jobs', function (Blueprint $table) {
            $table->id();
            $table->string('uuid')->unique();
            $table->string('connection');
            $table->string('queue');
            $table->longText('payload');
            $table->longText('exception');
            $table->timestamp('failed_at')->useCurrent();

            $table->index(['connection', 'queue', 'failed_at']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('jobs');
        Schema::dropIfExists('job_batches');
        Schema::dropIfExists('failed_jobs');
    }
};

database/migrations/2026_10_13_153049_create_personal_access_tokens_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('personal_access_tokens', function (Blueprint $table) {
            $table->id();
            $table->morphs('tokenable');
            $table->text('name');
            $table->string('token', 64)->unique();
            $table->text('abilities')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamp('expires_at')->nullable()->index();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('personal_access_tokens');
    }
};

database/migrations/2026_10_14_000001_create_tasks_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->text('description')->nullable();
            $table->string('status')->default('pending');
            $table->string('priority')->default('medium');
            $table->date('due_date')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

routes/api.php

Unduh berkas
php
<?php

use App\Http\Controllers\AuthApiController;
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

// Endpoint publik: mendaftar akun baru dan menukar kredensial dengan token.
Route::post('/register', [AuthApiController::class, 'register']);
Route::post('/login', [AuthApiController::class, 'login']);

// Endpoint privat: butuh header Authorization: Bearer <token> Sanctum.
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthApiController::class, 'logout']);
    Route::apiResource('tasks', TaskController::class);
});

tests/Feature/AuthApiTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_register_mengembalikan_token_dan_user_201(): void
    {
        $response = $this->postJson('/api/register', [
            'name' => 'Budi',
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response->assertStatus(201)
            ->assertJsonStructure([
                'user' => ['id', 'name', 'email'],
                'token',
            ]);

        $this->assertDatabaseHas('users', [
            'email' => '[email protected]',
        ]);

        // Token yang diterbitkan harus bisa dipakai mengakses endpoint privat.
        $token = $response->json('token');
        $this->withToken($token)
            ->getJson('/api/tasks')
            ->assertStatus(200)
            ->assertJsonPath('meta.total', 0);
    }

    public function test_register_dengan_email_duplikat_mengembalikan_422(): void
    {
        User::factory()->create(['email' => '[email protected]']);

        $this->postJson('/api/register', [
            'name' => 'Budi Dua',
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ])->assertStatus(422)->assertJsonValidationErrors('email');
    }

    public function test_register_dengan_input_kosong_mengembalikan_422(): void
    {
        $this->postJson('/api/register', [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['name', 'email', 'password']);
    }

    public function test_login_mengembalikan_token_untuk_kredensial_benar(): void
    {
        $user = User::factory()->create([
            'name' => 'Siti',
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response = $this->postJson('/api/login', [
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $response->assertStatus(200)
            ->assertJsonPath('user.email', '[email protected]')
            ->assertJsonStructure(['user', 'token']);

        $this->withToken($response->json('token'))
            ->getJson('/api/tasks')
            ->assertStatus(200);
    }

    public function test_login_dengan_kata_sandi_salah_mengembalikan_422(): void
    {
        User::factory()->create([
            'email' => '[email protected]',
            'password' => 'rahasia-kuat',
        ]);

        $this->postJson('/api/login', [
            'email' => '[email protected]',
            'password' => 'kata-sandi-salah',
        ])->assertStatus(422)->assertJsonValidationErrors('email');
    }

    public function test_logout_mencabut_token_aktif(): void
    {
        $user = User::factory()->create();
        $token = $user->createToken('api')->plainTextToken;

        $this->withToken($token)
            ->postJson('/api/logout')
            ->assertStatus(200)
            ->assertJsonPath('message', 'Logout berhasil.');

        $this->assertDatabaseCount('personal_access_tokens', 0);

        // Token lama sudah dicabut: request berikutnya ditolak 401.
        // Guard auth di-cache per request dalam satu proses; kita reset
        // cache tersebut agar request kedua benar-benar memeriksa token.
        $this->app->make('auth')->forgetGuards();

        $this->withToken($token)
            ->getJson('/api/tasks')
            ->assertStatus(401);
    }

    public function test_akses_endpoint_privat_tanpa_token_mengembalikan_401(): void
    {
        $this->getJson('/api/tasks')->assertStatus(401);
    }
}

tests/Feature/TaskApiTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class TaskApiTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

    private string $token;

    protected function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->create([
            'name' => 'Budi',
            'email' => '[email protected]',
        ]);
        $this->token = $this->user->createToken('api')->plainTextToken;
    }

    private function actingAsApi(): self
    {
        return $this->withToken($this->token);
    }

    public function test_index_hanya_menampilkan_tugas_milik_sendiri(): void
    {
        Task::factory()->count(2)->create(['user_id' => $this->user->id]);
        Task::factory()->create(); // tugas milik pengguna lain

        $this->actingAsApi()
            ->getJson('/api/tasks')
            ->assertStatus(200)
            ->assertJsonCount(2, 'data')
            ->assertJsonPath('meta.total', 2);
    }

    public function test_semua_endpoint_tugas_menolak_tamu_dengan_401(): void
    {
        $task = Task::factory()->create(['user_id' => $this->user->id]);

        $this->getJson('/api/tasks')->assertStatus(401);
        $this->postJson('/api/tasks', [])->assertStatus(401);
        $this->getJson("/api/tasks/{$task->id}")->assertStatus(401);
        $this->putJson("/api/tasks/{$task->id}", [])->assertStatus(401);
        $this->deleteJson("/api/tasks/{$task->id}")->assertStatus(401);
    }

    public function test_store_menyimpan_tugas_dengan_default_dan_mengembalikan_201(): void
    {
        $response = $this->actingAsApi()
            ->postJson('/api/tasks', [
                'title' => 'Belajar Sanctum',
                'description' => 'Membaca dokumentasi API authentication.',
                'due_date' => '2026-01-20',
            ]);

        $response->assertStatus(201)
            ->assertJsonPath('data.title', 'Belajar Sanctum')
            ->assertJsonPath('data.status', 'pending')
            ->assertJsonPath('data.priority', 'medium')
            ->assertJsonPath('data.due_date', '2026-01-20');

        $this->assertDatabaseHas('tasks', [
            'user_id' => $this->user->id,
            'title' => 'Belajar Sanctum',
        ]);
    }

    public function test_store_dengan_status_dan_prioritas_eksplisit(): void
    {
        $this->actingAsApi()
            ->postJson('/api/tasks', [
                'title' => 'Deploy ke production',
                'status' => 'in_progress',
                'priority' => 'high',
            ])
            ->assertStatus(201)
            ->assertJsonPath('data.status', 'in_progress')
            ->assertJsonPath('data.priority', 'high');
    }

    public function test_store_dengan_input_tidak_valid_mengembalikan_422(): void
    {
        $this->actingAsApi()
            ->postJson('/api/tasks', [
                'title' => '',
                'status' => 'selesai',
                'priority' => 'tinggi',
                'due_date' => 'bukan-tanggal',
            ])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['title', 'status', 'priority', 'due_date']);

        $this->assertDatabaseCount('tasks', 0);
    }

    public function test_show_mengembalikan_detail_tugas(): void
    {
        $task = Task::factory()->create([
            'user_id' => $this->user->id,
            'title' => 'Menulis laporan',
        ]);

        $this->actingAsApi()
            ->getJson("/api/tasks/{$task->id}")
            ->assertStatus(200)
            ->assertJsonPath('data.title', 'Menulis laporan')
            ->assertJsonStructure([
                'data' => [
                    'id', 'title', 'description', 'status',
                    'priority', 'due_date', 'created_at', 'updated_at',
                ],
            ]);
    }

    public function test_tugas_milik_pengguna_lain_mengembalikan_404(): void
    {
        $task = Task::factory()->create(); // milik pengguna lain

        $this->actingAsApi()->getJson("/api/tasks/{$task->id}")->assertStatus(404);
        $this->actingAsApi()
            ->putJson("/api/tasks/{$task->id}", ['title' => 'Perubahan'])
            ->assertStatus(404);
        $this->actingAsApi()->deleteJson("/api/tasks/{$task->id}")->assertStatus(404);
    }

    public function test_tugas_yang_tidak_ada_mengembalikan_404(): void
    {
        $this->actingAsApi()->getJson('/api/tasks/999')->assertStatus(404);
    }

    public function test_update_mengubah_field_yang_dikirim_saja(): void
    {
        $task = Task::factory()->create([
            'user_id' => $this->user->id,
            'title' => 'Belajar Sanctum',
            'priority' => 'medium',
        ]);

        $this->actingAsApi()
            ->putJson("/api/tasks/{$task->id}", [
                'status' => 'done',
                'priority' => 'high',
            ])
            ->assertStatus(200)
            ->assertJsonPath('data.status', 'done')
            ->assertJsonPath('data.priority', 'high')
            ->assertJsonPath('data.title', 'Belajar Sanctum');
    }

    public function test_update_dengan_status_tidak_valid_mengembalikan_422(): void
    {
        $task = Task::factory()->create(['user_id' => $this->user->id]);

        $this->actingAsApi()
            ->putJson("/api/tasks/{$task->id}", ['status' => 'batal'])
            ->assertStatus(422)
            ->assertJsonValidationErrors('status');

        $this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'pending']);
    }

    public function test_destroy_menghapus_tugas_dan_mengembalikan_204(): void
    {
        $task = Task::factory()->create(['user_id' => $this->user->id]);

        $this->actingAsApi()
            ->deleteJson("/api/tasks/{$task->id}")
            ->assertStatus(204)
            ->assertNoContent();

        $this->assertDatabaseMissing('tasks', ['id' => $task->id]);
    }

    public function test_filter_status_dan_priority(): void
    {
        Task::factory()->create(['user_id' => $this->user->id, 'status' => 'pending', 'priority' => 'low']);
        Task::factory()->create(['user_id' => $this->user->id, 'status' => 'in_progress', 'priority' => 'high']);
        Task::factory()->create(['user_id' => $this->user->id, 'status' => 'done', 'priority' => 'high']);

        $this->actingAsApi()
            ->getJson('/api/tasks?status=in_progress')
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.status', 'in_progress');

        $this->actingAsApi()
            ->getJson('/api/tasks?priority=high')
            ->assertJsonPath('meta.total', 2);

        $this->actingAsApi()
            ->getJson('/api/tasks?status=done&priority=high')
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.status', 'done')
            ->assertJsonPath('data.0.priority', 'high');
    }

    public function test_filter_dengan_nilai_tidak_valid_mengembalikan_422(): void
    {
        $this->actingAsApi()
            ->getJson('/api/tasks?status=unknown')
            ->assertStatus(422)
            ->assertJsonValidationErrors('status');
    }

    public function test_pagination_membagi_tugas_per_halaman(): void
    {
        Task::factory()->count(20)->create(['user_id' => $this->user->id]);

        $response = $this->actingAsApi()
            ->getJson('/api/tasks?per_page=8&page=2')
            ->assertStatus(200)
            ->assertJsonCount(8, 'data')
            ->assertJsonPath('meta.current_page', 2)
            ->assertJsonPath('meta.last_page', 3)
            ->assertJsonPath('meta.per_page', 8)
            ->assertJsonPath('meta.total', 20)
            ->assertJsonPath('meta.from', 9)
            ->assertJsonPath('meta.to', 16);

        $base = rtrim(config('app.url'), '/');

        $this->assertSame(
            "{$base}/api/tasks?per_page=8&page=3",
            $response->json('links.next'),
        );
        $this->assertSame(
            "{$base}/api/tasks?per_page=8&page=1",
            $response->json('links.prev'),
        );
    }
}

tests/TestCase.php

Unduh berkas
php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    //
}