İletişime geç

PHP'de Türkçe Büyük-Küçük Harf Dönüşümü: i, İ, ı, I Sorunu

strtoupper ve mb_strtoupper Türkçe metinlerde neden yanlış sonuç verir? Gerçek çıktılarla sorunun kaynağı ve intl Transliterator ile kalıcı çözüm.

PHP'de Türkçe Büyük-Küçük Harf Dönüşümü: i, İ, ı, I Sorunu Yazılım

Türkçe yazılım geliştiren herkesin bir gün karşılaştığı bir hata var: kullanıcı adı "istanbul" yazıyor, ekranda "ISTANBUL" görünüyor. Ya da arama kutusuna "ILIK" yazılıyor, veritabanındaki "ılık" bulunamıyor. Sebep, Türkçenin noktalı ve noktasız i harflerinin büyük-küçük eşleşmesinin diğer dillerden farklı olması.

Bu blogun arama özelliğini yazarken aynı sorunla bir kez daha karşılaştım. Aşağıdaki çıktıların hepsi PHP 8.4'te çalıştırılarak alındı.

Sorun: dört harf, iki farklı kural

İngilizcede i'nin büyüğü I'dır. Türkçede ise iki ayrı çift var:

  • i ↔ İ (noktalı)
  • ı ↔ I (noktasız)

PHP'nin dönüşüm fonksiyonları dil bilgisi olmadan çalıştığı için varsayılan olarak İngilizce kuralı uyguluyor.

PHP'de strtoupper, mb_strtoupper ve Transliterator çıktılarının terminalde karşılaştırması
Aynı metin, üç farklı fonksiyon, üç farklı sonuç.

strtoupper: hiç denemeyin

var_dump(strtoupper("istanbul ılık iğdır"));
// string(23) "ISTANBUL ıLıK IğDıR"

strtoupper bayt bazında çalışır ve UTF-8'i bilmez. Sadece ASCII harfleri dönüştürür, ı ve ğ olduğu gibi kalır. Türkçe metinde kullanılmaması gerekiyor.

mb_strtoupper: neredeyse doğru

var_dump(mb_strtoupper("istanbul ılık iğdır", 'UTF-8'));
// string(20) "ISTANBUL ILIK IĞDIR"

Çok baytlı karakterleri tanıyor, ğ ve ı doğru dönüşüyor. Ama i yine I oluyor. "İstanbul" "Istanbul"a dönüşüyor. Ters yönde durum daha da ilginç:

var_dump(mb_strtolower("İSTANBUL ILIK", 'UTF-8'));
// string(15) "i̇stanbul ilik"

Çıktıda iki hata var. I harfi ı yerine i olmuş. Daha sinsi olanı ise ilk harf: İ, Unicode kurallarına göre i + "birleşen nokta" (U+0307) olarak küçültülmüş. Ekranda normal görünüyor, ama bu metin veritabanındaki "istanbul" ile eşleşmiyor. Bayt sayısının 15 olması da bundan.

ucfirst ve başlık biçimi

var_dump(mb_ucfirst("istanbul"));                              // "Istanbul"
var_dump(mb_convert_case("istanbul ılık", MB_CASE_TITLE, 'UTF-8')); // "Istanbul Ilık"

PHP 8.4 ile gelen mb_ucfirst da aynı sorunu taşıyor.

Çözüm: intl Transliterator

PHP'nin intl eklentisi, ICU kütüphanesinin dile özel dönüşüm kurallarını kullanabiliyor. Hosting sağlayıcıların neredeyse hepsinde kurulu geliyor.

$upper = Transliterator::create('tr-Upper');
$lower = Transliterator::create('tr-Lower');
$title = Transliterator::create('tr-Title');

$upper->transliterate("istanbul ILIK İzmir"); // "İSTANBUL ILIK İZMİR"
$lower->transliterate("istanbul ILIK İzmir"); // "istanbul ılık izmir"
$title->transliterate("istanbul ILIK İzmir"); // "İstanbul Ilık İzmir"

Üç sonuç da doğru. Transliterator nesnesini oluşturmak görece maliyetli olduğu için bir kez oluşturup tekrar kullanmak gerekiyor. Ben küçük bir yardımcı sınıfta tutuyorum:

final class Tr
{
    private static array $t = [];

    public static function upper(string $s): string { return self::t('tr-Upper')->transliterate($s); }
    public static function lower(string $s): string { return self::t('tr-Lower')->transliterate($s); }
    public static function title(string $s): string { return self::t('tr-Title')->transliterate($s); }

    public static function ucfirst(string $s): string
    {
        return self::upper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
    }

    private static function t(string $id): Transliterator
    {
        return self::$t[$id] ??= Transliterator::create($id);
    }
}

intl yoksa: harita yöntemi

Sunucuda intl eklentisi yoksa, sorunlu harfleri önce elle değiştirip geri kalanı mb_ fonksiyonlarına bırakmak işe yarıyor:

function tr_upper(string $s): string
{
    return mb_strtoupper(str_replace(['i', 'ı'], ['İ', 'I'], $s), 'UTF-8');
}

function tr_lower(string $s): string
{
    return mb_strtolower(str_replace(['İ', 'I'], ['i', 'ı'], $s), 'UTF-8');
}

Sıra önemli: str_replace mutlaka mb_ dönüşümünden önce çalışmalı.

Arama yaparken: veritabanı tarafı

Büyük-küçük harf duyarsız arama yaparken dönüşümü PHP'de yapmak yetmiyor; karşılaştırma veritabanında yapılıyor.

  • MySQL: Sütunun collation değeri utf8mb4_turkish_ci (MySQL 8'de utf8mb4_tr_0900_ai_ci) ise LIKE '%ılık%' sorgusu "ILIK" yazan kaydı da bulur. utf8mb4_unicode_ci ise ı ile i'yi farklı, I ile i'yi aynı harf sayar.
  • SQLite: LIKE yalnızca ASCII harflerde büyük-küçük harf duyarsız. Bu blogun yerel geliştirme ortamında SQLite kullandığım için arama terimini yukarıdaki harita yöntemiyle küçük harfli, tamamı büyük ve ilk harfi büyük biçimlere çevirip bunları OR ile arıyorum. Böylece arama, veritabanının karşılaştırma kuralından bağımsız çalışıyor. Basit ama gayet iyi iş görüyor.

JavaScript tarafı

Tarayıcıda iş daha kolay; dil parametresi vermek yeterli:

"istanbul".toLocaleUpperCase('tr-TR'); // "İSTANBUL"
"ILIK".toLocaleLowerCase('tr-TR');     // "ılık"
"istanbul".toUpperCase();              // "ISTANBUL"  ← dil vermezseniz

Son bir uyarı: Laravel'deki Str::upper, Str::title gibi yardımcılar da içeride mb_ fonksiyonlarını kullanıyor. Kullanıcıya görünen Türkçe metinlerde bu yardımcılar yerine yukarıdaki gibi dile duyarlı bir fonksiyon kullanmak gerekiyor.