Bagian 2 menutup dengan library yang bekerja — tetapi bekerja dengan cara yang kaku. Separator tanda hubung di-hardcode, judul beraksen rusak menjadi r-sum-terbaru-2026, dan generate('') berdiam diri mengembalikan string kosong tanpa memberi tahu siapa pun. Pada Bagian 3 kita melunasi ketiga utang itu sekaligus, dan caranya bukan sekadar menambah kode: kita merancang public API — permukaan tempat kode orang lain bersentuhan dengan library kita. Keputusan terpentingnya tiga: konfigurasi bersifat immutabel, teks diproses sadar unicode, dan input yang tidak sah ditolak secara eksplisit.
Tiga Utang Versi Dasar #
Sebelum menulis kode baru, mari kita tetapkan dengan jelas apa yang kurang dan bagaimana kita membayarnya:
| Utang versi Bagian 2 | Konsekuensi | Pelunasan di Bagian 3 |
|---|---|---|
Separator - hardcoded | pengguna tak bisa minta belajar_php_dasar | opsi constructor + method withSeparator() |
strtolower() hanya ASCII | judul beraksen jadi slug hancur | mb_strtolower() + transliterasi iconv() |
generate('') mengembalikan '' diam-diam | bug pemanggil tersembunyi | InvalidInputException |
Tabel ini juga menetapkan batas pekerjaan: kita tidak menambah fitur baru di luar tiga baris itu. Library kecil yang baik menolak membengkak; setiap method publik adalah janji yang harus dites, didokumentasikan, dan dipertahankan — beban yang akan terasa nyata pada Bagian 4 saat menulis test suite.
Exception Domain: InvalidInputException #
Validasi input membutuhkan cara penolakan yang terstruktur, dan caranya adalah exception milik sendiri. Berikut seluruh isi src/Exception/InvalidInputException.php:
<?php
declare(strict_types=1);
namespace App\Util\Exception;
use InvalidArgumentException;
/**
* Dilempar ketika input atau opsi yang diberikan ke SlugGenerator tidak valid.
*
* Kelas ini extends InvalidArgumentException, sehingga pemanggil bisa
* menangkapnya secara spesifik (InvalidInputException) maupun generik
* (InvalidArgumentException).
*/
final class InvalidInputException extends InvalidArgumentException
{
}Kelasnya kosong — tidak ada satu properti pun — dan justru itulah intinya. Nilainya ada pada tipenya: nama InvalidInputException menjelaskan kesalahan pemanggil tanpa perlu membaca pesan, dan kedudukannya di bawah InvalidArgumentException membuatnya bisa ditangkap dua cara sekaligus. Mari kita buktikan kedua cara itu:
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\Exception\InvalidInputException;
use App\Util\SlugGenerator;
$generator = new SlugGenerator();
// Dua cara menangkap exception yang sama.
echo "subclass: ";
try {
$generator->generate(' ');
} catch (InvalidInputException $e) {
echo get_class($e), " -> ditangkap spesifik\n";
}
echo "parent : ";
try {
$generator->generate(' ');
} catch (InvalidArgumentException $e) {
echo get_class($e), " -> ditangkap generik\n";
}Output yang Dihasilkan:
subclass: App\Util\Exception\InvalidInputException -> ditangkap spesifik
parent : App\Util\Exception\InvalidInputException -> ditangkap generikPemanggil yang peduli dengan slug bisa menangkap subclass-nya; pemanggil yang hanya ingin memvalidasi seluruh masukan pengguna cukup menangkap induknya. Pola exception khusus per-library seperti ini dibahas tuntas pada chapter Custom Exceptions .
Kelas Baru: SlugGenerator yang Dapat Dikonfigurasi #
Sekarang kelas utamanya. Tiga keputusan rancangan perlu dipahami sebelum membaca kode. Pertama, konfigurasi dikirim lewat named arguments constructor — new SlugGenerator(separator: '_') — bukan lewat array opsi seperti $options['separator'] yang lazim pada library lama; dengan named arguments, mesin PHP sendiri yang menolak separator: 123 lewat TypeError, sehingga kita tidak perlu menulis pemeriksaan is_string() manual untuk tipe. Kedua, instance bersifat immutable: mengubah konfigurasi menghasilkan instance baru lewat method with*(), sehingga satu instance boleh dibagikan ke seluruh aplikasi tanpa risiko konfigurasinya diubah pihak lain. Ketiga, properti sengaja tidak memakai readonly — walau fitur itu tersedia sejak PHP 8.1, method with*() perlu menulis ulang properti pada salinan, dan kelonggaran menulis ulang properti readonly di dalam __clone() baru hadir di PHP 8.3, sedangkan package kita berjanji mendukung 8.1.
Berikut isi lengkap src/SlugGenerator.php versi baru — berkas yang sama tersedia di bagian bawah halaman:
<?php
declare(strict_types=1);
namespace App\Util;
use App\Util\Exception\InvalidInputException;
/**
* Pembuat slug URL yang dapat dikonfigurasi.
*
* Instance kelas ini bersifat immutable: method withSeparator() dan
* withMaxLength() mengembalikan instance baru, sehingga satu instance
* boleh dibagikan dan dipakai ulang tanpa risiko konfigurasinya berubah
* di tengah jalan.
*
* Penggunaan dasar:
*
* $generator = new SlugGenerator();
* $generator->generate('Belajar PHP Dasar');
* // "belajar-php-dasar"
*
* Penggunaan dengan konfigurasi:
*
* $generator = new SlugGenerator(separator: '_', maxLength: 20);
* $generator->generate('Rahasia Crème Brûlée yang Gampang');
* // "rahasia_creme_brulee"
*/
final class SlugGenerator
{
/**
* Sisa tanda aksen yang ditinggalkan iconv() setelah transliterasi,
* misalnya "`e" untuk "è". Tanda-tanda ini dibuang agar hasil tetap
* berupa huruf biasa.
*/
private const ACCENT_MARKS = [
'`' => '',
"'" => '',
'^' => '',
'"' => '',
'~' => '',
];
private string $separator;
private ?int $maxLength;
private bool $lowercase;
/**
* Bangun generator dengan konfigurasi awal.
*
* @param string $separator karakter pemisah antar kata, default "-".
* @param int|null $maxLength panjang maksimum slug dalam byte; null
* berarti tanpa batas. Default null.
* @param bool $lowercase ubah seluruh huruf menjadi huruf kecil.
* Default true.
*
* @throws InvalidInputException jika separator kosong atau mengandung
* huruf/angka, atau maxLength bernilai kurang dari 1.
*/
public function __construct(
string $separator = '-',
?int $maxLength = null,
bool $lowercase = true,
) {
self::assertValidSeparator($separator);
self::assertValidMaxLength($maxLength);
$this->separator = $separator;
$this->maxLength = $maxLength;
$this->lowercase = $lowercase;
}
/**
* Named constructor: generator dengan preset separator garis bawah.
*
* $generator = SlugGenerator::underscore();
* $generator->generate('Belajar PHP Dasar'); // "belajar_php_dasar"
*/
public static function underscore(): self
{
return new self(separator: '_');
}
/**
* Kembalikan instance baru dengan separator yang berbeda.
*
* Instance lama tidak berubah:
*
* $asli = new SlugGenerator();
* $baru = $asli->withSeparator('_');
* $asli->generate('Belajar PHP'); // "belajar-php"
* $baru->generate('Belajar PHP'); // "belajar_php"
*
* @throws InvalidInputException jika separator tidak valid.
*/
public function withSeparator(string $separator): self
{
self::assertValidSeparator($separator);
$baru = clone $this;
$baru->separator = $separator;
return $baru;
}
/**
* Kembalikan instance baru dengan panjang maksimum yang berbeda.
*
* @param int|null $maxLength panjang maksimum slug dalam byte;
* null menghapus batas.
*
* @throws InvalidInputException jika maxLength kurang dari 1.
*/
public function withMaxLength(?int $maxLength): self
{
self::assertValidMaxLength($maxLength);
$baru = clone $this;
$baru->maxLength = $maxLength;
return $baru;
}
/**
* Ubah judul menjadi slug URL.
*
* Tahapan pengolahannya: huruf kecil (unicode-aware), transliterasi
* karakter beraksen ke ASCII lewat iconv(), penggantian rentetan
* karakter non-alfanumerik dengan separator, perapian separator di
* ujung, lalu pemotongan sesuai maxLength pada batas kata bila
* memungkinkan.
*
* @param string $text judul bebas, tidak boleh kosong.
*
* @return string slug hasil olahan; string kosong bila input tidak
* mengandung satu pun huruf atau angka (misalnya "!!!").
*
* @throws InvalidInputException jika $text kosong atau hanya berisi
* spasi.
*/
public function generate(string $text): string
{
$trimmed = trim($text);
if ($trimmed === '') {
throw new InvalidInputException(
'Input tidak boleh kosong atau hanya berisi spasi.'
);
}
// 1. Huruf kecil yang sadar unicode (mb_strtolower), bukan strtolower.
$slug = $this->lowercase
? mb_strtolower($trimmed, 'UTF-8')
: $trimmed;
// 2. Transliterasi karakter beraksen menjadi ASCII.
// "//TRANSLIT" mengubah "è" menjadi "`e", "//IGNORE" membuang
// karakter yang tidak punya padanan ASCII (mis. emoji).
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slug);
if (is_string($ascii)) {
$slug = $ascii;
}
// 3. Buang sisa tanda aksen dari hasil transliterasi.
$slug = strtr($slug, self::ACCENT_MARKS);
// 4. Ganti rentetan non-alfanumerik dengan separator.
$slug = preg_replace('/[^A-Za-z0-9]+/', $this->separator, $slug) ?? '';
// 5. Rapikan separator yang menempel di ujung hasil.
$slug = trim($slug, $this->separator);
// 6. Potong sesuai maxLength, diutamakan pada batas kata.
if ($this->maxLength !== null && strlen($slug) > $this->maxLength) {
$slug = $this->truncate($slug, $this->maxLength);
}
return $slug;
}
/**
* Potong slug menjadi maksimum $maxLength byte.
*
* Pemotongan diupayakan jatuh pada separator terakhir di dalam jangkauan
* supaya hasil tidak terpotong di tengah kata; karena itu hasil boleh
* lebih pendek dari $maxLength.
*/
private function truncate(string $slug, int $maxLength): string
{
$slug = substr($slug, 0, $maxLength);
$posisiSeparator = strrpos($slug, $this->separator);
if ($posisiSeparator !== false && $posisiSeparator > 0) {
$slug = substr($slug, 0, $posisiSeparator);
}
return $slug;
}
/**
* Pastikan separator tidak akan merusak slug.
*
* Huruf dan angka dilarang karena akan menyatu dengan kata; "$" dan "\"
* dilarang karena keduanya berarti khusus pada string pengganti
* preg_replace().
*/
private static function assertValidSeparator(string $separator): void
{
if ($separator === '') {
throw new InvalidInputException(
'Separator tidak boleh berupa string kosong.'
);
}
if (preg_match('/[A-Za-z0-9$\\\\]/', $separator) === 1) {
throw new InvalidInputException(
'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": '
. '"' . $separator . '"'
);
}
}
/**
* Pastikan maxLength bernilai 1 atau lebih bila tidak null.
*/
private static function assertValidMaxLength(?int $maxLength): void
{
if ($maxLength !== null && $maxLength < 1) {
throw new InvalidInputException(
'maxLength harus bernilai 1 atau lebih, diberikan: ' . $maxLength
);
}
}
}Struktur kelasnya bisa dibaca dalam lima kelompok. Properti privat ($separator, $maxLength, $lowercase) menyimpan konfigurasi tanpa membocorkannya — tidak ada getter pun, karena belum ada kebutuhan nyata dan API yang minimal lebih mudah dipertahankan. Constructor menerima tiga parameter bernama dengan nilai bawaan, lalu memvalidasi lewat dua method statis privat sebelum properti diisi. Named constructor underscore() adalah contoh pola factory bernama: satu baris yang memberi nama pada konfigurasi populer. Wither withSeparator() dan withMaxLength() menyalin instance dengan clone lalu mengubah satu properti di salinannya; validasi yang sama dipanggil ulang supaya jalur pembuatan dan jalur perubahan tidak bisa berbeda pendapat. generate() sendiri kini berjalan enam langkah yang kita bedah satu per satu pada dua bagian berikutnya.
Perhatikan juga docblock di atas setiap method publik: parameter, nilai balik, kondisi exception, dan contoh pemakaian. Bagi pengguna library, docblock inilah dokumentasi yang paling dekat — terbaca langsung di IDE. Menuliskannya bersama kodenya, bukan setelahnya, adalah kebiasaan yang menjaga dokumentasi tetap jujur.
Konfigurasi dan Immutabilitas dalam Praktik #
Empat konfigurasi berbeda dari satu kelas yang sama:
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\SlugGenerator;
$judul = 'Belajar PHP dan MySQL: Panduan Lengkap';
$default = new SlugGenerator();
$underscore = SlugGenerator::underscore();
$denganOpsi = new SlugGenerator(separator: '_', maxLength: 30);
$tanpaLowercase = new SlugGenerator(lowercase: false);
echo "default : ", $default->generate($judul), "\n";
echo "underscore : ", $underscore->generate($judul), "\n";
echo "opsi lengkap: ", $denganOpsi->generate($judul), "\n";
echo "tanpa lower : ", $tanpaLowercase->generate($judul), "\n";Output yang Dihasilkan:
default : belajar-php-dan-mysql-panduan-lengkap
underscore : belajar_php_dan_mysql_panduan_lengkap
opsi lengkap: belajar_php_dan_mysql_panduan
tanpa lower : Belajar-PHP-dan-MySQL-Panduan-LengkapBaris opsi lengkap memperlihatkan maxLength bekerja: slug utuhnya belajar_php_dan_mysql_panduan_lengkap sepanjang 37 byte dipotong dalam jangkauan 30 byte, lalu dimundurkan ke batas kata sehingga lengkap tidak terpenggal. Sifat immutable wither paling mudah dibuktikan dengan menyimpan dua referensi:
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\SlugGenerator;
$asli = new SlugGenerator();
$baru = $asli->withSeparator('_')->withMaxLength(15);
echo "asli : ", $asli->generate('Belajar PHP Dasar'), "\n";
echo "baru : ", $baru->generate('Belajar PHP Dasar'), "\n";
echo "baru : ", $baru->generate('Framework Laravel Untuk Pemula'), "\n";Output yang Dihasilkan:
asli : belajar-php-dasar
baru : belajar_php
baru : frameworkSetelah dua pemanggilan with*(), instance $asli tetap menghasilkan slug bertanda hubung — ia tidak pernah berubah. Instance $baru adalah objek berbeda hasil clone, bukan $asli yang dimodifikasi. Baris terakhir juga memperlihatkan karakter pemotongan truncate(): dari framework_laravel_untuk_pemula, potongan 15 byte pertama adalah framework_larav, lalu mundur ke separator terakhir sehingga hasilnya framework — utuh di batas kata, meski boleh lebih pendek dari batas.
Unicode: mb_strtolower dan Transliterasi iconv #
Langkah 1 dan 2 di generate() melunasi utang unicode. Fungsi mb_strtolower() menghitung huruf kecil berdasarkan karakter, bukan byte, sehingga É benar-benar menjadi é. Lalu iconv() dengan ASCII//TRANSLIT//IGNORE menerjemahkan karakter beraksen ke padanan ASCII terdekat dan membuang yang tidak punya padanan (seperti emoji). Hasilnya:
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\SlugGenerator;
$generator = new SlugGenerator();
$judul = [
'Rahasia Crème Brûlée yang Gampang',
'Ångström & Über Modes',
'Résumé Terbaru 2026',
];
foreach ($judul as $teks) {
echo $teks, ' -> ', $generator->generate($teks), "\n";
}Output yang Dihasilkan:
Rahasia Crème Brûlée yang Gampang -> rahasia-creme-brulee-yang-gampang
Ångström & Über Modes -> angstrom-uber-modes
Résumé Terbaru 2026 -> resume-terbaru-2026Bandingkan baris terakhir dengan Bagian 2: résumé kini menjadi resume-terbaru-2026, bukan r-sum-terbaru-2026. Ada satu jebakan implementasi yang perlu dijelaskan. Pada build iconv di mesin uji buku ini, è ditransliterasi menjadi dua karakter — backtick diikuti huruf e — sehingga tanpa pembersihan, “crème” akan menjadi cr-eme. Konstanta ACCENT_MARKS pada langkah 3 membuang tanda-tanda sisa itu; itulah alasan strtr() berdiri sebagai langkah tersendiri. Solusi industri untuk masalah ini sebenarnya adalah ekstensi intl dengan Transliterator, tetapi ekstensi itu tidak terpasang di semua lingkungan PHP — dan menambahkannya sebagai dependensi memperberat package. Kompromi mbstring + iconv yang kita pilih cukup untuk alfabet Latin beraksen, dan keduanya terpasang nyaris di mana-mana.
Validasi Input: Antara TypeError dan Exception #
Type declaration string $text sudah menjadi penjaga pertama — tetapi penjaga yang perlu dipahami batasnya. Input yang jelas bukan string (array, objek tanpa __toString) ditolak mesin PHP dengan TypeError. Input kosong atau berisi spasi adalah string yang sah secara tipe namun tidak bermakna, dan penolakan seperti ini urusan kita:
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\Exception\InvalidInputException;
use App\Util\SlugGenerator;
$generator = new SlugGenerator();
foreach (['', ' ', "\t\n"] as $teks) {
try {
$generator->generate($teks);
} catch (InvalidInputException $e) {
echo 'ditangkap: ', $e->getMessage(), "\n";
}
}Output yang Dihasilkan:
ditangkap: Input tidak boleh kosong atau hanya berisi spasi.
ditangkap: Input tidak boleh kosong atau hanya berisi spasi.
ditangkap: Input tidak boleh kosong atau hanya berisi spasi.Sisi konfigurasi divalidasi dengan aturan yang sama. Separator kosong akan menghapus seluruh pemisah, huruf dan angka akan menyatu dengan kata, sedangkan $ dan \ punya arti khusus sebagai string pengganti preg_replace():
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\Exception\InvalidInputException;
use App\Util\SlugGenerator;
foreach (['', 'x', 'a-b', '$'] as $sep) {
try {
new SlugGenerator(separator: $sep);
} catch (InvalidInputException $e) {
echo 'ditolak: ', $e->getMessage(), "\n";
}
}
try {
new SlugGenerator(maxLength: 0);
} catch (InvalidInputException $e) {
echo 'ditolak: ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
ditolak: Separator tidak boleh berupa string kosong.
ditolak: Separator tidak boleh mengandung huruf, angka, "$", atau "\": "x"
ditolak: Separator tidak boleh mengandung huruf, angka, "$", atau "\": "a-b"
ditolak: Separator tidak boleh mengandung huruf, angka, "$", atau "\": "$"
ditolak: maxLength harus bernilai 1 atau lebih, diberikan: 0Sementara itu, TypeError tetap menjadi milik mesin PHP. Menangkapnya eksplisit berguna di lapisan batas aplikasi, misalnya saat menerima data mentah dari json_decode():
<?php
require __DIR__ . '/src/SlugGenerator.php';
require __DIR__ . '/src/Exception/InvalidInputException.php';
use App\Util\SlugGenerator;
$generator = new SlugGenerator();
try {
$generator->generate(['judul', 'berbentuk array']);
} catch (TypeError $e) {
// Pesan TypeError memuat path absolut berkas pemanggil;
// potong bagian itu supaya contoh tetap pendek.
$pesan = strstr($e->getMessage(), ', called in', true) ?: $e->getMessage();
echo 'ditangkap TypeError: ', $pesan, "\n";
}Output yang Dihasilkan:
ditangkap TypeError: App\Util\SlugGenerator::generate(): Argument #1 ($text) must be of type string, array givenMenjaga Backward Compatibility #
Kelas baru ini menggantikan kelas Bagian 2, tetapi pemanggil lama tidak boleh patah. Mari kita audit perubahannya terhadap perilaku yang sudah teruji:
| Input | Bagian 2 | Bagian 3 |
|---|---|---|
new SlugGenerator() lalu generate('Belajar PHP Dasar') | belajar-php-dasar | belajar-php-dasar |
'Tips & Trik -- Untuk Pemula' | tips-trik-untuk-pemula | tips-trik-untuk-pemula |
'Résumé Terbaru 2026' | r-sum-terbaru-2026 | resume-terbaru-2026 |
'' (string kosong) | '' secara diam-diam | InvalidInputException |
Dua baris pertama adalah kompatibilitas yang berhasil dijaga: constructor tanpa argumen tetap valid, tanda tangan generate(string): string tidak berubah, dan seluruh input ASCII murni menghasilkan byte yang sama. Dua baris terakhir adalah perubahan perilaku yang disengaja: judul beraksen membaik, dan string kosong kini ditolak. Mengapa perubahan seperti ini boleh? Karena package kita masih 0.x — semantic versioning menyatakan fase ini bebas mengubah API tanpa menaikkan angka mayor. Sejak 1.0.0, perubahan sekecil baris terakhir pun menuntut rilis mayor baru; disiplin menilai setiap perubahan seperti tabel inilah inti chapter Maintaining Backward Compatibility
. Tabel ini sendiri belum bisa disebut jaminan — pada Bagian 4 kita mengubahnya menjadi test suite yang memeriksa baris-barisnya secara otomatis setiap kali kode berubah.
Apa yang Berubah #
Dibandingkan Bagian 2 yang hanya memiliki satu method sederhana:
src/SlugGenerator.phpditulis ulang — constructor dengan named arguments (separator,maxLength,lowercase) lengkap validasinya, named constructorunderscore(), wither immutabelwithSeparator()danwithMaxLength(), sertagenerate()enam langkah yang sadar unicode dan memvalidasi input.src/Exception/InvalidInputException.phpbaru — exception domain yang extendsInvalidArgumentException, bisa ditangkap spesifik maupun generik.- Kontrak yang tidak berubah:
new SlugGenerator()->generate('Belajar PHP Dasar')tetap menghasilkanbelajar-php-dasar.
Public API kini punya bentuk final yang akan kita pertahankan sepanjang sisa proyek: satu kelas dengan tiga method publik (generate, withSeparator, withMaxLength) plus satu preset statis (underscore), ditopang satu exception domain.
Cara Menjalankan #
Dari direktori bagian-3-public-api-dan-validation/ pada bundle halaman ini:
# 1. Demo manual — muat dua berkas kelas lalu uji alurnya
php -r '
require "src/SlugGenerator.php";
require "src/Exception/InvalidInputException.php";
$g = App\Util\SlugGenerator::underscore();
echo $g->generate("Rahasia Crème Brûlée"), "\n";
'
# 2. Sesi proyek konsumen seperti Bagian 2 — kini dengan API baru
mkdir -p /tmp/slugify-dev/src/Exception
cp ../bagian-1-struktur-package/composer.json /tmp/slugify-dev/
cp -r src/. /tmp/slugify-dev/src/
# lalu ikuti langkah proyek konsumen pada Bagian 2 (composer install + demo.php)Kedua berkas src/SlugGenerator.php dan src/Exception/InvalidInputException.php tersedia di bagian bawah halaman. Sebagai rujukan resmi: dokumentasi mb_strtolower()
dan iconv()
untuk pengolahan unicode, kelas InvalidArgumentException
untuk posisi exception kita di hirarki SPL, serta objek dan kloning
untuk mekanisme clone di balik wither. API sudah berbentuk — sekarang kita butuh jaring pengaman: Bagian 4 membekukannya menjadi test suite PHPUnit yang berjalan otomatis.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/Exception/InvalidInputException.php
Unduh berkas<?php
declare(strict_types=1);
namespace App\Util\Exception;
use InvalidArgumentException;
/**
* Dilempar ketika input atau opsi yang diberikan ke SlugGenerator tidak valid.
*
* Kelas ini extends InvalidArgumentException, sehingga pemanggil bisa
* menangkapnya secara spesifik (InvalidInputException) maupun generik
* (InvalidArgumentException).
*/
final class InvalidInputException extends InvalidArgumentException
{
}
src/SlugGenerator.php
Unduh berkas<?php
declare(strict_types=1);
namespace App\Util;
use App\Util\Exception\InvalidInputException;
/**
* Pembuat slug URL yang dapat dikonfigurasi.
*
* Instance kelas ini bersifat immutable: method withSeparator() dan
* withMaxLength() mengembalikan instance baru, sehingga satu instance
* boleh dibagikan dan dipakai ulang tanpa risiko konfigurasinya berubah
* di tengah jalan.
*
* Penggunaan dasar:
*
* $generator = new SlugGenerator();
* $generator->generate('Belajar PHP Dasar');
* // "belajar-php-dasar"
*
* Penggunaan dengan konfigurasi:
*
* $generator = new SlugGenerator(separator: '_', maxLength: 20);
* $generator->generate('Rahasia Crème Brûlée yang Gampang');
* // "rahasia_creme_brulee"
*/
final class SlugGenerator
{
/**
* Sisa tanda aksen yang ditinggalkan iconv() setelah transliterasi,
* misalnya "`e" untuk "è". Tanda-tanda ini dibuang agar hasil tetap
* berupa huruf biasa.
*/
private const ACCENT_MARKS = [
'`' => '',
"'" => '',
'^' => '',
'"' => '',
'~' => '',
];
private string $separator;
private ?int $maxLength;
private bool $lowercase;
/**
* Bangun generator dengan konfigurasi awal.
*
* @param string $separator karakter pemisah antar kata, default "-".
* @param int|null $maxLength panjang maksimum slug dalam byte; null
* berarti tanpa batas. Default null.
* @param bool $lowercase ubah seluruh huruf menjadi huruf kecil.
* Default true.
*
* @throws InvalidInputException jika separator kosong atau mengandung
* huruf/angka, atau maxLength bernilai kurang dari 1.
*/
public function __construct(
string $separator = '-',
?int $maxLength = null,
bool $lowercase = true,
) {
self::assertValidSeparator($separator);
self::assertValidMaxLength($maxLength);
$this->separator = $separator;
$this->maxLength = $maxLength;
$this->lowercase = $lowercase;
}
/**
* Named constructor: generator dengan preset separator garis bawah.
*
* $generator = SlugGenerator::underscore();
* $generator->generate('Belajar PHP Dasar'); // "belajar_php_dasar"
*/
public static function underscore(): self
{
return new self(separator: '_');
}
/**
* Kembalikan instance baru dengan separator yang berbeda.
*
* Instance lama tidak berubah:
*
* $asli = new SlugGenerator();
* $baru = $asli->withSeparator('_');
* $asli->generate('Belajar PHP'); // "belajar-php"
* $baru->generate('Belajar PHP'); // "belajar_php"
*
* @throws InvalidInputException jika separator tidak valid.
*/
public function withSeparator(string $separator): self
{
self::assertValidSeparator($separator);
$baru = clone $this;
$baru->separator = $separator;
return $baru;
}
/**
* Kembalikan instance baru dengan panjang maksimum yang berbeda.
*
* @param int|null $maxLength panjang maksimum slug dalam byte;
* null menghapus batas.
*
* @throws InvalidInputException jika maxLength kurang dari 1.
*/
public function withMaxLength(?int $maxLength): self
{
self::assertValidMaxLength($maxLength);
$baru = clone $this;
$baru->maxLength = $maxLength;
return $baru;
}
/**
* Ubah judul menjadi slug URL.
*
* Tahapan pengolahannya: huruf kecil (unicode-aware), transliterasi
* karakter beraksen ke ASCII lewat iconv(), penggantian rentetan
* karakter non-alfanumerik dengan separator, perapian separator di
* ujung, lalu pemotongan sesuai maxLength pada batas kata bila
* memungkinkan.
*
* @param string $text judul bebas, tidak boleh kosong.
*
* @return string slug hasil olahan; string kosong bila input tidak
* mengandung satu pun huruf atau angka (misalnya "!!!").
*
* @throws InvalidInputException jika $text kosong atau hanya berisi
* spasi.
*/
public function generate(string $text): string
{
$trimmed = trim($text);
if ($trimmed === '') {
throw new InvalidInputException(
'Input tidak boleh kosong atau hanya berisi spasi.'
);
}
// 1. Huruf kecil yang sadar unicode (mb_strtolower), bukan strtolower.
$slug = $this->lowercase
? mb_strtolower($trimmed, 'UTF-8')
: $trimmed;
// 2. Transliterasi karakter beraksen menjadi ASCII.
// "//TRANSLIT" mengubah "è" menjadi "`e", "//IGNORE" membuang
// karakter yang tidak punya padanan ASCII (mis. emoji).
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slug);
if (is_string($ascii)) {
$slug = $ascii;
}
// 3. Buang sisa tanda aksen dari hasil transliterasi.
$slug = strtr($slug, self::ACCENT_MARKS);
// 4. Ganti rentetan non-alfanumerik dengan separator.
$slug = preg_replace('/[^A-Za-z0-9]+/', $this->separator, $slug) ?? '';
// 5. Rapikan separator yang menempel di ujung hasil.
$slug = trim($slug, $this->separator);
// 6. Potong sesuai maxLength, diutamakan pada batas kata.
if ($this->maxLength !== null && strlen($slug) > $this->maxLength) {
$slug = $this->truncate($slug, $this->maxLength);
}
return $slug;
}
/**
* Potong slug menjadi maksimum $maxLength byte.
*
* Pemotongan diupayakan jatuh pada separator terakhir di dalam jangkauan
* supaya hasil tidak terpotong di tengah kata; karena itu hasil boleh
* lebih pendek dari $maxLength.
*/
private function truncate(string $slug, int $maxLength): string
{
$slug = substr($slug, 0, $maxLength);
$posisiSeparator = strrpos($slug, $this->separator);
if ($posisiSeparator !== false && $posisiSeparator > 0) {
$slug = substr($slug, 0, $posisiSeparator);
}
return $slug;
}
/**
* Pastikan separator tidak akan merusak slug.
*
* Huruf dan angka dilarang karena akan menyatu dengan kata; "$" dan "\"
* dilarang karena keduanya berarti khusus pada string pengganti
* preg_replace().
*/
private static function assertValidSeparator(string $separator): void
{
if ($separator === '') {
throw new InvalidInputException(
'Separator tidak boleh berupa string kosong.'
);
}
if (preg_match('/[A-Za-z0-9$\\\\]/', $separator) === 1) {
throw new InvalidInputException(
'Separator tidak boleh mengandung huruf, angka, "$", atau "\\": '
. '"' . $separator . '"'
);
}
}
/**
* Pastikan maxLength bernilai 1 atau lebih bila tidak null.
*/
private static function assertValidMaxLength(?int $maxLength): void
{
if ($maxLength !== null && $maxLength < 1) {
throw new InvalidInputException(
'maxLength harus bernilai 1 atau lebih, diberikan: ' . $maxLength
);
}
}
}