Form Doğrulama: İstemci ve Sunucu
Dokuz alanlı bir kayıt formunda çift katmanlı doğrulama: kurallar tek kaynakta tanımlanır, hem JavaScript hem PHP aynı listeden okur. Canlı geri bildirim ve şifre gücü ölçer dahil.
Ekran Görüntüleri 1 görsel
Bu Örnek Ne Yapıyor?
- Dokuz alanlı kayıt formu; her alan hem tarayıcıda hem sunucuda doğrulanıyor
- Kurallar system/rules.php içinde bir kez VERİ olarak tanımlı — iki taraf aynı listeden okuyor
- İstemci doğrulaması atlandığında sunucunun 422 döndürdüğü ve kayıt oluşmadığı ölçülmüş
- Kullanıcı adı ve e-posta için yazarken canlı benzersizlik kontrolü (Ajax)
- Şifre gücü ölçer ve bcrypt'in 72 karakter sınırı gibi gerçek sınırların işlenmesi
- İki listenin ayrışmasıyla oluşan altı somut hata tablo hâlinde ölçülüp belgelenmiş
- TOCTOU zinciri: "müsait" cevabı ile kaydın yazılması arasındaki yarış koşulu ele alınmış
Nerede İşe Yarar?
- Üyelik ve kayıt formlarında kullanıcı adı / e-posta benzersizliğinin kontrolü
- Var olan bir projede istemci ve sunucu kurallarının ayrışmasının giderilmesi
- Şifre politikası (uzunluk, büyük/küçük harf, rakam) uygulanan her form
- Ekip içi eğitimde "tarayıcıdaki kontrol neden güvenlik değildir" örneği
- Çok alanlı başvuru ve sipariş formlarında alan bazlı hata gösterimi
Gereksinimler
- PHP 8.0+ · MySQL 5.7+ veya MariaDB 10.3+ · Apache/mod_rewrite · Tarayıcıda JavaScript
Nasıl Kurulur?
- Proje dosyalarını web köküne kopyalayın (örn. XAMPP'ta htdocs veya kendi web sunucunuzun kök dizini)
- cy_validation.sql dosyasını içe aktarın — kullanıcı tablosunu ve benzersiz indeksleri oluşturur
- system/config.php içindeki DB_HOST, DB_NAME, DB_USER ve DB_PASS değerlerini düzenleyin
- Formu açıp bir alana yanlış veri yazarak canlı geri bildirimi deneyin
- İsterseniz JavaScript'i kapatıp formu gönderin: sunucu aynı kuralları tek başına uygular
Veritabanı şeması projedeki cy_validation.sql dosyasında.
Nasıl Çalışıyor?
Form Doğrulama Örneği
İstemci + sunucu çift katmanlı form doğrulama — kurallar tek kaynaktan.
Canlı geri bildirim · Şifre gücü ölçer · AJAX benzersizlik kontrolü · TOCTOU zinciri
cilginyazilim.com · Türkçe | English
Canlı Demo
Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.
Tek karede projenin tamamı: e-posta canlı olarak sorulup “zaten kayıtlı” dönmüş, kullanıcı adının altında yeşil “✓ Müsait”, şifre ölçeri “Çok Güçlü”, telefon ve doğum tarihi kırmızı ve gerekçeli. Form, siz yazarken cevap veriyor.▲ Görsele tıklayarak demoyu açabilirsinizAlanları boş bırakın, bozuk e-posta yazın, alınmış bir kullanıcı adı deneyin — hepsi anında.
Bu proje ne yapıyor?
Dokuz alanlı bir kayıt formu. Her alan hem anında (JavaScript, kullanıcı deneyimi için) hem de sunucuda (PHP, güvenlik sınırı olarak) doğrulanır. Kullanıcı adı ve e-posta için “müsait mi?” sorusu, yazarken CANLI olarak sorulur.
Ama projenin anlattığı asıl şey bir form değil, formun altındaki üç soru:
1. İstemci doğrulaması neden güvenlik değildir, sunucu doğrulaması neden tek sınırdır?
2. Aynı kural iki dilde iki kez yazıldığında ne olur — ve bu yapısal olarak nasıl çözülür?
3. “Bu kullanıcı adı müsait” cevabı ne zaman yalan olur, ve bu yalan hangi katmanda yakalanır?
1. Altın kural: istemci doğrulaması güvenlik değildir
Tarayıcıdaki hiçbir kontrol güvenlik önlemi değildir.
Bu bir slogan değil, ölçülebilir bir gerçektir. Tarayıcı konsolunu açıp CyValidation nesnesini silebilir, assets/js/validation.js dosyasını hiç yüklemeyebilir ya da formu hiç açmadan doğrudan system/ajax.php'ye istek atabilirsiniz. Bu depoda tam olarak bu yapıldı — JavaScript hiç çalıştırılmadan, kabuktan doğrudan POST edildi:
POST system/ajax.php
full_name=x email=gecersiz username=1KOTU
password=••••••••
birth_date=2030-13-45 terms=0
→ HTTP 422
errors: full_name, email, username, password,
password_confirm, birth_date, terms
→ Oluşan kayıt: 0İstemci tarafı ne için var öyleyse? Kullanıcının hatayı görmek için sayfanın yenilenmesini beklememesi için. Tek işlevi budur ve bu değersiz bir işlev değildir — ama güvenlikle hiçbir ilgisi yoktur.
Sunucu tarafı neden tek sınırdır? Çünkü saldırganın kontrol edemediği tek yer orasıdır. Tarayıcıdaki kod kullanıcının makinesinde, kullanıcının denetiminde çalışır; kural koyduğunuz yer, kuralı uygulayan taraf değildir.
2. İki dilde tek kural: ayrışma sorunu ve yapısal çözümü
Bu projenin en öğretici mühendislik kararı burasıdır.
Sorun ölçüldü
Eski sürümde her JavaScript doğrulayıcısının üstünde // bkz. function.php validate_email() gibi bir yorum vardı. Yani “bu iki liste aynı olmalı” yorumla söylenmişti. Yorum derlenmez, test edilmez, kırılmaz. Sonuç, gerçek formu tarayıcıda sürerek ölçüldü:
| Girdi | Sunucu | İstemci | Sonuç |
|---|---|---|---|
| E-posta, 191 karakter | ❌ reddetti | ✅ kabul etti | Kullanıcı formu gönderiyor, sunucudan hata yiyor |
| Şifre, 73 karakter | ❌ reddetti | ✅ kabul etti | Aynı |
| Mesaj, 495 harf + 20 boşluk | ✅ kabul etti | ❌ reddetti | Geçerli girdi istemcide engelleniyor |
| Ad soyad, 60 astral harf | ✅ kabul etti | ❌ reddetti | Aynı (.length yüzeyde 120 sayıyor) |
Kullanıcı adı "şş" | “desen hatası” | “uzunluk hatası” | Aynı girdiye iki farklı gerekçe |
Kullanıcı adı "şşşşşşşşşşş" | “uzunluk hatası” | “desen hatası” | Aynı, ters yönde |
Bu iki sınırın (190 / 72) istemcide hiç yazılmamış olması bir dikkatsizlik değil, kaçınılmaz bir sonuçtur: aynı bilgi iki yerde tutulduğu her yerde, zamanla ayrışır.
Çözüm: sınırları VERİ hâline getirmek
Sayısal sınırlar, desenler ve hata mesajları artık system/rules.php içinde bir kez tanımlanır:
'password' => [
'required' => true,
'min' => 8,
'max' => 72, // bcrypt'in sert sınırı
'pattern' => '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
'messages' => [
'length' => 'Şifre en az {min} karakter olmalıdır.',
'max' => 'Şifre en fazla {max} karakter olabilir.',
'pattern' => 'Şifre en az bir büyük harf, bir küçük harf ve bir rakam içermelidir.',
],
],
- PHP doğrulayıcıları bu diziyi
rule_check()üzerinden okur. index.phpaynı diziyiclient_rules()ile JSON'a çevirip sayfaya gömer.validation.jso JSON'u okur. Dosyada72,190,100gibi tek bir sayı bile yazılı değildir.- HTML
maxlengthdeğerleri de aynı diziden basılır — yani aynı sınırın üçüncü bir elle yazılmış kopyası da yok.
Bağlantının kanıtı
Yorumla “aynı olmalı” demek yetmediği için, bağın gerçekten var olduğu ölçüldü. rules.php'de iki sayı değiştirildi (full_name.max 100 → 40, birth_date.min_age 18 → 21) ve başka hiçbir dosyaya dokunulmadı:
| Önce | Sonra | |
|---|---|---|
| Sunucu – 50 harflik ad | kabul | Ad soyad 2-40 karakter arasında olmalıdır. |
| İstemci – 100 harflik ad | kabul | Ad soyad 2-40 karakter arasında olmalıdır. |
| Sunucu – 19 yaşındaki tarih | kabul | …en az 21 yaşında olmalısınız. |
| İstemci – 17 yaşındaki tarih | …en az 18… | …en az 21 yaşında olmalısınız. |
Yalnızca sayı değil, mesaj cümlesi de birlikte hareket etti — çünkü mesajlar da {min} / {max} / {age} yer tutucularıyla aynı kaynaktan üretiliyor.
Bu çözümün kapsamadığı şey (dürüst sınır)
Yalnızca veriye dönüştürülebilen kısım paylaşılır. Şunlar her dilde ayrı yazılmak zorundadır ve bilinçli olarak ayrı bırakılmıştır:
| Yordam | PHP | JavaScript | Neden paylaşılamaz |
|---|---|---|---|
| E-posta biçimi | filter_var(FILTER_VALIDATE_EMAIL) | basit desen | Tarayıcıda birebir karşılığı yok |
| Yaş hesabı | DateTimeImmutable::diff() | elle hesap | Farklı tarih kitaplıkları |
| Telefon normalleştirme | preg_replace + substr | replace + slice | Aynı mantık, farklı sözdizimi |
E-posta deseni istemcide bilerek daha gevşektir. Gevşek istemci, sunucunun kabul edeceği bir adresi reddetme hatasına düşmez; ters yön ise yalnızca fazladan bir sunucu turudur. Yani ayrışmanın hangi yöne olabileceği de bir tasarım kararıdır.
Paylaşılan desenler ayrıca iki motorda da aynı anlama gelen alt kümeyle sınırlıdır (\p{L}, \p{M}, \d, \s, karakter sınıfları, çapalar). PHP'ye özgü hiçbir şey kullanılmaz — kullanılsaydı desen tarayıcıda sessizce farklı davranır ve çözmeye çalıştığımız sorun geri gelirdi.
3. “Müsait mi?” — UX değeri ile sayım açığı arasındaki ödünleşim
Kullanıcı adı/e-posta yazarken 500 ms geciktirmeli bir AJAX isteği “müsait mi?” diye sorar. Bu özelliğin iki yüzü vardır ve bu depo ikisini de ölçtü.
İyi yüzü: gerçek bir UX kazancı
Kullanıcı, formu doldurup gönderdikten sonra “bu kullanıcı adı alınmış, baştan seç” duvarına çarpmaz. Cevabı yazarken alır. Ekran görüntüsündeki yeşil “✓ Müsait” budur.
Kötü yüzü: sayım (enumeration) açığı
Bu uç nokta, tanımı gereği “bu e-posta bu sitede kayıtlı mı?” sorusunu yanıtlar. Elinde e-posta listesi olan biri, listeyi tek tek sorup hangi adreslerin kayıtlı olduğunu öğrenebilir. Ölçüm, korumasız hâlde bunun ne kadar kolay olduğunu gösterdi: 60 ardışık sorgu, 60 kez HTTP 200.
Zamanlama tarafı da ölçüldü — ve orada sorun çıkmadı:
check_email, kayıtlı vs kayıtsız adres (150'şer dönüşümlü istek)
medyan farkı : 0,065 ms
p10 farkı : 0,024 ms
ölçüm gürültüsü: 0,431 ms
→ Zamanlamadan bilgi sızmıyor.Zaten sızmasına gerek yok: cevap düz metin olarak veriliyor. Bu, güvenlikte sık rastlanan bir yanılgının iyi bir örneğidir — yan kanal aramadan önce ön kapıya bakın.
Verilen karar ve gerekçesi
Canlı kontrol KALDI. Üstüne hız sınırı eklendi. Gerekçe:
- Özelliği kaldırmak, projeyi kaldırmak olurdu. Bu depo tam olarak bu özelliği anlatıyor. Anlattığı şeyi silen bir “düzeltme” öğretici değildir.
- Sızdırılan bilgi tek bir bite indirildi. Uygulama, kayıtlı veriyi hiçbir yerde listelemez — ekranda gösterilen bir kayıt listesi, arama, sayaç ya da profil yoktur. Uç noktanın verebileceği tek bilgi, sorulan tek bir değer için “var / yok”tur. Toplu bir liste çekmenin yolu kapalıdır; kalan tek yol, değerleri tek tek denemektir — ve hız sınırı tam olarak bunu pahalılaştırır.
- Kısıtlama iki alana da uygulandı. Kullanıcı adı ile e-posta aynı kotayı paylaşır. Ayrı kota verseydik, sayım yapan biri iki kotayı da doldurup iki kat istek atardı.
- Hız sınırı sayımı imkânsız değil, PAHALI kılar. Bu dürüstçe söylenmelidir: dağıtık bir saldırgan (bot ağı) her istekte farklı IP kullanarak sınırı aşar. Sayımı tümüyle bitirmenin tek yolu özelliği kaldırmaktır.
Gerçek bir üründe ne yapardınız? Kayıt akışını “her zaman başarılı görünen” bir akışa çevirir, sonucu e-postayla bildirirsiniz (“bu adres zaten kayıtlıysa giriş bağlantısı gönderdik”). Böylece uç nokta hiçbir şey söylemez. Bunun bedeli, buradaki canlı geri bildirimin tümüyle kaybolmasıdır. Bu depo, bir demo olduğu için ödünleşimin UX tarafını seçti ve seçimini yazıya döktü — asıl öğretici olan da budur.
Hız sınırı nasıl ayarlandı?
| Uç nokta | Sınır | Neden bu sayı |
|---|---|---|
check_username / check_email | 40 / dakika (ortak kota) | Gerçek bir kullanıcı formu doldururken ~10-15 istek atar (500 ms geciktirme sayesinde). 16 istekle ölçüldü: sınır vurmuyor. |
submit | 5 / dakika | Bir insan dakikada 5 kez kayıt olmaz. Asıl amaç işlemciyi korumak — aşağıya bakın. |
submit sınırının gerçek gerekçesi ölçümdür: password_hash() bu makinede ~116 ms CPU harcıyor (bcrypt, cost 10) — bu, bir gönderimin toplam süresinin (~128 ms) yaklaşık %90'ı. Kimliği doğrulanmamış bir istekle 116 ms işlemci yaktırabilmek, ucuz bir hizmet dışı bırakma kaldıracıdır. Sınır, doğrulamadan önce uygulanır; sonraya bırakılsaydı geçersiz form gönderen bir bot sınıra hiç takılmadan sunucuyu meşgul ederdi.
Sayaç veritabanına değil, flock() ile kilitlenen bir dosyaya yazılır: her istekte bir INSERT atmak, korumaya çalıştığınız yükün ta kendisini üretirdi.
4. TOCTOU zinciri: üç katman, ölçülmüş
“Müsait” cevabı, kaydın başarılı olacağını garanti etmez:
Kullanıcı A: "ahmet" yazdı → canlı kontrol: müsait ✓
Kullanıcı B: "ahmet" yazdı → canlı kontrol: müsait ✓ (A henüz kaydetmedi)
Kullanıcı A: Kaydı gönderir → başarılı, "ahmet" alındı
Kullanıcı B: Kaydı gönderir → ???Bu klasik TOCTOU (time-of-check / time-of-use) açığıdır. Üç katmanla kapatılır:
| # | Katman | Nerede | Ne yakalar |
|---|---|---|---|
| 1 | Canlı kontrol | handle_check_username / handle_check_email | Hiçbir şey — yalnızca UX. Garanti vermez. |
| 2 | Son kontrol | handle_submit() içinde yeniden sorgu | Çakışmaların büyük çoğunluğu |
| 3 | UNIQUE indeks | Veritabanı | Gerçek eşzamanlılık — son söz |
Zinciri kırmayı denedim, kırılmadı
Aynı gönderim eşzamanlı iki istekle 12 tur denendi:
| Senaryo | Sonuç | Oluşan kayıt |
|---|---|---|
| Aynı oturumdan iki istek | 200 + 422 (12/12) | her turda 1 |
| Farklı oturumlardan iki istek | 200 + 409 (12/12) | her turda 1 |
Mükerrer kayıt hiçbir turda oluşmadı.
Aradaki fark öğreticidir: aynı PHPSESSID ile gelen iki istek, PHP'nin oturum kilidi yüzünden sıraya girer — ikinci istek birincisi bittikten sonra çalışır ve 2. katman (son kontrol) onu yakalar. Gerçek yarış ancak farklı oturumlardaki iki kullanıcı arasında oluşur; orada 2. katman yetmez ve devreye 3. katman girer: UNIQUE indeks SQLSTATE 23000 fırlatır, uygulama bunu yakalayıp HTTP 409 ve anlaşılır bir mesajla döner. Ham SQL hatası kullanıcıya sızdırılmaz.
Yani 3. katman “ihtimale karşı” değildir — ölçümde 12/12 tetiklendi. İki katmanla yetinen bir kod, o 12 durumda mükerrer kayıt üretirdi.
5. Doğrulama kuralları — ve her birinin NEDENİ
| Alan | Kural | Zorunlu | Neden böyle |
|---|---|---|---|
| Ad Soyad | 2-100 karakter, \p{L}\p{M} + boşluk, ., ', - | ✔ |
\p{L} her dildeki harfi kapsar: “Ayşe”, “O'Brien”, “Jean-Luc” geçer. Rakam ve ` → 422 (\p{L}` deseni engelliyor). Kayıtlı veri zaten hiçbir yanıtta geri dönmediği için, saklanan (stored) XSS'in çıkabileceği bir yüzey de yok.
- Veri sızıntısı: Hiçbir uç nokta kayıt döndürmüyor. E-posta, telefon ve
password_hashyalnızca veritabanında; yanıtlarda arandı, bulunamadı. - Ölçek: 100.000 kayıtta
check_emailmedyanı ~6-7 ms;EXPLAINçıktısıtype=const, key=uniq_submissions_email, rows=1— indeks kullanılıyor.
7. API uç noktaları
Hepsi system/ajax.php üzerinde, POST ile, CSRF token zorunlu.
action | Girdi | Başarı | Hata |
|---|---|---|---|
check_username | username | 200 {available, reason} | 403 429 |
check_email | email | 200 {available, reason} | 403 429 |
submit | Tüm form alanları | 200 {success, id} | 422 (alan hataları) · 409 (yarış) · 403 · 429 |
Bunlar tek uç noktalardır. Kayıtları okuyan bir uç yoktur: submissions tablosu yalnızca yazılır ve benzersizlik sorgularında karşılaştırma için okunur; hiçbir yanıtta kayıt listesi dönmez.
HTTP durum kodları ve anlamları
| Kod | Ne zaman | Neden bu kod |
|---|---|---|
200 | Başarılı — canlı kontroller dâhil | “Bu ad alınmış” bir cevaptır, hata değil. İstemci available alanına bakar. |
400 | Bilinmeyen veya boş action | İstek anlaşılmadı |
403 | CSRF token yok/geçersiz | İstek anlaşıldı, yetkilendirilmedi. 419 kullanmayın — bu Apache onu 500'e çeviriyor (ölçüldü) |
405 | POST dışı yöntem | Yöntem desteklenmiyor |
409 | Yarış durumu: UNIQUE indeks reddetti | Çakışma (conflict) — istek geçerliydi ama kaynağın durumu değişti |
422 | Alan doğrulama hataları | İstek biçimi doğru, içeriği işlenemez. Tüm hatalar errors{} içinde tek seferde döner |
429 | Hız sınırı aşıldı | Retry-After başlığı ve retry_after alanı ile |
Neden tüm hatalar tek seferde dönüyor? Kullanıcıyı “bir hatayı düzelt, sonrakini gör” döngüsüne sokmak, uzun bir formda kötü bir deneyimdir. Ölçüldü: yedi bozuk alanla gönderilen bir istek, yedi hatanın tamamını tek yanıtta döndürüyor.
8. Veritabanı şeması
CREATE TABLE `submissions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`full_name` VARCHAR(100) NOT NULL,
`email` VARCHAR(190) NOT NULL, -- rules.php'deki 'max' ile AYNI sayı
`username` VARCHAR(20) NOT NULL,
`phone` VARCHAR(20) DEFAULT NULL,
`password_hash` VARCHAR(255) NOT NULL, -- password_hash() çıktısı
`birth_date` DATE DEFAULT NULL,
`message` VARCHAR(500) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_submissions_email` (`email`), -- TOCTOU zincirinin
UNIQUE KEY `uniq_submissions_username` (`username`) -- son halkası
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Örnek veri: 60 kayıt — ve password_hash sütununda ne var?
Kurulum dosyası 60 örnek kayıtla gelir. Neden? Boş bir tabloyla kurulan proje kendi en önemli özelliğini gösteremez: canlı benzersizlik kontrolü denenemez — çakışacak kayıt olmadığı için her kullanıcı adı ve her e-posta “müsait” çıkar. Yani projeyi indiren kişi, formun yazarken cevap veren yanını hiç görmez. 60 kayıtla, kullanıcı adı alanına ahmet yazıp kırmızı “zaten alınmış”, ahmet2 yazıp yeşil “✓ Müsait” cevabını ilk denemede görürsünüz.
Bu kayıtlar arayüzde listelenmez. Tablo hiçbir uçtan okunup ekrana basılmaz; yalnızca email_exists() / username_exists() sorgularının karşılaştırma yaptığı veri kümesidir.
password_hash sütununa ne kondu? 60 satırın hepsinde aynı, gerçek bir bcrypt çıktısı — OrnekParola123 parolasının hash'i. Üç sebeple sorun değildir:
- Bu bir giriş sistemi değildir. Hiçbir yerde
password_verify()çağrılmaz; bu sütun hiçbir kapıyı açmaz. Varlık sebebi tek bir prensibi göstermektir: parola düz metin saklanmaz. - Parola kamuya açıktır ve kasıtlıdır — bu satırda ve SQL dosyasının yorumunda yazılıdır. Sızabilecek bir sır yoktur.
- Düz metin yazmadık. Örnek veri bile olsa o sütunda düz metin görmek, kopyalayarak öğrenen birine yanlış deseni öğretirdi.
Gerçek bir sistemde bunu yapmayın: aynı hash'i çok kullanıcıya vermek, “bu iki kullanıcının parolası aynı” bilgisini sızdırır.password_hash()her çağrıda rastgele bir tuz (salt) üretir; aynı parola bile her seferinde farklı bir hash verir. Buradaki tekrarın tek sebebi SQL'inpassword_hash()çağıramamasıdır. Uygulamanın kendisi (system/ajax.php) her kayıttapassword_hash()çağırır — yani formdan giren gerçek kayıtlar tekil hash alır.
9. Kurulum
cd C:/xampp/htdocs
git clone https://github.com/CilginYazilim/form-validation-example.git
mysql -u root -p < form-validation-example/cy_validation.sql
İsteğe bağlı — kendi veritabanı bilgileriniz:
cp .env.example .env(Windows:copy .env.example .env) deyipDB_*
satırlarını doldurun. Bu dosya olmadan da çalışır; varsayılanlar yerel bir
XAMPP kurulumuna (root, boş parola) göredir..env.gitignore
içindedir — parolanız depoya gitmez.
Ya da phpMyAdmin → İçe Aktar → cy_validation.sql → Başlat.
Sonra: http://localhost/form-validation-example/
Gereken tek şey PHP 8.0+, MySQL 5.7+ ve bir Apache. Composer yok, npm yok, derleme adımı yok — jQuery ve Bootstrap depoda gömülü gelir.
Canlıya alırkensystem/config.phpiçindekiAPP_DEBUG'ıfalseyapın.
Ortam değişkenleri
Depo kökündeki .env dosyasına yazın; system/config.php dosyasına
hiç dokunmayın:
cp .env.example .env # Windows: copy .env.example .env.env .gitignore içindedir: depoya gönderilmez ve dağıtım (deploy) onusilmez.
system/config.php ise depoda durur ve her dağıtımda depodakisürümle değiştirilir — parolayı oraya yazarsanız hem GitHub'a gider hem de
ilk deploy'da kaybolur.
Dosyayı hiç oluşturmasanız da uygulama çalışır; aşağıdaki varsayılanlar
yerel bir XAMPP kurulumuna göredir.
Değer arama sırası: .env → sunucunun gerçek ortam değişkeni
(Apache SetEnv, systemd…) → buradaki varsayılan.
| Değişken | Varsayılan | Ne işe yarar |
|---|---|---|
DB_HOST | 127.0.0.1 | Veritabanı sunucusu |
DB_NAME | cy_validation | Veritabanı adı |
DB_USER | root | Kullanıcı |
DB_PASS | (boş) | Şifre — koda yazmayın |
APP_TIMEZONE | Europe/Istanbul | PHP'nin saat dilimi |
APP_DEBUG | ortamdan | Hataların ekrana basılıp basılmayacağı |
APP_TIMEZONE neden var? XAMPP'ın php.ini dosyasındaki
date.timezone, MySQL'in kullandığı sistem diliminden farklı olabilir.
Test makinesinde PHP Europe/Berlin, MySQL Europe/Istanbul
kullanıyordu; aynı anı anlatan iki satır bir saat farklı görünüyordu.
Zaman hesapları SQL tarafında yapıldığı için doğruydu, ama ekrana
basılan saat kayıyordu. Artık dilim açıkça sabitleniyor — sunucunuz başka
bir bölgedeyse bu değişkeni tanımlamanız yeterli, koda dokunmayın.
10. Dosya yapısı
form-validation-example/
├── index.php ← Form; kuralları JS'e aktarır, temayı çizimden önce uygular
├── .env.example ← Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_validation.sql ← Veritabanı kurulumu + 60 örnek kayıt
├── .htaccess ← Dizin listeleme kapalı, .sql/.md kapalı, güvenlik başlıkları
├── system/
│ ├── .htaccess ← Beyaz liste: yalnızca ajax.php açık
│ ├── config.php ← Oturum güvenliği, PDO, hız sınırı ayarları
│ ├── rules.php ← ⭐ KURALLARIN TEK KAYNAĞI (PHP + JS buradan okur)
│ ├── function.php ← Doğrulayıcılar, CSRF, hız sınırı, veri erişimi
│ └── ajax.php ← check_username / check_email / submit
└── assets/
├── css/cilginyazilim.css ← Ortak marka tasarımı (dokunmayın)
├── css/style.css ← Sayfaya özel stiller + mobil düzen, tema/şifre düğmeleri
└── js/validation.js ← Canlı doğrulama, şifre ölçeri, sayaç, tema/şifre düğmeleri
Hangi fonksiyon ne işe yarıyor?
| Fonksiyon | Dosya | İşi |
|---|---|---|
validation_rules() | rules.php | Tüm kural tanımlarını döndürür — tek kaynak |
rule_check() | rules.php | Ortak kısmı uygular: zorunluluk → uzunluk → desen |
rule_message() | rules.php | {min} / {max} / {age} yer tutucularını doldurur |
client_rules() | rules.php | Kuralların istemciye gidecek JSON hâli |
validate_*() | function.php | Alana özgü yordam (normalleştirme, tarih, URL) + rule_check() |
require_csrf() | function.php | Token doğrular, yoksa 403 |
rate_limit() | function.php | Kayan pencere sayacı, aşılırsa 429 + Retry-After |
email_exists() / username_exists() | function.php | Benzersizlik sorgusu (canlı kontrol + son kontrol aynı fonksiyonu kullanır) |
handle_submit() | ajax.php | Tüm alanları doğrular, hataları tek seferde toplar, kaydeder, 23000 → 409 |
ruleCheck() | validation.js | rule_check()'in birebir istemci karşılığı |
codePointLength() | validation.js | Kod noktası sayar — .length değil (astral ayrışmasının çözümü) |
passwordScore() | validation.js | Ölçer puanı; zorunlu kural sağlanmadıkça “Zayıf”ı geçmez |
setFieldError() | validation.js | Hata metnini yazan tek yer; .is-shown sınıfını da o ekler |
revealField() | validation.js | Hatalı alanı ekranın ortasına kaydırır, sonra odaklar (mobil klavye) |
togglePassword() | validation.js | Şifreyi göster/gizle; imleç konumunu korur |
toggleTheme() | validation.js | Koyu/açık tema; tercih localStorage'da |
11. Özelleştirme
Bir sınırı değiştirmek → yalnızca system/rules.php. PHP, JavaScript ve HTML maxlength birlikte hareket eder:
'full_name' => [ 'min' => 2, 'max' => 40, … ], // 100 yerine 40
'birth_date' => [ 'min_age' => 21, … ], // 18 yerine 21Yeni alan eklemek:
rules.php→ kural tanımıfunction.php→validate_yeni_alan()(yordamsal kısım varsa)ajax.php→handle_submit()içindeki listeye ekleindex.php→ `+data-error-for="yeni_alan"- validation.js
→validators.yeni_alan+fieldOrder` - SQL → sütun
Hız sınırını değiştirmek → system/config.php içindeki RATE_LIMIT_* sabitleri.
Vekil (proxy) arkasında çalıştırmak → client_fingerprint() fonksiyonu bilerek yalnızca REMOTE_ADDR okur; X-Forwarded-For okunmaz çünkü o başlık istemci tarafından uydurulabilir ve sınırı tek satırla kapatılabilir hâle getirir. Ters vekil arkasındaysanız orayı bilinçli olarak değiştirin.
12. Arayüz: mobil, tema ve erişilebilirlik
Doğrulama mantığı 1.0.0'da yerine oturmuştu. 1.1.0 tümüyle bir arayüz sürümüdür: system/ altındaki hiçbir dosya değişmedi — yani bu bölümdeki hiçbir şey güvenlik sınırına dokunmaz. Değişenler yalnızca index.php, assets/css/style.css ve assets/js/validation.js.
Aşağıdaki maddelerin her biri telefonda somut olarak yaşanan bir sorunu çözer; "daha güzel dursun" diye eklenmiş süs yoktur.
iOS'ta odaklanınca sayfanın yakınlaşması
Safari (iOS), yazı boyutu 16 px'ten küçük bir alana odaklanıldığında sayfayı otomatik yakınlaştırır — ve alandan çıkınca geri almaz. Sonuç: form yatay kayar, kalan alanların yarısı ekranın dışında kalır. Tek gerçek çözüm, dokunulan alanın yazı boyutudur:
@media (max-width: 575.98px) {
.cy-app .form-control,
.cy-app .form-select { font-size: 16px; }
}`
içine maximum-scale=1` yazıp yakınlaştırmayı tümden kapatmak da "işe yarar" — ama az gören bir kullanıcının sayfayı büyütmesini de engeller. Bu bir erişilebilirlik ihlalidir ve bu depoda kullanılmadı.
Klavye, alana göre açılsın
type özniteliği doğrulama içindir; hangi klavyenin açılacağını inputmode söyler ve ikisi her tarayıcıda aynı şey değildir.
| Alan | Eklenen | Ne değişti |
|---|---|---|
| E-posta | inputmode="email" autocapitalize="none" autocorrect="off" | @ ve . tuşları doğrudan görünür; iOS artık adresin ilk harfini büyütmüyor ve yazılanı "düzeltmiyor" |
| Telefon | inputmode="tel" | Harf klavyesi değil tuş takımı açılır |
| Kullanıcı adı | autocapitalize="none" spellcheck="false" | Kural küçük harfle başlamayı şart koşuyor; klavyenin ilk harfi büyütmesi, kullanıcıyı doğrudan hata mesajına götürüyordu |
| Ad soyad | autocapitalize="words" | Baş harfleri klavye kendisi büyütür |
| Tümü | enterkeyhint | Enter tuşu "İleri" / "Bitti" olarak etiketlenir |
Bildirimler yukarıdan aşağı taşındı
Ölçülen sorun: toast'lar sağ üste sabitlenmişti. Telefonda gönder düğmesi ekranın altındadır; kullanıcı düğmeye baktığı anda ekranın öbür ucunda beliren bildirimi kaçırıyordu. Artık dar ekranda alttan ve tam genişlikte gelir — parmağın ve gözün zaten bulunduğu yerden. Alt kenardaki jest çubuğunun altında kalmaması için env(safe-area-inset-bottom) kadar boşluk bırakılır.
Hatalı alana kaydırma
.focus() tek başına yetmiyordu: tarayıcı alanı ekrana getiriyor, ama aynı anda açılan klavye görünür alanı yarıya indiriyor ve hata satırı klavyenin altında kalıyordu. Kullanıcı "bir şey oldu ama ne?" diyordu.
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
node.focus({ preventScroll: true });Sıra önemlidir:
focus() önce çağrılırsa tarayıcının kendi otomatik kaydırması bizimkinin üzerine yazar. Aynı işlem, sunucudan 422 ile dönen hatalar için de uygulanır — o hata neredeyse her zaman ekranın görünmeyen bir yerindedir.
Şifreyi göster / gizle
Mobilde bir nezaket değil, gerekliliktir: küçük bir klavyede büyük harf + küçük harf + rakam zorunluluğu olan bir şifreyi göremeden yazmak, formun en sık terk edildiği yerdir.
İki ayrıntı bilinçlidir:
- İmleç korunur.
typedeğiştirmek imleci alanın sonuna atar; kullanıcı şifrenin ortasındaki bir harfi düzeltirken göze bastıysa imleci kaybetmesi kabul edilemez. Konum okunup geri yazılır. - Gönderim sonrası kapanır. Kayıt tamamlandığında açık kalmış hiçbir şifre ekranda durmaz.
Bootstrap'in .input-group'u kullanılmadı: .is-invalid ile birlikte kenarlık yarıçaplarını bozuyor ve .invalid-feedback'i yanlış yere düşürüyor. Düğme, input'un üzerine bindirilir; input tek parça kalır.
.invalid-feedback neden bir sınıfla yönetiliyor?
Bootstrap'in hata satırı, "hemen önceki kardeşim .is-invalid mi?" diye bakar (.form-control.is-invalid ~ .invalid-feedback). Şifre alanları göster/gizle düğmesi yüzünden bir sarmalayıcının içine girince bu kardeşlik kırıldı ve mesaj hiç görünmez oldu.
Çözüm, seçiciyi her yerleşime göre yeniden yazmak değil; hata metnini yazan tek fonksiyonun (setFieldError) .is-shown sınıfını da eklemesi oldu. Kural artık alanın DOM'daki yerinden bağımsız. Metin üç ayrı yerden yazılıyordu (anlık doğrulama, canlı benzersizlik yanıtı, sunucunun 422 cevabı); üçü de tek fonksiyona indirildi — aksi hâlde biri unutulacaktı.
Koyu / açık tema düğmesi
cilginyazilim.css koyu temayı zaten destekliyordu (prefers-color-scheme ve data-cy-theme), ama kullanıcının seçme yolu yoktu. Başlıktaki düğme bunu ekler ve tercih localStorage'da saklanır.
Tema, index.php'nin ` bölümündeki satır içi bir blokla, sayfa çizilmeden önce uygulanır. validation.js` sayfanın sonunda yüklenir; temayı orada uygulasaydık koyu temayı seçmiş bir kullanıcı her açılışta yarım saniyelik beyaz ekran görürdü.
Seçim yapılmamışsa data-cy-theme özniteliği hiç yazılmaz — o durumda işletim sisteminin tercihi geçerlidir. İlk tıklamada "şu an hangi temadayız?" sorusu matchMedia ile tarayıcıya sorulur; kendi varsayımımızı yazsaydık, koyu temadaki bir kullanıcının ilk tıklaması hiçbir şeyi değiştirmemiş gibi görünürdü.
`` iki ayrı değerle verilir, böylece mobil tarayıcının adres çubuğu da sayfanın zeminiyle aynı renge boyanır.
Yan panel mobilde katlanır
"İki Katmanlı Doğrulama" paneli açıklayıcı metindir; formu doldurmak için gerekli değildir. Telefonda formun altında üç paragraf hâlinde durunca, gönder düğmesinden sonra gereksiz bir kaydırma kuyruğu bırakıyordu. Artık lg altında kapalı başlar; masaüstünde açık gelir ve katlama düğmesi hiç görünmez.
Dokunma hedefleri ve onay kutusu
Tema düğmesi 44×44 px, gönder düğmesi 50 px yüksekliğinde, metin alanları 46 px. Onay kutusu 1 rem'den 1.35 rem'e büyütüldü — Bootstrap'in negatif margin-left'i de birlikte büyütüldü, yoksa etiket kutunun üstüne biner.
"Kullanım şartları" bağlantısı eskiden href="#" + onclick="return false" idi: dokunulunca hiçbir şey olmayan bir bağlantı, telefonda "bozuk" izlenimi verir. Artık gerçekten bir metin açan bir modal var, ve işaretleme ` değil ` — çünkü yaptığı şey gezinmek değil, bir şey açmak.
Erişilebilirlik
- Her alan
aria-describedbyile kendi yardım ve hata satırına bağlandı. - Hata durumunda
aria-invalid="true"yazılır: ekran okuyucu, alanın geçersiz olduğunu rengi görerek anlayamaz. - Canlı "Müsait" sonucu
role="status"+aria-live="polite"ile duyurulur; yeşil tik tek başına yetmez. - Odak halkası
:focus-visibleile verilir — yalnızca klavyeyle gezerken görünür, fareyle tıklayanda görünmez. Halkayı tümüyle kaldırmak (outline: none) klavye kullanıcısını sayfada kaybeder. viewport-fit=coverile çentikli ekranlarda güvenli alan boşluklarıenv()üzerinden verilir.
13. Örnek kullanım alanları
- Üyelik / kayıt formları — projenin doğrudan konusu.
- İletişim ve talep formları — canlı benzersizlik kontrolünü çıkarıp kalan katmanları kullanın.
- Etkinlik / başvuru kayıtları — yaş sınırı, sözleşme onayı ve mükerrer başvuru engeli hazır gelir.
- Bülten aboneliği — e-posta benzersizliği + hız sınırı, tam olarak ihtiyaç duyulan iki şey.
- Yönetim paneli “kullanıcı ekle” ekranı — canlı kullanıcı adı kontrolü buraya birebir uyar.
- Eğitim materyali — “istemci doğrulaması neden güvenlik değildir” konusunu anlatmak için çalışan, ölçümleri yazılı bir örnek.
Lisans
MIT — dilediğiniz gibi indirip kullanabilirsiniz.
Çılgın Yazılım · github.com/CilginYazilim/form-validation-example
Daha fazla örnek kod: cilginyazilim.com/kutuphane
· Bu örneğin anlatımı: Form Doğrulama
Telif © Çılgın Yazılım (cilginyazilim.com)
Kaynak Kod soldaki ağaçtan bir dosya seçin
-
assets
-
css
- bootstrap.min.css 227.5 KB
- cilginyazilim.css 19.4 KB
- style.css 17.4 KB
-
images
- logo.png 70.4 KB
- screenshot-live-validation.png 422.8 KB
-
js
- bootstrap.bundle.js 203.2 KB
- jquery-3.7.0.js 278.3 KB
- validation.js 27.9 KB
-
-
system
- .htaccess 1.8 KB
- ajax.php 9.6 KB
- config.local.php.example 1.3 KB
- config.php 10.1 KB
- function.php 15.1 KB
- rules.php 11.8 KB
- .gitignore 1.1 KB
- .htaccess 3.7 KB
- CHANGELOG.md 3.1 KB
- cy_validation.sql 16.8 KB
- index.php 23.2 KB
- LICENSE 1.1 KB
- README.en.md 39.8 KB
- README.md 41.3 KB
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli
değerler gösterilmeden önce maskelenir (••••••••).
Sık Sorulan Sorular
Hayır. Tarayıcıdaki kod kullanıcının makinesinde, kullanıcının denetiminde çalışır: konsoldan doğrulama nesnesi silinebilir, betik hiç yüklenmeyebilir ya da form hiç açılmadan doğrudan uç noktaya istek atılabilir. Bu depoda tam olarak bu yapıldı; sunucu 422 döndürdü ve hiçbir kayıt oluşmadı. İstemci tarafı yalnızca kullanıcının hatayı görmek için sayfa yenilenmesini beklememesi içindir.
İki kez yazmak zorundasınız ama iki kez TANIMLAMAK zorunda değilsiniz. Bu örnekte sayısal sınırlar, desenler ve hata mesajları system/rules.php içinde bir kez veri olarak durur; PHP doğrudan okur, JavaScript aynı listeyi alır. Ayrı ayrı tanımlandığında ayrışma kaçınılmazdır: ölçümde 190 ve 72 karakterlik iki sınır istemcide hiç yazılmamıştı ve aynı girdiye iki farklı hata gerekçesi çıkıyordu.
Cevabın verildiği an ile kaydın yazıldığı an arasında başka biri aynı adı alabilir — buna kontrol ile kullanım arasındaki yarış koşulu (TOCTOU) denir. Canlı kontrol bir kolaylıktır, sınır değildir. Gerçek sınır veritabanındaki benzersiz indekstir; kayıt sırasında çakışma oluşursa hata yakalanıp kullanıcıya alan bazlı olarak bildirilir.
bcrypt algoritmasının sert sınırı budur: 72 baytın ötesindeki karakterler sessizce yok sayılır. Sınırı forma yazmazsanız kullanıcı 80 karakterlik bir şifre belirlediğini sanır, oysa yalnızca ilk 72 karakteri anlamlıdır. Bu yüzden sınır kural dosyasında açıkça tanımlanmış ve hata mesajıyla birlikte iki tarafa da aktarılmıştır.
JavaScript'in length özelliği UTF-16 kod birimi sayar; astral düzlemdeki bir karakter 2 sayılır. PHP tarafında mb_strlen kullanılırken istemcide length kullanılırsa aynı girdi iki farklı uzunluk verir. Ölçümde 60 harflik bir ad soyad istemcide 120 sayıldığı için geçerli girdi engelleniyordu; örnek bunu karakter bazlı sayımla çözüyor.

Yorumlar 0 konuşma
Bu kod örneğine henüz yorum yapılmamış. Takıldığınız bir yer veya merak ettiğiniz bir ayrıntı varsa ilk soruyu siz sorun.
Soru Sor veya Yorum Yaz
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.