Kita sudah bisa mencetak dan mencabut token dari kode, tetapi pengguna akhir tidak akan pernah membuka tinker. Mereka butuh antarmuka: halaman yang mendaftarkan perangkat apa saja yang memegang akses, formulir pembuatan token baru dengan pilihan abilities dari chapter Token Abilities , dan tombol pencabutan. Chapter ini membangun tiga endpoint intinya — daftar, buat, hapus — sebagai controller API sungguhan yang lolos lima feature test di sandbox. Prinsip keamanannya satu kalimat: nilai token hanya tampil sekali di response pembuatan, selebihnya selamanya hanya metadata.
Endpoint Daftar Token #
Relasi tokens dari trait HasApiTokens menjadi dasar listing. Kita bangun controller resource dengan pagination, hanya menampilkan kolom yang aman:
<?php
// app/Http/Controllers/Api/TokenController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TokenController extends Controller
{
public function index(Request $request): JsonResponse
{
$tokens = $request->user()->tokens()
->latest('id')
->paginate(10, ['id', 'name', 'abilities', 'last_used_at', 'created_at']);
return response()->json([
'data' => $tokens->map(fn ($token) => [
'id' => $token->id,
'name' => $token->name,
'abilities' => $token->abilities,
'last_used_at' => optional($token->last_used_at)->toDateTimeString(),
'created_at' => $token->created_at->toDateTimeString(),
]),
'current_page' => $tokens->currentPage(),
'last_page' => $tokens->lastPage(),
]);
}
}Perhatikan pemilihan kolomnya: id, name, abilities, last_used_at, created_at — tidak ada token. Pemanggilan map() juga secara eksplisit membentuk struktur JSON, jadi kolom hash tidak mungkin bocor meski developer kelak menambah field. Dengan tiga token di database (satu di antaranya sempat dipakai request), hasil nyatanya:
Output yang Dihasilkan:
### GET /api/tokens → HTTP 200
{
"data": [
{
"id": 3,
"name": "laptop-budi",
"abilities": ["posts:read", "posts:create"],
"last_used_at": "2026-01-02 09:05:00",
"created_at": "2026-01-02 09:00:00"
},
{
"id": 2,
"name": "tablet-budi",
"abilities": ["posts:read"],
"last_used_at": null,
"created_at": "2026-01-02 09:00:00"
},
{
"id": 1,
"name": "mobile-budi",
"abilities": ["*"],
"last_used_at": null,
"created_at": "2026-01-02 09:00:00"
}
],
"current_page": 1,
"last_page": 1
}Kolom last_used_at adalah alat audit yang murah: tablet-budi yang tidak pernah terpakai sejak pembuatan adalah kandidat pencabutan — persis wawasan yang kita tandai di chapter API Authentication
. Nilai null milik tablet-budi berbeda makna dari laptop-budi: yang pertama belum pernah dipakai sama sekali, yang kedua aktif lima menit setelah dibuat. Untuk user dengan banyak perangkat, pagination paginate(10) menjaga response tetap ringan — kuncinya diurutkan dari yang terbaru lewat latest('id') sehingga token baru selalu muncul di halaman pertama.
Endpoint Pembuatan Token #
Formulir pembuatan menerima nama dan daftar abilities, lalu memanggil createToken() seperti biasa:
<?php
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'abilities' => ['array'],
'abilities.*' => ['string'],
]);
$abilities = $validated['abilities'] ?? ['*'];
$newToken = $request->user()->createToken(
$validated['name'],
$abilities,
);
return response()->json([
'id' => $newToken->accessToken->id,
'name' => $newToken->accessToken->name,
'abilities' => $newToken->accessToken->abilities,
'plain_text_token' => $newToken->plainTextToken,
], 201);
}Output yang Dihasilkan:
### POST /api/tokens {"name": "mobile-budi", "abilities": ["posts:read", "posts:create"]}
HTTP 201
{
"id": 2,
"name": "mobile-budi",
"abilities": [
"posts:read",
"posts:create"
],
"plain_text_token": "2|P9itvGbRvega7shh7St9I2lZXanR5Ohwan74bUb68b5b9588"
}Mari kita telaah method store di atas langkah demi langkah:
$request->validate([...]): Memastikannamewajib berupa string maksimal 255 karakter, danabilities— bila dikirim — array berisi string. Tanpa validasi ini, client bisa menyuntik ability sembarang.$validated['abilities'] ?? ['*']: Bila client tidak memilih ability apa pun, token dibuat dengan wildcard — keputusan yang sebaiknya dicatat di dokumentasi API.createToken($validated['name'], $abilities): Membuat baris token persis seperti chapter Personal Access Tokens .- Response 201 dengan
plain_text_token: Satu-satunya kesempatan bagi client untuk membaca nilai token.
Inilah satu-satunya titik di mana plain_text_token boleh muncul — response ini. Antarmuka yang benar menampilkannya sekali di halaman berikutnya dengan tombol “salin” dan peringatan bahwa nilai tidak akan bisa dilihat lagi, persis seperti pola GitHub saat membuat personal access token.
Antarmuka Pilihan Abilities #
Formulir sebaiknya memakai daftar centang dari daftar ability yang diketahui aplikasi, bukan input teks bebas. Sketsa Blade-nya:
{{-- resources/views/tokens/create.blade.php (sketsa) --}}
<form method="POST" action="{{ route('tokens.store') }}">
@csrf
<label for="name">Nama token</label>
<input id="name" type="text" name="name" required
placeholder="contoh: mobile-budi">
@foreach (['posts:read', 'posts:create'] as $ability)
<label>
<input type="checkbox" name="abilities[]" value="{{ $ability }}">
{{ $ability }}
</label>
@endforeach
<button type="submit">Buat token</button>
</form>Sketsa di atas tidak memuat output karena interaksinya perlu browser; logika servernya tetap endpoint store yang sudah teruji di atas. Input name dipetakan ke nama token, dan setiap centang menghasilkan satu elemen abilities[].
Alur setelah submit mengikuti pola dua halaman: form create men-submit ke store, lalu di-redirect ke halaman show yang menampilkan plaintext token beserta tombol salin. Halaman show ini tidak boleh bisa dimuat ulang — nilainya disimpan sekali di flash session atau variabel sementara, bukan di database, dan setelah halaman ditutup nilainya memang hilang selamanya. Bila pengguna kehilangan nilai tersebut, satu-satunya jalan adalah mencabut token lama dan membuat token baru; kekakuan ini disengaja karena tidak ada mekanisme lain yang aman.
Pengujian CRUD #
Lima feature test mengunci perilaku endpoint di atas — termasuk dua aturan keamanan: nilai token tidak pernah muncul di listing, dan token milik user lain tidak bisa dihapus:
<?php
// tests/Feature/Sanctum/TokenManagementTest.php (ringkas)
public function test_daftar_token_tidak_pernah_memuat_nilai_token(): void
{
$this->budi->createToken('mobile-budi');
$this->budi->createToken('tablet-budi');
$response = $this->actingAs($this->budi, 'sanctum')->getJson('/api/tokens');
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$response->assertJsonPath('data.0.name', 'tablet-budi');
$response->assertJsonPath('data.1.name', 'mobile-budi');
}
public function test_tidak_bisa_menghapus_token_milik_user_lain(): void
{
$siti = User::factory()->create(['email' => '[email protected]']);
$tokenSiti = $siti->createToken('mobile-siti');
$response = $this->actingAs($this->budi, 'sanctum')
->deleteJson("/api/tokens/{$tokenSiti->accessToken->id}");
$response->assertStatus(404);
$this->assertDatabaseHas('personal_access_tokens', [
'id' => $tokenSiti->accessToken->id,
]);
}Output yang Dihasilkan:
PASS Tests\Feature\Sanctum/TokenManagementTest
✓ bisa membuat token dengan nama dan abilities
✓ daftar token tidak pernah memuat nilai token
✓ bisa menghapus token sendiri
✓ tidak bisa menghapus token milik user lain
✓ nama token wajib diisi
Tests: 5 passed (20 assertions)Endpoint penghapusan memakai $request->user()->tokens()->where('id', $tokenId)->delete() — scope relasi memastikan hanya token milik sendiri yang tersentuh, sehingga token milik Siti berakhir 404, bukan error. Detail operasi pencabutan itu sendiri — per perangkat, semua perangkat, hingga token kedaluwarsa — kita bedah tuntas di chapter Revoking Tokens
. Rujukan resmi: dokumentasi Sanctum — token management
.