Proyek Akhir: Aplikasi Daftar Tugas
Menyatukan semuanya: enum, readonly DTO, repository berbasis interface, service, validasi, exception, dan respons JSON; lalu langkah belajar selanjutnya.
Tujuan proyek
Kita membangun inti aplikasi Daftar Tugas (Todo) dengan arsitektur bersih yang memakai hampir semua yang sudah kamu pelajari: strict_types, enum, readonly class, constructor promotion, interface, dependency injection, exception kustom, match, dan JSON.
Struktur proyek
todo/
├── public/index.php ← titik masuk web (router sederhana)
├── src/
│ ├── Status.php ← enum
│ ├── Tugas.php ← readonly class (entitas)
│ ├── TugasRepository.php ← interface
│ ├── MemoriTugasRepository.php ← implementasi di memori (bisa diganti PDO)
│ ├── TugasService.php ← logika bisnis
│ └── TugasTidakDitemukan.php ← exception kustom
├── tests/
└── composer.json ← autoload PSR-4 "App\\": "src/"
1. Enum & entitas
enum Status: string
{
case Baru = 'baru';
case Proses = 'proses';
case Selesai = 'selesai';
public function bolehKe(self $tujuan): bool
{
return match ($this) {
self::Baru => $tujuan === self::Proses,
self::Proses => $tujuan === self::Selesai,
self::Selesai => false,
};
}
}
readonly class Tugas
{
public function __construct(
public int $id,
public string $judul,
public Status $status = Status::Baru,
) {}
public function denganStatus(Status $status): static
{
return new static($this->id, $this->judul, $status); // immutable: buat salinan baru
}
}
2. Kontrak penyimpanan
interface TugasRepository
{
public function simpan(Tugas $tugas): void;
public function cari(int $id): ?Tugas;
/** @return Tugas[] */
public function semua(): array;
public function idBerikut(): int;
}
Service hanya tahu TugasRepository. Hari ini implementasinya array di memori; besok PDO/MySQL — tanpa mengubah service. Inilah kekuatan interface dan dependency injection.
3. Service dengan aturan bisnis
final class TugasService
{
public function __construct(private TugasRepository $repo) {}
public function buat(string $judul): Tugas
{
$judul = trim($judul);
if ($judul === '' || mb_strlen($judul) > 100) {
throw new InvalidArgumentException('Judul wajib diisi, maksimal 100 karakter');
}
$tugas = new Tugas($this->repo->idBerikut(), $judul);
$this->repo->simpan($tugas);
return $tugas;
}
public function ubahStatus(int $id, Status $tujuan): Tugas
{
$tugas = $this->repo->cari($id) ?? throw new TugasTidakDitemukan($id);
if (!$tugas->status->bolehKe($tujuan)) {
throw new DomainException("Tidak bisa dari {$tugas->status->value} ke {$tujuan->value}");
}
$baru = $tugas->denganStatus($tujuan);
$this->repo->simpan($baru);
return $baru;
}
}
4. Titik masuk web (JSON API mini)
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\{TugasService, MemoriTugasRepository, TugasTidakDitemukan, Status};
header('Content-Type: application/json; charset=utf-8');
$service = new TugasService(new MemoriTugasRepository());
$metode = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
try {
$hasil = match (true) {
$metode === 'POST' && $path === '/tugas'
=> $service->buat((string) (json_decode(file_get_contents('php://input'), true)['judul'] ?? '')),
default => throw new RuntimeException('Rute tidak ditemukan', 404),
};
echo json_encode($hasil, JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException $e) {
http_response_code(422);
echo json_encode(['error' => $e->getMessage()]);
} catch (TugasTidakDitemukan) {
http_response_code(404);
echo json_encode(['error' => 'Tugas tidak ditemukan']);
} catch (Throwable $e) {
error_log((string) $e); // detail untuk developer
http_response_code(500);
echo json_encode(['error' => 'Terjadi kesalahan server']); // pesan ramah untuk pengguna
}
5. Pengujian
// tests/TugasServiceTest.php (PHPUnit)
public function test_judul_kosong_ditolak(): void
{
$this->expectException(InvalidArgumentException::class);
(new TugasService(new MemoriTugasRepository()))->buat(' ');
}
public function test_selesai_tidak_bisa_diubah(): void
{
$svc = new TugasService(new MemoriTugasRepository());
$t = $svc->buat('Belajar PHP');
$svc->ubahStatus($t->id, Status::Proses);
$svc->ubahStatus($t->id, Status::Selesai);
$this->expectException(DomainException::class);
$svc->ubahStatus($t->id, Status::Baru);
}
Karena service bergantung pada interface, tes cukup memakai repository di memori — tanpa database, cepat dan deterministik.
Tantangan lanjutan (kerjakan di komputermu)
- Buat
PdoTugasRepository(SQLite) dan tukar tanpa menyentuhTugasService. - Tambah
PUT /tugas/{id}/statusdanGET /tugas. - Tambah autentikasi: tabel pengguna,
password_hash, session/JWT. - Jalankan
phpstanlevel 8 dan perbaiki temuannya. - Pasang di server dengan Nginx + PHP-FPM, HTTPS, dan
display_errors=Off.
Setelah kursus ini, ke mana?
- Laravel — framework yang membungkus semua konsep ini. Lanjutkan ke kursus Laravel 13 dari Nol.
- Pengujian: Pest/PHPUnit, TDD. Kualitas: PHPStan, Pint, Rector.
- Arsitektur: domain-driven design, event, CQRS; performa: OPcache, JIT, profiling dengan Xdebug/Blackfire.
- Basis data lanjut: indeks, EXPLAIN, migrasi, replikasi.
Selamat! Setelah lulus bab ini dan quiz akhir, kamu berhak mendapatkan sertifikat. Kunci menjadi mahir: buat proyekmu sendiri dan terus membaca kode orang lain.