Daftar tugas pengguna bisa mencapai ratusan baris, dan mengirim semuanya dalam satu respons adalah resep API yang lambat. Solusinya dua lapis: filtering memangkas data yang tidak relevan, dan pagination membagi sisanya menjadi halaman. Bagian ini menambahkan keduanya pada GET /api/tasks dengan query scope Eloquent, lalu merapikan metadata halaman lewat TaskCollection. Keduanya kita bahas satu bab karena saling bergantung: meta.total pada pagination dihitung setelah filter diterapkan, sehingga angka yang dibaca klien selalu jumlah tugas yang lolos penyaringan, bukan jumlah seluruh tabel.

Query Scope di Model #

Filter didefinisikan sebagai local scope di model Task, jadi logika penyaringan bisa dipakai ulang dari controller mana pun:

php
/** 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);
}

Method bernama scopeStatus otomatis bisa dipanggil sebagai ->status('done') pada query builder. Parameternya datang dari query string setelah melewati validasi Rule::in(Task::STATUSES), jadi nilai yang tidak sah sudah tertahan di depan pintu dengan 422.

Menyaring di Controller #

TaskController::index() memvalidasi query string, lalu merangkai scope lewat when():

php
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);
}

Method when($value, $callback) hanya menjalankan closure bila $value tidak kosong — filter yang tidak diminta klien tidak menambah klausa WHERE. Query dasarnya $request->user()->tasks() mengunci hasil pada milik pemilik token, dan per_page dibatasi max:100 agar klien tidak meminta seluruh tabel dalam satu halaman. Urutan latest('id') kita pilih daripada latest('created_at') karena ID yang selalu naik menghindari tugas ber-tanggal sama bertukar posisi antar halaman.

Validasi pada query string mungkin terdengar berlebihan — banyak API memilih mengabaikan parameter yang tidak dikenal. Kita justru menolaknya dengan 422 karena filter yang salah ketik (?status=inprogres) yang dibungkam akan menampilkan daftar kosong palsu, dan pengguna menyalahkan datanya sendiri. Pesan 422 dari envelope Bagian 4 langsung memberi tahu klien nilai apa saja yang sah. Respons nyata untuk filter tunggal dan filter gabungan:

text
GET /api/tasks?status=in_progress (token Budi — 2 dari 7 tugas)
HTTP 200
{
    "data": [
        {
            "id": 6,
            "title": "Refactor modul autentikasi",
            "description": null,
            "status": "in_progress",
            "priority": "medium",
            "due_date": null,
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-01-10T09:00:00+00:00"
        },
        {
            "id": 2,
            "title": "Deploy ke production",
            "description": null,
            "status": "in_progress",
            "priority": "high",
            "due_date": "2026-01-18",
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-01-10T09:00:00+00:00"
        }
    ],
    "links": {
        "first": "http://localhost/api/tasks?status=in_progress&page=1",
        "last": "http://localhost/api/tasks?status=in_progress&page=1",
        "next": null,
        "prev": null
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 1,
        "per_page": 15,
        "to": 2,
        "total": 2
    }
}

Gabungan ?status=done&priority=high pada data yang sama mengembalikan tepat satu tugas dengan meta.total bernilai 1 — filter bekerja sebagai AND, bukan OR.

Pagination Meta di TaskCollection #

Envelope links dan meta di atas bukan kerja tangan index() — paginate() menghasilkan LengthAwarePaginator, dan TaskCollection menyerahkannya ke klien lewat method paginationInformation():

php
/** @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'],
            ],
        ];
    }
}

Versi bawaan Laravel mengirim meta yang lebih bertele-tele; menimpanya seperti di atas membuat klien hanya menerima enam angka yang benar-benar dipakai. Enam angka itu punya peran masing-masing: total adalah jumlah seluruh tugas yang cocok filter, last_page berapa kali klien harus melanjutkan halaman, from dan to posisi item pertama-terakhir pada halaman ini, current_page posisi sekarang, dan per_page ukuran halaman yang sedang dipakai. Tautan pada links memuat halaman first, last, next, dan prev — null bila halaman tersebut tidak ada, seperti next pada halaman terakhir.

Kelas ini berbeda dari TaskResource: resource mewakili satu model, sedangkan collection mewakili daftar sekaligus pembawa metadata pagination. Properti $collects memastikan setiap item lewat TaskResource, sehingga bentuk item di daftar identik dengan bentuk di endpoint show. Halaman kedua dari tujuh tugas dengan per_page=3 terlihat sebagai berikut:

text
GET /api/tasks?per_page=3&page=2
HTTP 200
{
    "data": [
        {
            "id": 4,
            "title": "Review pull request",
            "description": null,
            "status": "done",
            "priority": "high",
            "due_date": "2026-01-15",
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-01-10T09:00:00+00:00"
        },
        {
            "id": 3,
            "title": "Menulis laporan mingguan",
            "description": "Ringkasan sprint minggu kedua.",
            "status": "done",
            "priority": "low",
            "due_date": null,
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-01-10T09:00:00+00:00"
        },
        {
            "id": 2,
            "title": "Deploy ke production",
            "description": null,
            "status": "in_progress",
            "priority": "high",
            "due_date": "2026-01-18",
            "created_at": "2026-01-10T09:00:00+00:00",
            "updated_at": "2026-01-10T09:00:00+00:00"
        }
    ],
    "links": {
        "first": "http://localhost/api/tasks?per_page=3&page=1",
        "last": "http://localhost/api/tasks?per_page=3&page=3",
        "next": "http://localhost/api/tasks?per_page=3&page=3",
        "prev": "http://localhost/api/tasks?per_page=3&page=1"
    },
    "meta": {
        "current_page": 2,
        "from": 4,
        "last_page": 3,
        "per_page": 3,
        "to": 6,
        "total": 7
    }
}

Semua fitur kini lengkap; tinggal satu langkah terakhir membuktikannya. Bagian 6 menutup proyek dengan feature test yang menghajar seluruh kontrak API ini.

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
{
    //
}