---
title: "İç İçe Kategoriler (Ağaç Yapı)"
url: "https://cilginyazilim.com/kutuphane/php-nested-categories"
description: "Bu örnek, PHP 8 ve MySQL 8 ile yazılmış üretime hazır bir iç içe kategori (ağaç) yönetim modülüdür. Ağaç adjacency list modeliyle saklanır (her satır parent_id tutar), böylece bir dalı taşımak tek bir UPDATE ile yapılır; okuma tarafındaki maliyet ise recursive CTE (WITH RECURSIVE) ile ortadan kaldırılır. Tek bir sorgu ağacın tamamını derinlik, kökten itibaren yol (breadcrumb), sıralama yolu ve ürün sayaçlarıyla birlikte döndürür. sort_path alanı her seviyenin position değerini sıfır dolgulu birleştirir; bu dizeye göre sıralamak ağacın derinlik öncelikli görünüm sırasını veritabanında üretir. Ürün sayaçları iki ayrı sayı olarak gelir: kategoriye doğrudan bağlı olanlar ve alt ağacın tamamı — menülerde gösterilmesi gereken ikincisidir. Yazma tarafında iki kritik koruma vardır. Döngü koruması, bir kategorinin kendi torununun altına taşınmasını engeller; yabancı anahtar bunu göremez, çünkü gösterilen id geçerlidir, ama sonuç kökten erişilemeyen bir halka olur ve dal ekrandan kaybolur. Derinlik koruması hedefin seviyesine ek olarak taşınan alt ağacın kendi yüksekliğini de hesaba katar. Her iki kontrol de transaction içinde, satır kilitlenerek yapılır. Slug ebeveyn altında benzersizdir; taşımada çakışırsa yeniden üretilir. Silme tek bir davranış değildir: engelle, çocukları bir üste taşı ve tüm dalı sil stratejileri kullanıcıya sorulur ve her birinin etkisi önceden hesaplanıp gösterilir; ürünü olan kategori hiçbir stratejide silinmez. Sürükle-bırak dış kütüphane olmadan, tarayıcının HTML5 DnD API'siyle yazılmıştır."
published: "2026-08-30T18:43:41+03:00"
modified: "2026-09-05T01:39:56+03:00"
category: "Arayüz ve Tablo"
type: "kod örneği"
difficulty: "İleri"
tech: ["PHP 8", "PDO", "MySQL 8", "Recursive CTE", "Ajax", "Bootstrap 5"]
tags: ["Ağaç Yapı", "Ajax", "Hiyerarşi", "Kategori", "PDO", "Recursive CTE", "Sürükle-Bırak", "Transaction"]
license: "MIT"
site: "CılgınYazılım"
language: "tr"
---

# İç İçe Kategoriler (Ağaç Yapı)

Bu örnek, PHP 8 ve MySQL 8 ile yazılmış üretime hazır bir iç içe kategori (ağaç) yönetim modülüdür. Ağaç adjacency list modeliyle saklanır (her satır parent_id tutar), böylece bir dalı taşımak tek bir UPDATE ile yapılır; okuma tarafındaki maliyet ise recursive CTE (WITH RECURSIVE) ile ortadan kaldırılır. Tek bir sorgu ağacın tamamını derinlik, kökten itibaren yol (breadcrumb), sıralama yolu ve ürün sayaçlarıyla birlikte döndürür. sort_path alanı her seviyenin position değerini sıfır dolgulu birleştirir; bu dizeye göre sıralamak ağacın derinlik öncelikli görünüm sırasını veritabanında üretir. Ürün sayaçları iki ayrı sayı olarak gelir: kategoriye doğrudan bağlı olanlar ve alt ağacın tamamı — menülerde gösterilmesi gereken ikincisidir. Yazma tarafında iki kritik koruma vardır. Döngü koruması, bir kategorinin kendi torununun altına taşınmasını engeller; yabancı anahtar bunu göremez, çünkü gösterilen id geçerlidir, ama sonuç kökten erişilemeyen bir halka olur ve dal ekrandan kaybolur. Derinlik koruması hedefin seviyesine ek olarak taşınan alt ağacın kendi yüksekliğini de hesaba katar. Her iki kontrol de transaction içinde, satır kilitlenerek yapılır. Slug ebeveyn altında benzersizdir; taşımada çakışırsa yeniden üretilir. Silme tek bir davranış değildir: engelle, çocukları bir üste taşı ve tüm dalı sil stratejileri kullanıcıya sorulur ve her birinin etkisi önceden hesaplanıp gösterilir; ürünü olan kategori hiçbir stratejide silinmez. Sürükle-bırak dış kütüphane olmadan, tarayıcının HTML5 DnD API'siyle yazılmıştır.

- **Gereksinim:** PHP 8.0+ (pdo_mysql, mbstring) · MySQL 8.0+ veya MariaDB 10.2.2+ (recursive CTE ZORUNLU) · Apache/mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/nested-categories

## Öne Çıkan Özellikler

- Adjacency list modeli: her satır parent_id tutar, bir dalı taşımak tek UPDATE
- Recursive CTE ile tüm ağaç, derinlik ve yol bilgisiyle birlikte tek sorguda okunur
- sort_path sayesinde derinlik öncelikli sıralama veritabanında üretilir
- Alt ağaç ürün sayaçları (doğrudan / toplam) aynı sorguda gelir
- Döngü koruması: bir kategori kendi alt kategorisinin altına taşınamaz
- Yabancı anahtar döngüyü göremez; kontrol uygulama katmanında ve transaction içinde yapılır
- Derinlik koruması hedefin seviyesine değil, taşınan dalın yüksekliğine de bakar
- Slug ebeveyn altında benzersizdir; farklı dallarda aynı ad çakışma değildir
- Taşımada slug hedefte çakışırsa yeniden üretilir, taşıma reddedilmez
- Üç silme stratejisi: engelle, çocukları bir üste taşı, tüm dalı sil
- Her stratejinin kaç kaydı etkileyeceği silme modalında önceden yazar
- Ürünü olan kategori hiçbir stratejide silinemez (ON DELETE RESTRICT)
- Sürükle-bırak dış kütüphane olmadan, tarayıcının HTML5 DnD API'siyle yazıldı
- Bırakma noktasının üst yarısı "araya gir", alt yarısı "alt kategori yap" demektir
- Arama filtrelemez, vurgular ve eşleşenlere giden dalları açar (Türkçe harf duyarsız)
- Paylaşılabilir derin bağlantı (#kategori-11), CSV dışa aktarım ve mobilde sıfır yatay kaydırma

## Kullanım Senaryoları

- E-ticaret kategori ağacı ve menü yönetimi
- Çok seviyeli site menüsü / navigasyon düzenleyici
- Departman, birim ve organizasyon şeması
- Klasör ve belge ağacı (doküman yönetimi)
- İç içe yorum ve forum başlığı yapısı
- Bölge / il / ilçe gibi coğrafi hiyerarşiler

## Kurulum

1. MySQL sürümünüzü kontrol edin: SELECT VERSION(); — recursive CTE için 8.0+ gerekir.
2. Depoyu indirin ya da ZIP olarak açın.
3. Veritabanını kurun: mysql -u root -p < cy_tree.sql (dosya veritabanını kendisi oluşturur).
4. Projeyi bir web sunucusu altına koyun ya da php -S 127.0.0.1:8000 ile çalıştırın.
5. Tarayıcıda açın; 35 kategori ve 55 ürün dolu bir ağaç gelir.
6. Satırları sürükleyerek taşıyın; döngü ve derinlik kurallarını deneyin.
7. Canlıya alırken system/config.local.php.example dosyasını config.local.php olarak kopyalayıp künyeyi oraya yazın.

## Ayrıntılı Anlatım

<img src="assets/images/logo.png" alt="Çılgın Yazılım" width="90">

## İç İçe Kategoriler (Ağaç Yapı)

#### PHP PDO · MySQL Recursive CTE · AJAX · Bootstrap 5 · Çılgın Yazılım Tasarım Kalıbı

**Sınırsız derinlikte kategori ağacı: tek sorguda oku, sürükleyerek taşı, döngüye izin verme.**

[![PHP](https://img.shields.io/badge/PHP-8.0%2B-777BB4?style=flat-square&logo=php&logoColor=white)](https://php.net)
[![MySQL](https://img.shields.io/badge/MySQL-8.0%2B-4479A1?style=flat-square&logo=mysql&logoColor=white)](https://mysql.com)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-5.2-7952B3?style=flat-square&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![Bağımlılık](https://img.shields.io/badge/Bağımlılık-Sıfır-16a34a?style=flat-square)](#kurulum)
[![License](https://img.shields.io/badge/Lisans-MIT-16a34a?style=flat-square)](LICENSE)

**🇹🇷 Türkçe** · [🇬🇧 English](README.en.md)

[**▶ Canlı Demo**](https://cilginyazilim.com/kutuphane/uygulama/nested-categories/) · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane/php-nested-categories) · [cilginyazilim.com](https://cilginyazilim.com)

---

### Canlı Demo

**Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.**

<a href="https://cilginyazilim.com/kutuphane/uygulama/nested-categories/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane/php-nested-categories"><img src="https://img.shields.io/badge/KAYNAK_KODU_%C4%B0NCELE-0ea5e9?style=for-the-badge&logo=readthedocs&logoColor=white&labelColor=061321" alt="Kaynak Kodu İncele" height="42"></a>
<a href="https://github.com/CilginYazilim/nested-categories/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/ZIP_%C4%B0ND%C4%B0R-16a34a?style=for-the-badge&logo=github&logoColor=white&labelColor=061321" alt="ZIP İndir" height="42"></a>

<br><br>

<a href="https://cilginyazilim.com/kutuphane/uygulama/nested-categories/" title="Canlı demoyu açmak için tıklayın">
  <img src="docs/screenshots/01-agac.png" alt="Kategori ağacı canlı demo önizlemesi" width="860">
</a>

<sub>▲ Görsele tıklayarak demoyu açabilirsiniz</sub>

<br>

#### Demoda 60 saniyede neleri deneyebilirsiniz?

| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|--------------|----------------------------|
| **1** | **Elektronik** satırındaki `0 / 31` sayısına bakın | Soldaki sayı o kategoriye **doğrudan** bağlı ürün, sağdaki **alt ağacın tamamı**. "Elektronik (0)" yazan bir menü kimseye bir şey anlatmaz; doğru sayı ikincisidir |
| **2** | **Dizüstü**'nü sürükleyip **Masaüstü**'nün *üst yarısına* bırakın | Aynı seviyede yer değiştirir. Üst yarı "araya gir", alt yarı "alt kategori yap" demektir — bırakma noktasının yarısı anlamı belirler |
| **3** | **Bilgisayar**'ı sürükleyip kendi torunu **İşlemci**'nin üstüne bırakın | **HTTP 422** ve net bir mesaj. Bu engel olmasaydı ağaç köke bağlı olmayan bir halkaya dönüşür, dal ekrandan tamamen kaybolurdu |
| **4** | **Bilgisayar**'ı **Android** altına taşımayı deneyin | Yine 422: *"bu dal 7. seviyeye inerdi"*. Sunucu yalnızca hedefin derinliğine değil, **taşınan dalın yüksekliğine** de bakar |
| **5** | Arama kutusuna `aksesuar` yazın | İki sonuç bulunur ve **ikisi de görünür kalır**. Arama filtrelemez, vurgular — ağaçta konum bilginin kendisidir; "hangi Aksesuar?" sorusunu ancak konum cevaplar |
| **6** | İki `Aksesuar` satırının slug'larına bakın | İkisi de `/aksesuar`. Slug **global değil, ebeveyn altında** benzersizdir; farklı dallarda aynı ad çakışma değildir |
| **7** | **Ses Sistemleri**'ni silmeye çalışın (🗑) | Üç strateji sunulur: *engelle*, *bir üste taşı*, *tüm dalı sil* — ve her birinin kaç kaydı etkileyeceği **önceden** yazar |
| **8** | **Dizüstü**'nü silmeye çalışın | **HTTP 409**: içinde 3 ürün var. Hiçbir stratejide ürünü olan kategori silinmez; veritabanı bunu `ON DELETE RESTRICT` ile de garanti eder |
| **9** | 👁 **Göz** butonuna basın | Kökten itibaren tam yol, seviye ve dört sayaç açılır. Adres çubuğu `#kategori-11` olur — bağlantı **paylaşılabilir** |
| **10** | Telefonunuzdan açın | Girinti ve dal çizgileri korunur, **yatay kaydırma yoktur**; slug gizlenir ve detay modalında durur |

> **İpucu:** Demoyu açıkken **F12 → Network** sekmesini açın. Her taşımada `move` isteğinin `id`, `parent_id` ve `position` alanlarını, dönen JSON'u ve HTTP durum kodlarını (200 / 403 / 409 / 422 / 429) canlı görebilirsiniz.

#### Demo alanı hakkında bilinmesi gerekenler

| Konu | Durum |
|------|-------|
| **Veriler** | `cy_tree.sql` içindeki **35 kategori + 55 ürün**. Ağaç bilinçli olarak dengesizdir: bir dal 4 seviye iner, diğeri 1 seviyede kalır. |
| **Sıfırlama** | Demo veritabanı **düzenli aralıklarla** başlangıç haline döner; yaptığınız taşımalar kalıcı değildir. |
| **MySQL sürümü** | Recursive CTE gerektirir: **MySQL 8.0+** veya **MariaDB 10.2.2+**. Bu projenin tek gerçek ortam koşuludur. |
| **Kimlik doğrulama** | **Yoktur.** Bilinçli bir tercihtir — örnek, ağaç mantığına odaklanır. |
| **`APP_DEBUG`** | Canlıda **otomatik `false`** — sunucu adından türetilir, yerelde `true` kalır. |
| **Bağımlılık** | **Sıfır.** Composer yok, npm yok, CDN yok, sürükle-bırak kütüphanesi bile yok — tarayıcının kendi HTML5 DnD API'si kullanılır. |

> Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp `cy_tree.sql`'i içe aktarmanız aynı ekranı kendi bilgisayarınızda **2 dakikada** ayağa kaldırır → [Kurulum](#kurulum)

---

### Bu Proje Nedir?

Kategori ağacı her projede karşınıza çıkar ve neredeyse her seferinde aynı üç soruyla birlikte gelir:

1. **Nasıl saklayacağım?** — `parent_id` mi, `lft/rgt` mi, ayrı bir ilişki tablosu mu?
2. **Tüm alt ağacı nasıl getireceğim?** — özyinelemeli PHP döngüsüyle 40 sorgu atmadan
3. **Kullanıcı bir dalı kendi altına taşırsa ne olacak?**

Üçüncüsü en sinsisidir. `parent_id`'yi yanlış bir değere yazmak hiçbir hata üretmez: yabancı anahtar memnundur, çünkü gösterilen id gerçekten vardır. Ama o an ağaç köke bağlı olmayan bir **halkaya** dönüşür; kökten inen sorgu oraya hiç ulaşamadığı için dal ekrandan tamamen kaybolur ve veritabanında yetim bir döngü olarak kalır. Kimse bir şey fark etmez — ta ki "ürünlerin yarısı nerede?" diye sorulana kadar.

Bu proje üçünü de cevaplıyor: **adjacency list** ile saklıyor (yazması ucuz), **recursive CTE** ile tek sorguda okuyor (MySQL 8 sayesinde artık pahalı değil) ve her taşımada **döngü ile derinliği sunucuda** doğruluyor.

Bir de dördüncü soruyu cevaplıyor: **"Bu kategoride kaç ürün var?"** Cevap iki farklı sayıdır ve ikisi de doğrudur — doğrudan bağlı olanlar, ve alt ağacın tamamı. Menülerde gösterilmesi gereken ikincisidir; ekranda ikisi birden durur.

**Kimler için uygun?**

- Kategori, menü, departman, yorum veya klasör ağacı kuracaklar
- `parent_id` ile başlayıp "tüm alt ağacı nasıl getiririm?" duvarına toslayanlar
- Nested set / closure table arasında karar vermeye çalışanlar
- Sürükle-bırak sıralamayı **dış kütüphanesiz** yazmak isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir tasarım kalıbı arayanlar

> **Klonla, `cy_tree.sql`'i içe aktar, çalıştır.** Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.

Bu proje, **[Çılgın Yazılım Kütüphanesi](https://cilginyazilim.com/kutuphane)** altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.

---

### İçindekiler

- [Canlı Demo](#canlı-demo)
- [Ekran Görüntüleri](#ekran-görüntüleri)
- [Dört Kritik Karar](#dört-kritik-karar)
- [Neler Var?](#neler-var)
- [Güvenlik: Neyi, Nasıl Kapattık?](#güvenlik-neyi-nasıl-kapattık)
- [Kurulum](#kurulum)
- [Yapılandırma](#yapılandırma)
- [Kendi Projenize Eklemek](#kendi-projenize-eklemek)
- [Çılgın Yazılım Tasarım Kalıbı](#çılgın-yazılım-tasarım-kalıbı)
- [Dosya Yapısı](#dosya-yapısı)
- [Nasıl Çalışıyor?](#nasıl-çalışıyor)
- [AJAX API Referansı](#ajax-api-referansı)
- [Veritabanı Şeması](#veritabanı-şeması)
- [Sık Sorulanlar](#sık-sorulanlar)
- [Canlı Ortama Alırken](#canlı-ortama-alırken)
- [Sorun Giderme](#sorun-giderme)
- [Yol Haritası](#yol-haritası)
- [Katkı](#katkı)
- [Lisans](#lisans)

---

### Ekran Görüntüleri

#### Ağaç görünümü

Girinti, kesikli dal çizgileri ve her satırda **doğrudan / toplam** ürün sayacı. Sayaç şeridi ağacın nabzını verir: kaç kategori, kaç kök, kaç yaprak, en derin seviye kaç (ve sınıra ne kadar kaldı).

![Ağaç görünümü](docs/screenshots/01-agac.png)

#### Kategori detayı ve tam yol

Kökten itibaren breadcrumb, seviye, kardeşleri arasındaki sıra ve dört sayaç. Adres çubuğu `#kategori-11` olur; bağlantıyı gönderdiğinizde karşı taraf **doğrudan o kategoriyi** açar.

![Kategori detayı](docs/screenshots/02-kategori-detay.png)

#### Silme: tek bir "sil" yoktur

Çocuğu olan bir düğümü silmenin üç makul karşılığı vardır ve hangisinin doğru olduğu duruma göre değişir. Bu yüzden strateji bir ayar dosyasına gömülmez, **silme anında sorulur** — ve her seçeneğin kaç kaydı etkileyeceği modal açılırken hesaplanıp yazılır.

![Silme stratejisi](docs/screenshots/03-silme-stratejisi.png)

#### Mobil görünüm

Dar ekranda girinti daraltılır, dal çizgileri ve sayaçlar korunur, slug gizlenir (detay modalında durur). **Yatay kaydırma yoktur**; dokunma hedefleri en az 32–44px'dir.

<img src="docs/screenshots/04-mobil.png" alt="Mobil görünüm" width="360">

**Üç modal:**

| Modal | Açılış | İçerik |
|-------|--------|--------|
| ✎ **Ekle / Yeniden adlandır** | ＋ veya kalem butonu | Form, alan bazlı hata mesajı, "nereye eklenecek" satırı, slug açıklaması |
| 🗑 **Silme** | Çöp kutusu butonu | Üç strateji + her birinin etkisi + ürün koruması uyarısı |
| 👁 **Detay** | Göz butonu | Breadcrumb, seviye, sıra, alt kategori ve ürün sayaçları, paylaşılabilir `#kategori-11` bağlantısı |

---

### Dört Kritik Karar

#### 1) Adjacency list — ve neden nested set değil

Ağaç saklamanın üç klasik yolu vardır:

| Model | Okuma | Yazma | Bedeli |
|-------|-------|-------|--------|
| **Adjacency list** (bu proje) | Recursive CTE gerekir | Bir dalı taşımak **tek `UPDATE`** | MySQL 8.0+ şartı |
| Nested set (`lft`/`rgt`) | Çok hızlı, düz `BETWEEN` | Tek ekleme **tablonun yarısını** günceller | Sık değişen ağaçta felaket |
| Closure table | Çok hızlı | Güvenli ama iki tablo | N düğüm için **O(N·derinlik)** satır |

Nested set'in cazibesi okuma hızıdır; ama bir yönetim ekranında ağaç sürekli değişir ve her sürükle-bırakta yüzlerce satır güncellemek kabul edilebilir değildir. MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını —"tüm alt ağacı getir"— ortadan kaldırdığı için ödünleşim artık nettir.

```sql
WITH RECURSIVE tree AS (
    SELECT id, parent_id, name, 0 AS depth,
           CAST(LPAD(position, 5, '0') AS CHAR(500)) AS sort_path
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.parent_id, c.name, t.depth + 1,
           CONCAT(t.sort_path, '.', LPAD(c.position, 5, '0'))
    FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY sort_path
```

`sort_path` bu sorgunun en işlevsel parçasıdır: her seviyenin sırası 5 haneye sıfırlanıp birleştirilir (`00000.00002.00001`). Bu dizeye göre sıralamak, ağacın **derinlik öncelikli** doğru sırasını —yani ekranda göründüğü sırayı— veritabanında üretir. PHP'de yeniden sıralamak, aynı işi ikinci kez yapmak olurdu.

#### 2) Döngü koruması: yabancı anahtarın göremediği hata

`parent_id`'yi kendi torununa yazmak hiçbir kısıtı ihlal etmez — gösterilen id gerçekten vardır. Ama ağaç o an köke bağlı olmayan bir halkaya döner:

```
Bilgisayar → Bileşenler → İşlemci → Bilgisayar → …
```

Kökten inen CTE bu halkaya hiç ulaşamaz; dal ekrandan **tamamen kaybolur**. Korumak için taşımadan önce hedefin, taşınan düğümün torunu olup olmadığına bakıyoruz:

```php
if (isset(descendant_ids($db, $id)[$newParent])) {
    json_error('Bir kategori kendi alt kategorisinin altına taşınamaz (döngü oluşurdu).', 422);
}
```

**Kontrol transaction'ın İÇİNDE yapılır.** Dışarıda yapılsaydı iki eşzamanlı taşıma birbirini geçersiz kılabilirdi: ikisi de kendi kontrolünü geçer, ikisi de yazılır ve ortaya kontrolün engellemeye çalıştığı döngü çıkardı.

#### 3) Derinlik: hedefin seviyesi değil, dalın yüksekliği

Naif kontrol hedefin derinliğine bakar. Doğrusu **taşınan alt ağacın kendi yüksekliğini** de hesaba katar:

```php
$newDepth = node_depth($db, $newParent) + subtree_height($db, $id);
if ($newDepth > MAX_DEPTH) { … }
```

3 seviyelik bir dalı 3. seviyeye taşımak, dalın en alt yaprağını 6. seviyeye indirir. Yalnızca hedefe bakan bir kontrol bunu kaçırır ve sınır bir işe yaramaz. Hata mesajı da bu yüzden somuttur: *"bu dal 7. seviyeye inerdi"*.

#### 4) Slug: global değil, ebeveyn altında benzersiz

`elektronik/aksesuar` ile `telefon/aksesuar` aynı anda var olabilmelidir — URL yolları farklıdır, ortada çakışma yoktur. Global benzersizlik dayatmak kullanıcıyı `aksesuar-2` gibi adlara zorlardı.

```sql
UNIQUE KEY `uq_cat_parent_slug` (`parent_id`, `slug`)
```

Bunun bir bedeli vardır ve ilk sürümde bu bedel bir hataya dönüşmüştü: **taşıma slug çakışması üretebilir.** `telefon/aksesuar` düğümünü `elektronik` altına taşımak, orada zaten bir `aksesuar` varsa `23000` hatası verir ve kullanıcı hiçbir açıklaması olmayan bir 500 görür. Doğru davranış taşımayı reddetmek değil, hedefte çakışmayan bir slug üretmektir:

```php
$slug = $oldParent === $newParent
    ? $cur['slug']
    : unique_slug($db, $cur['slug'], $newParent, $id);   // → "aksesuar-2"
```

Aynı sorun `reparent` silme stratejisinde de vardır — yukarı çıkan her çocuk için slug yeniden üretilir.

---

### Neler Var?

<tr><td valign="top" width="50%">

**Ağaç çekirdeği**

- Adjacency list + **recursive CTE** ile tek sorguda okuma
- `sort_path` ile derinlik öncelikli sıralama **veritabanında**
- Alt ağaç ürün sayaçları (doğrudan / toplam) yine **aynı sorguda**
- Döngü koruması — transaction içinde doğrulanır
- Derinlik koruması — dalın **yüksekliği** hesaba katılır
- Slug ebeveyn altında benzersiz; taşımada otomatik yenilenir
- `position` değerleri her işlemden sonra 0'dan sıkıştırılır
- Üç silme stratejisi: **engelle / bir üste taşı / tüm dalı sil**
- Ürünü olan kategori hiçbir stratejide silinemez (`ON DELETE RESTRICT`)

</td><td valign="top" width="50%">

**Arayüz ve altyapı**

- **Dış kütüphanesiz** sürükle-bırak (HTML5 DnD API)
- Bırakma noktasının yarısına göre "araya gir" / "altına gir"
- Aç-kapat; açık/kapalı dallar tazelemeler arasında **korunur**
- Arama **filtrelemez, vurgular** ve dalları açar (Türkçe duyarsız)
- Sayaç şeridi; derinlik sınırına dayanınca uyarı rengine geçer
- Ağacı CSV indir (girintili ad + tam yol sütunu)
- Paylaşılabilir derin bağlantı: `#kategori-11`
- Form modalı (`prompt()` yok), silme modalı, detay modalı
- Çift gönderim koruması, toast bildirimleri, `aria-live`
- Üç ayrı hız sınırı kovası (read / write / export)
- Mobil: 767 / 480px eşikleri, **yatay kaydırma yok**

</td></tr>

---

### Güvenlik: Neyi, Nasıl Kapattık?

| Açık | Tipik hatalı kod | Bu projede |
|------|------------------|------------|
| **SQL Injection** | `"... WHERE parent_id = ".$_POST['pid']` | Tüm sorgular prepared statement, `EMULATE_PREPARES = false`. Silme stratejisi `DELETE_STRATEGIES` **beyaz listesinden** geçer; id'ler `filter_input(..., FILTER_VALIDATE_INT)` ile doğrulanır. |
| **XSS (ağaç satırlarında)** | `$li.html(node.name)` | Kategori adı doğrudan kullanıcı verisidir. Sunucuda `e()`, istemcide `esc()`. Ağaç satırları dizgi birleştirerek kuruluyor — tam da bu yüzden her değer kaçışlanır. |
| **CSRF** | *(genelde hiç yok)* | Oturuma bağlı 32 baytlık token, **her** istekte, `hash_equals()` ile sabit zamanlı doğrulama. Token'sız istek → **HTTP 403**. |
| **Döngü (veri bozulması)** | `UPDATE ... SET parent_id = ?` | Yabancı anahtar bunu **engellemez**. Taşımadan önce torun kontrolü yapılır ve kontrol **transaction içindedir**. |
| **Sınırsız derinlik** | *(hiç kontrol yok)* | `MAX_DEPTH`; hedefin derinliği **artı** taşınan dalın yüksekliği. |
| **Benzersizlik çakışması** | Her `23000`'i aynı sayma | Slug çakışması **önlenir** (taşımada ve reparent'ta yeniden üretilir), hataya bırakılmaz. |
| **Yanlışlıkla toplu silme** | Sessiz `CASCADE` | Strateji kullanıcıya sorulur ve **kaç kaydı etkileyeceği önceden yazılır**. Ürünü olan kategori hiçbir stratejide silinmez. |
| **Yarım kalan taşıma** | Ardışık `UPDATE`'ler | Taşıma ve sıra sıkıştırma **tek transaction**; biri başarısız olursa hepsi geri alınır. |
| **Bozuk kodlamanın yanıtı yutması** | `json_encode()` → `false` → boş gövde | `JSON_INVALID_UTF8_SUBSTITUTE`: tek bozuk bayt yüzünden 35 düğümün tamamı kaybolmaz. |
| **CSV formül enjeksiyonu** | Hücreyi olduğu gibi yazmak | `=`, `+`, `-`, `@` ile başlayan hücrelerin başına tek tırnak konur — `=1+1` adlı bir kategori hiçbir kuralı çiğnemez ama Excel'de çalışan bir hücreye dönüşürdü. |
| **Kaynak tüketimi** | Sınırsız istek | `RATE_LIMIT_READ` / `WRITE` / `EXPORT` — **ayrı** kovalar; ucuz bir okuma isteği pahalı bir işlemin hakkını yemez. |
| **Bilgi sızıntısı** | Ekrana basılan MySQL hataları | `APP_DEBUG` sunucu adından türetilir; canlıda otomatik `false`, detay `error_log()`'a gider. |
| **Kurulum dosyasının indirilmesi** | `/cy_tree.sql` → HTTP 200 | `.htaccess`: `.sql`, `.md`, `.json`, `.log` … kapalı (README dosyaları bilinçli istisnadır). |

---

### Kurulum

> Sadece görmek istiyorsanız kurulum gerekmez → [**Canlı Demoyu açın**](https://cilginyazilim.com/kutuphane/uygulama/nested-categories/). Aşağıdaki adımlar projeyi kendi bilgisayarınızda çalıştırmak içindir (~2 dakika).

#### Gereksinimler

- PHP **8.0+** (`pdo_mysql` ve `mbstring` eklentileri)
- **MySQL 8.0+** veya **MariaDB 10.2.2+** — *recursive CTE için zorunlu*
- Apache (XAMPP / WAMP / Laragon) — ya da PHP'nin yerleşik sunucusu

#### Adımlar

**1 — Projeyi indirin**

```bash
git clone https://github.com/CilginYazilim/nested-categories.git
cd nested-categories
```

**2 — Veritabanını oluşturun**

`cy_tree.sql` veritabanını da kendisi oluşturur; önceden `cy_tree` adında bir veritabanı açmanıza gerek yok.

```bash
mysql -u root -p < cy_tree.sql
```

> **İsteğe bağlı — kendi veritabanı bilgileriniz:**
> `cp .env.example .env` (Windows: `copy .env.example .env`) deyip `DB_*`
> 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.

phpMyAdmin ile: **İçe Aktar → Dosya seç → `cy_tree.sql` → Başlat**

**3 — Çalıştırın**

```bash
php -S 127.0.0.1:8000
```

XAMPP kullanıyorsanız projeyi `htdocs` altına koyup şu adresi açın:
`http://localhost/nested-categories/`

**4 — Tarayıcıda açın** → `http://127.0.0.1:8000/`

Karşınıza **35 kategori** ve **55 ürün** dolu, çalışır durumda bir ağaç gelecek.

> MySQL sürümünüzü kontrol etmek için: `SELECT VERSION();` — 8.0'ın altındaysa recursive CTE çalışmaz ve uygulama size bunu açıkça söyler.

#### Ortam değişkenleri

Depo kökündeki **`.env`** dosyasına yazın; `system/config.php` dosyasına
hiç dokunmayın:

```bash
cp .env.example .env        # Windows: copy .env.example .env
```

`.env` `.gitignore` içindedir: depoya gönderilmez ve dağıtım (deploy) onu
**silmez**. `system/config.php` ise depoda durur ve her dağıtımda depodaki
sü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_tree` | 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.

---

### Yapılandırma

Tüm ayarlar [system/config.php](system/config.php) içindedir.

| Sabit | Varsayılan | Ne işe yarar |
|-------|-----------|--------------|
| `MAX_DEPTH` | `5` | En fazla derinlik (kök = 0). Ekleme ve taşımada uygulanır |
| `DELETE_STRATEGIES` | `block, cascade, reparent` | İzin verilen silme stratejileri (**beyaz liste**) |
| `DELETE_DEFAULT` | `block` | Silme modalında açılışta seçili gelen strateji |
| `RATE_LIMIT_READ` | `[120, 60]` | Ağaç okuma isteği sınırı |
| `RATE_LIMIT_WRITE` | `[80, 60]` | Ekleme / taşıma / silme sınırı |
| `RATE_LIMIT_EXPORT` | `[10, 60]` | CSV dışa aktarım sınırı (en pahalısı) |

#### Derinlik sınırını değiştirmek

`MAX_DEPTH` değerini büyütmek yeterlidir; hem ekleme hem taşıma aynı sabiti okur. Sınırsız derinlik teknik olarak mümkündür ama önerilmez: menü ve breadcrumb'lar pratikte bir tavan ister, ve sınırsız derinlik tek bir hatalı taşımanın ağacı okunamaz hâle getirmesine izin verir.

#### Silme stratejisini sabitlemek

Kullanıcıya sormak istemiyorsanız `DELETE_STRATEGIES` dizisini tek elemana indirin:

```php
define('DELETE_STRATEGIES', ['block']);
define('DELETE_DEFAULT', 'block');
```

Sunucu beyaz listeyi zorlar; modaldeki diğer seçenekler gönderilse bile kabul edilmez.

#### Şifreyi koda yazmayın

Canlı veritabanı künyesi `config.php`'ye **yazılmaz**: o dosya depoda durur ve her dağıtımda depodaki sürümle değiştirilir. Bunun yerine yanına `config.local.php` koyun (`.gitignore` içindedir):

```bash
cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurun
```

En kısa yol ise depo kökündeki `.env` dosyasıdır — aynı korumaya sahiptir
(`.gitignore` içindedir, deploy silmez) ama tek satırda kurulur:

```bash
cp .env.example .env        # Windows: copy .env.example .env
# sonra DB_* satırlarını doldurun
```

Ortam değişkeni de kullanabilirsiniz:

```bash
# Linux / macOS
DB_HOST=127.0.0.1 DB_NAME=cy_tree DB_USER=uygulama DB_PASS=gizli php -S 127.0.0.1:8000

# Windows (PowerShell)
$env:DB_PASS="••••••••"; php -S 127.0.0.1:8000
```

Öncelik sırası: **`config.local.php` → `.env` → ortam değişkeni → yerel varsayılan.**

---

### Kendi Projenize Eklemek

Ağaç katmanını başka bir varlığa (menü, departman, klasör, yorum) taşımak için:

**1 — Tabloyu kopyalayın, adını değiştirin**

`categories` tablosunun yapısı varlıktan bağımsızdır. Değişmesi gereken tek şey addır; üç indeksi **olduğu gibi** taşıyın:

```sql
KEY        `idx_menu_parent_pos`  (`parent_id`, `position`),
UNIQUE KEY `uq_menu_parent_slug`  (`parent_id`, `slug`),
CONSTRAINT `fk_menu_parent` FOREIGN KEY (`parent_id`)
    REFERENCES `menu_items` (`id`) ON DELETE CASCADE
```

**2 — `function.php` içindeki tablo adlarını değiştirin**

`fetch_tree`, `descendant_ids`, `node_depth`, `subtree_height`, `unique_slug`, `normalize_positions` — hepsi `categories` adını kullanır. Tek yerde sabit tanımlayıp oradan okumak da mümkündür.

**3 — Sayaç sütununu kendi ilişkinize bağlayın**

`fetch_tree()` içindeki `direct` ve `totals` CTE'leri `products` tablosunu sayar. Sizde bu "menüdeki bağlantı sayısı" ya da "departmandaki personel" olabilir. İhtiyacınız yoksa iki CTE'yi de silin; sorgu yine çalışır.

**4 — Derinlik ve silme kurallarını gözden geçirin**

Menü ağacında `MAX_DEPTH = 3` makuldür; dosya ağacında sınırsıza yakın. Silme stratejisi de değişir: menüde `reparent`, dosya sisteminde `cascade` daha doğrudur.

> **Atlamayın:** Döngü kontrolünü ve transaction'ı taşımayı unutursanız, projeyi kopyalamış ama **onu değerli kılan tek şeyi** almamış olursunuz.

---

### Çılgın Yazılım Tasarım Kalıbı

[assets/css/cilginyazilim.css](assets/css/cilginyazilim.css) dosyası bu projeye değil **markaya** aittir. Diğer örnek projelerde de aynı görsel dili kullanabilmek için ayrı bir dosya olarak tutulur; bu örneğe özgü her şey (ağaç, bırakma göstergeleri, sayaç rozetleri, araç çubuğu) [assets/css/style.css](assets/css/style.css) içindedir.

#### Hazır bileşenler

| Sınıf | Ne işe yarar |
|-------|--------------|
| `.cy-card` / `.cy-card__header` / `__body` / `__footer` | Gradyan başlıklı ana kart |
| `.cy-brand` / `.cy-brand__mark` / `__title` / `__subtitle` | Logo kutusu + başlık bloğu |
| `.cy-btn` + `--primary` \| `--onbrand` \| `--glass` | Marka butonları |
| `.cy-badge` + `--glass` \| `--soft` | Rozetler |
| `.cy-modal` / `.cy-detail` | Gradyan başlıklı modal ve etiket/değer listesi |
| `.cy-toast` + `--success` \| `--danger` \| `--info` | Bildirim balonları |
| `.cy-topbar` / `.cy-footer-note` | Üst şerit ve alt bilgi bloğu |

**Bu örneğe özgü** (`style.css`): `.cy-tree` / `.cy-tree__row` / `__toggle` / `__count`, `.drop-before` / `.drop-into`, `.cy-stats` / `.cy-stat`, `.cy-toolbar` / `.cy-search`, `.cy-breadcrumb` / `.cy-crumb`, `.cy-choice`, `.cy-btn-icon`.

#### Renkleri değiştirmek

Tüm bileşenler CSS değişkenlerinden beslenir. **Tek yeri** değiştirmek yeter:

```css
:root {
    --cy-brand-900: #061321;   /* Logodaki en koyu lacivert */
    --cy-brand-600: #0b5cb5;   /* Ana marka mavisi          */
    --cy-accent:    #0ea5e9;   /* Vurgu rengi               */
    --cy-gradient:  linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}
```

#### Koyu tema

Kullanıcının işletim sistemi koyu temadaysa **otomatik** devreye girer. Zorlamak isterseniz:

```html
<html data-cy-theme="dark">   <!-- veya "light" -->
```

---

### Dosya Yapısı

```
nested-categories/
├── index.php                 → Arayüz. Veritabanına DOKUNMAZ; tek kart + üç modal.
├── .env.example              → Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_tree.sql               → Şema + 35 kategori + 55 ürün (NOW() - INTERVAL)
├── .htaccess                 → Dizin listeleme kapalı, .sql/.md kapalı (README istisna)
│
├── system/
│   ├── config.php            → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│   ├── config.local.php      → (siz oluşturursunuz) canlı künye — .gitignore'da
│   ├── config.local.php.example
│   ├── function.php          → CSRF, hız sınırı, CTE sorguları, slug, CSV
│   ├── ajax.php              → 8 uç nokta
│   └── .htaccess             → BEYAZ LİSTE: yalnızca ajax.php dışarıya açık
│
├── assets/
│   ├── css/
│   │   ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│   │   ├── style.css         → Yalnızca bu örneğe özgü stiller
│   │   └── bootstrap.min.css
│   ├── js/
│   │   ├── tree.js           → Ağaç, sürükle-bırak, arama, modallar (9 bölüm)
│   │   ├── jquery-3.7.0.js
│   │   └── bootstrap.bundle.js
│   └── images/logo.png
│
├── docs/screenshots/         → README'de kullanılan görseller
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE                   → MIT
```

---

### Nasıl Çalışıyor?

```
┌─ OKUMA:  action=tree ────────────────────────────────────────────────┐
│  fetch_tree() — TEK SORGU, üç CTE zinciri:                           │
│                                                                      │
│    tree     → kökten aşağı gezer; depth, sort_path, name_path üretir │
│    direct   → kategori başına DOĞRUDAN ürün sayısı (GROUP BY)        │
│    closure  → ata→torun çiftleri (kategori kendisinin de atasıdır)   │
│    totals   → closure + direct → ALT AĞAÇ toplamları                 │
│                                                                      │
│  ORDER BY sort_path  →  ekranda göründüğü sıra, veritabanında üretilir│
│  ↳ döner: DÜZ liste (iç içe JSON değil)                              │
└──────────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─ İSTEMCİ: ağacı kurar ───────────────────────────────────────────────┐
│  buildTree(): parent_id'ye göre grupla (O(n)) → özyinelemeli <ul>    │
│  Sıralama zaten doğru; gruplama sırayı bozmaz.                       │
│  Açık/kapalı dallar `collapsed` kümesinde saklanır ve her            │
│  tazelemeden sonra geri uygulanır — kullanıcı yerini kaybetmesin.    │
└──────────────────────────────────────────────────────────────────────┘
                              │  sürükle-bırak
                              ▼
┌─ YAZMA:  action=move ────────────────────────────────────────────────┐
│  BEGIN                                                               │
│    SELECT … FOR UPDATE            ← satırı kilitle                   │
│    Kural 1: hedef == kaynak?           → 422                         │
│    Kural 2: hedef, kaynağın torunu mu? → 422  (DÖNGÜ)                │
│    Kural 3: node_depth(hedef) + subtree_height(kaynak) > MAX_DEPTH?  │
│                                        → 422  (DERİNLİK)             │
│    Slug hedefte çakışıyor mu?          → unique_slug() ile yenile    │
│    UPDATE parent_id, slug, position = 999999                         │
│    normalize_positions(eski ebeveyn)   ← boşlukları kapat            │
│    Kardeşleri sırayla al, düğümü index'e sok, position'ları yaz      │
│  COMMIT                                                              │
└──────────────────────────────────────────────────────────────────────┘
```

#### Sorumluluk dağılımı

| Katman | Dosya | Sorumluluk |
|--------|-------|-----------|
| Sunum | `index.php` | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | `assets/js/tree.js` | Ağaç kurma, DnD, arama, modallar, kaçışlama |
| Uç noktalar | `system/ajax.php` | İstek doğrulama, kurallar, transaction, JSON |
| Alan mantığı | `system/function.php` | CTE sorguları, slug, sıra sıkıştırma, CSV |
| Ayar | `system/config.php` | Sabitler, PDO, `APP_DEBUG` türetimi |

---

### AJAX API Referansı

Tüm istekler `system/ajax.php` adresine gider. `export` dışında **POST** ve **CSRF token** zorunludur.

<details>
<summary><b><code>tree</code> — tüm ağaç</b></summary>

```json
{
  "success": true,
  "max_depth": 5,
  "nodes": [
    {"id": 1, "parent_id": null, "name": "Elektronik", "slug": "elektronik",
     "position": 0, "depth": 0, "path": "Elektronik",
     "direct_count": 0, "total_count": 31, "child_count": 4}
  ]
}
```

Liste **düzdür** ve `sort_path`'e göre sıralıdır; iç içe yapıyı istemci kurar.
</details>

<details>
<summary><b><code>stats</code> — sayaç şeridi</b></summary>

```json
{"success": true, "total": 35, "roots": 5, "max_depth": 4, "leaves": 23, "products": 55}
```
</details>

<details>
<summary><b><code>detail</code> — tek kategori</b></summary>

**İstek:** `id`

```json
{
  "success": true,
  "category": {
    "id": 11, "name": "Bileşenler", "slug": "bilesenler",
    "path": "Elektronik / Bilgisayar / Bileşenler",
    "depth": 2, "position": 2,
    "child_count": 3, "sub_cats": 5,
    "direct_count": 0, "total_count": 11,
    "created_at": "25.07.2026 19:26", "is_root": false, "can_nest": true
  }
}
```
</details>

<details>
<summary><b><code>add</code> · <code>rename</code></b></summary>

**add:** `parent_id` (boş = kök), `name` → `{"id": 41, "slug": "giyilebilir-teknoloji"}`
**rename:** `id`, `name` → `{"slug": "yeni-ad"}`

Slug addan otomatik üretilir; aynı ebeveyn altında çakışırsa sonuna numara eklenir (`giyilebilir-teknoloji-2`). Ad değişmemişse `rename` hiçbir şey yazmaz ve `unchanged: true` döner.

Geçersiz ad → **422** ve `errors.name`.
</details>

<details>
<summary><b><code>move</code> — sürükle-bırak</b></summary>

**İstek:** `id`, `parent_id` (boş = kök), `position`

Başarılıysa `{"success": true, "slug": "aksesuar-2"}` — slug hedefte çakıştıysa yenilenmiş hâli döner.

Üç hata durumu, hepsi **422**:

```json
{"description": "Bir kategori kendi altına taşınamaz."}
{"description": "Bir kategori kendi alt kategorisinin altına taşınamaz (döngü oluşurdu)."}
{"description": "Taşıma en fazla 5 seviye derinliğe izin verir; bu dal 7. seviyeye inerdi.",
 "would_be_depth": 7}
```
</details>

<details>
<summary><b><code>delete</code> — strateji ile</b></summary>

**İstek:** `id`, `strategy` (`block` \| `reparent` \| `cascade`)

İki **409** durumu vardır ve ikisi de arıza değil, kuralın çalışmasıdır:

```json
{"description": "Bu kategorinin 4 alt kategorisi var. Önce onları taşıyın, ya da başka bir silme stratejisi seçin.", "children": 4}
{"description": "Bu kategoride 3 ürün var. Önce ürünleri başka bir kategoriye taşıyın.", "products": 3}
```

`cascade` seçilirse ürün kontrolü **tüm alt ağacı** kapsar.
</details>

<details>
<summary><b><code>export</code> — CSV (GET)</b></summary>

`?action=export` → UTF-8 BOM'lu, `;` ayraçlı CSV.

```
#;Seviye;Ad;Slug;Yol;"Alt kategori";"Doğrudan ürün";"Toplam ürün";Oluşturma
1;0;Elektronik;elektronik;Elektronik;4;0;31;"21.07.2026 19:15"
5;1;"    Bilgisayar";bilgisayar;"Elektronik / Bilgisayar";3;0;16;"23.07.2026 19:15"
```

Ad sütunu seviyeye göre **girintilidir**; "Yol" sütunu düz bir tabloda hiyerarşiyi taşıyabilen tek şeydir.
</details>

#### HTTP durum kodları

| Kod | Anlamı |
|-----|--------|
| `200` | İşlem başarılı |
| `400` | Geçersiz parametre (örn. hatalı ID) |
| `403` | CSRF token geçersiz veya oturum düşmüş |
| `404` | Kategori bulunamadı |
| `405` | POST dışı istek |
| `409` | **Silme engellendi** — alt kategorisi ya da ürünü var |
| `422` | Döngü, derinlik aşımı ya da geçersiz ad |
| `429` | Hız sınırı aşıldı (`retry_after` saniye döner) |
| `500` | Sunucu / veritabanı hatası (recursive CTE desteklenmiyor olabilir) |

---

### Veritabanı Şeması

```sql
CREATE TABLE `categories` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `parent_id`  INT UNSIGNED NULL DEFAULT NULL,
  `name`       VARCHAR(100) NOT NULL,
  `slug`       VARCHAR(120) NOT NULL,
  `position`   INT UNSIGNED NOT NULL DEFAULT 0,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_cat_parent_pos` (`parent_id`, `position`),
  UNIQUE KEY `uq_cat_parent_slug` (`parent_id`, `slug`),
  CONSTRAINT `fk_cat_parent` FOREIGN KEY (`parent_id`)
    REFERENCES `categories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `products` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `category_id` INT UNSIGNED NOT NULL,
  `name`        VARCHAR(150) NOT NULL,
  `price`       DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  `created_at`  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_prod_category` (`category_id`),
  CONSTRAINT `fk_prod_category` FOREIGN KEY (`category_id`)
    REFERENCES `categories` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

| Karar | Neden |
|-------|-------|
| **`parent_id` NULL = kök** | Ayrı bir "kök" satırı tutmaktansa NULL kullanmak, `WHERE parent_id IS NULL` ile kökleri tek koşulda verir ve sahte bir satırı her sorguda dışlamak gerekmez |
| **`uq_cat_parent_slug` (parent_id, slug)** | `elektronik/aksesuar` ile `telefon/aksesuar` aynı anda var olabilmelidir. Global benzersizlik kullanıcıyı `aksesuar-2` gibi adlara zorlardı |
| **`idx_cat_parent_pos` (parent_id, position)** | Kardeşleri sıralı getiren her sorgunun dayandığı indeks. Recursive CTE'nin her adımı bu indeksi kullanır |
| **`position` sıkıştırılır (0,1,2…)** | Boşluklu bırakmak (10, 20, 30) ilk bakışta cazip ama zamanla çakışır ve "aradaki boşluk bitti" durumunda yine yeniden numaralandırma gerekir |
| **`ON DELETE CASCADE` (kategori→kategori)** | `cascade` stratejisi seçildiğinde alt ağacı veritabanı temizler; uygulama kodunun özyinelemeli silme yazmasına gerek kalmaz |
| **`ON DELETE RESTRICT` (ürün→kategori)** | Kategori bir etikettir, ürün ise veridir. Etiketi silmek veriyi yok etmemeli — veritabanı bunu uygulama koduna güvenmeden garanti eder |
| **`delimiter`/`order` gibi adlardan kaçınma** | `position` yerine `order` yazmak, her sorguda backtick zorunluluğu getirirdi (`ORDER` ayrılmış kelimedir) |
| **InnoDB** | Transaction ve yabancı anahtar desteği — taşımanın atomik olmasının ön şartı |

---

### Sık Sorulanlar

<details>
<summary><b>Neden nested set (lft/rgt) kullanmıyorsunuz? Okuması daha hızlı değil mi?</b></summary>

Okuması daha hızlı, yazması felakettir. Nested set'te tek bir kategori eklemek, o noktadan sonraki **bütün** satırların `lft`/`rgt` değerlerini güncellemeyi gerektirir — 500 düğümlük bir ağaçta ortaya bir ekleme için 250 satırlık `UPDATE` çıkar. Sürükle-bırakla sürekli değişen bir yönetim ekranında bu yanlış ödünleşimdir.

MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını kapattığı için karar artık nettir. Ağacınız neredeyse hiç değişmiyor ve günde milyonlarca kez okunuyorsa nested set hâlâ savunulabilir; o zaman bile closure table daha dengeli bir seçenektir.
</details>

<details>
<summary><b>Recursive CTE olmayan bir MySQL'de (5.7) ne yapabilirim?</b></summary>

Üç seçenek var:

1. **PHP'de özyineleme:** tüm tabloyu tek sorguyla çekin (`SELECT * FROM categories`) ve ağacı bellekte kurun. Birkaç bin düğüme kadar tamamen makuldür ve aslında tek sorgudur — CTE'den tek farkı işin nerede yapıldığıdır.
2. **`path` sütunu:** her satıra `1/5/11/` gibi bir materyalize yol yazın; alt ağaç `WHERE path LIKE '1/5/%'` olur. Ucuz okuma, ama taşımada tüm alt ağacın yolunu güncellemek gerekir.
3. **Closure table:** ata-torun çiftlerini ayrı tabloda tutun.

Bu proje bilinçli olarak birincisini değil CTE'yi seçti, çünkü anlatmak istediği şey tam olarak MySQL 8'in bu işi artık veritabanında yapabildiğidir.
</details>

<details>
<summary><b>Döngü kontrolünü atlarsam gerçekten ne olur?</b></summary>

Hiçbir hata almazsınız — sorun tam olarak budur. Yabancı anahtar memnundur, çünkü `parent_id` var olan bir id'yi gösterir. Ama o dal artık kökten erişilemez: `WHERE parent_id IS NULL` ile başlayan CTE halkaya hiç ulaşamaz, dolayısıyla dal **ekrandan tamamen kaybolur**.

Veritabanında hâlâ durur, sorguyla bulunabilir, ama arayüzde yoktur. Genellikle aylar sonra "şu 40 ürün nerede?" diye sorulduğunda fark edilir. Bu yüzden kontrol hem yapılır hem de **transaction içinde** yapılır.
</details>

<details>
<summary><b>Bir kategoriyi silince ürünleri ne oluyor?</b></summary>

Hiçbir şey — çünkü silme **gerçekleşmiyor**. Ürünü olan bir kategori hiçbir stratejide silinemez; uygulama önce sayar ve anlaşılır bir mesajla **409** döner, veritabanı da `ON DELETE RESTRICT` ile aynı kuralı bağımsız olarak zorlar.

`cascade` stratejisinde kontrol **tüm alt ağacı** kapsar: "Bu dalda 16 ürün var" der ve hiçbir şeye dokunmaz. Önce ürünleri başka bir kategoriye taşımanız gerekir.
</details>

<details>
<summary><b>Arama neden ağacı filtrelemiyor?</b></summary>

Çünkü ağaçta **konum bilginin kendisidir**. `aksesuar` araması iki sonuç bulur; ikisini de listeleyip bağlamdan koparmak "hangisi?" sorusunu cevapsız bırakırdı. Eşleşenleri vurgulayıp onlara giden dalları açmak, hem sonucu hem de sonucun nerede olduğunu aynı anda gösterir.

Arama Türkçe duyarsızdır: `İŞLEMCİ` yazmak `İşlemci`yi bulur. JavaScript'in `toLowerCase()`'i Türkçe I/İ çiftinde beklendiği gibi davranmadığı için harfler tek tek eşlenir (bkz. `fold()`).
</details>

<details>
<summary><b>Sürükle-bırak için neden bir kütüphane kullanmadınız?</b></summary>

Gerekmedi. Tarayıcının HTML5 Drag & Drop API'si (`dragstart` / `dragover` / `drop`) bu iş için yeterlidir ve tüm dosya 80 satır kadar tutar. Bir kütüphane eklemek 50–200 KB, bir bağımlılık ve bir sürüm yükümlülüğü getirirdi.

İki incelik vardır ve ikisi de kodda yorumlanmıştır: `dragover` içinde `preventDefault()` çağırmazsanız `drop` **hiç tetiklenmez**; iç içe listelerde `dragstart` olayını `stopPropagation()` ile durdurmazsanız olay atalara kabarır ve yanlış düğüm taşınır.
</details>

<details>
<summary><b>"Doğrudan / toplam" sayısını her satır için ayrı sorguyla mı hesaplıyorsunuz?</b></summary>

Hayır — 35 düğümde 35 sorgu ederdi. Ata-torun çiftleri tek bir recursive CTE ile üretilip (`closure`) doğrudan sayılarla birleştiriliyor (`totals`); ağacın tamamı, sayaçlarıyla birlikte **tek sorguda** geliyor.

Ağacınız on binlerce düğüme çıkarsa bu sayıları bir sütunda önbelleğe almak (`cached_total`) ve yalnızca değişimde güncellemek mantıklı olur — ama o noktaya gelmeden yapmak erken optimizasyondur.
</details>

---

### Canlı Ortama Alırken

- [ ] MySQL sürümünün **8.0+** olduğunu doğrulayın (`SELECT VERSION();`)
- [ ] `APP_DEBUG` zaten ortamdan türetiliyor — yine de canlıda `false` olduğunu **doğrulayın**
- [ ] `system/config.local.php` oluşturun; künyeyi `config.php`'ye **yazmayın**
- [ ] Veritabanı için `root` yerine **sınırlı yetkili** bir kullanıcı oluşturun
- [ ] **HTTPS** kullanın; `session.cookie_secure = 1` ve `session.cookie_httponly = 1` ayarlayın
- [ ] **Giriş sistemi ekleyin** — kimlik doğrulaması olmayan bir ağaç düzenleme ekranı, herkese açık bir yazma noktasıdır
- [ ] `MAX_DEPTH` değerini kendi menü/breadcrumb tasarımınıza göre gözden geçirin
- [ ] Silme stratejilerini daraltmayı düşünün (`DELETE_STRATEGIES`); `cascade` her projede istenmez
- [ ] Nginx kullanıyorsanız `.htaccess` çalışmaz; `.sql` ve `.md` erişimini sunucu yapılandırmasından kapatın:
  ```nginx
  location ~* \.(sql|log|ini|bak)$ { deny all; }
  ```
- [ ] Ağaç on binlerce düğüme çıkacaksa sayaçları önbelleğe almayı planlayın

---

### Sorun Giderme

| Belirti | Çözüm |
|---------|-------|
| **Ağaç hiç yüklenmiyor, 500 dönüyor** | En olası sebep: MySQL 8.0'ın altında bir sürüm, recursive CTE desteklenmiyor. `SELECT VERSION();` ile kontrol edin — uygulama `APP_DEBUG` açıkken bu ipucunu mesaja ekler. |
| **"Veritabanına bağlanılamadı"** | MySQL çalışmıyor veya `DB_*` bilgileri hatalı. XAMPP panelinden MySQL'i başlatın. |
| **Sürükleyince hiçbir şey olmuyor** | `drop` olayı `dragover` içinde `preventDefault()` çağrılmazsa tetiklenmez. Kodu değiştirdiyseniz o satırı geri koyun. |
| **Yanlış düğüm taşınıyor** | `dragstart` içinde `stopPropagation()` eksik. İç içe `<li>`'lerde olay atalara kabarır ve `dragId` en dıştaki kökle ezilir. |
| **HTTP 422 alıyorum** | Bu bir arıza değil: ya döngü (kendi torununun altına taşıma) ya da derinlik aşımı. Mesaj hangisi olduğunu söyler. |
| **HTTP 409 alıyorum** | Silme engellendi: kategorinin alt kategorileri (strateji `block`) ya da ürünleri var. Başka bir strateji seçin veya önce içeriği taşıyın. |
| **HTTP 403 dönüyor** | Oturum düşmüş — sayfayı yenileyin. Sunucuda `session.save_path` yazılabilir olmalıdır. |
| **Türkçe karakterler bozuk** | Veritabanı utf8mb4 değil ya da `cy_tree.sql` `SET NAMES utf8mb4` olmadan içe aktarılmış. Dosyayı yeniden yükleyin. |
| **Aynı ada sahip iki kategori var, biri `-2` slug aldı** | Beklenen davranış: slug aynı ebeveyn altında benzersizdir. Farklı dallarda aynı slug serbesttir. |
| **CSV Excel'de bozuk görünüyor** | Dosya UTF-8 BOM'lu ve `;` ayraçlıdır. "Metni Sütunlara Dönüştür" ile açtıysanız ayracı `;` seçin. |
| **`$ is not defined`** | JavaScript yükleme sırası bozulmuş. jQuery **her zaman** en başta gelmelidir. |

---

### Yol Haritası

- [ ] Klavyeyle taşıma (ok tuşları) — sürükle-bırakın erişilebilir karşılığı
- [ ] Çoklu seçim ve toplu taşıma
- [ ] Kategori birleştirme (iki dalı tek dala indirgeme, ürünleri taşıyarak)
- [ ] Sayaçların önbelleğe alınması (`cached_total` sütunu + tetikleyici)
- [ ] Ağacı JSON olarak dışa/içe aktarma
- [ ] Kategoriye açıklama, görsel ve SEO alanları
- [ ] Kullanıcı girişi ve rol tabanlı yetkilendirme
- [ ] PHPUnit testleri (`descendant_ids`, `node_depth`, `subtree_height`, `unique_slug`)

---

### Katkı

**Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.**

📦 **Depo:** [github.com/CilginYazilim/nested-categories](https://github.com/CilginYazilim/nested-categories)

| Nasıl katkı sağlarım? | Nereden |
|----------------------|---------|
| 🐛 Hata bildir | [Issues](https://github.com/CilginYazilim/nested-categories/issues) |
| 💡 Özellik öner | [Issues](https://github.com/CilginYazilim/nested-categories/issues) |
| 🔧 Kod gönder | [Pull Requests](https://github.com/CilginYazilim/nested-categories/pulls) |
| ❓ Soru sor | [Discussions](https://github.com/CilginYazilim/nested-categories/discussions) |

#### Katkı ölçütleri

- **Kod açıklamalı olsun.** Bu projenin temel amacı öğretmek; yorumsuz kod PR'ı geri döner.
- **Döngü ve derinlik kontrollerini transaction dışına çıkarmayın.** Eşzamanlı iki taşımada tam olarak engellemeye çalıştıkları şey oluşur.
- **Yeni bir yazma işlemi eklerken** `normalize_positions()` çağrısını ve transaction'ı unutmayın.
- **Tasarım değişikliklerini** `style.css` üzerinden yapın; `cilginyazilim.css` markaya aittir ve diğer projelerle **ortaktır**.
- Yeni bir dış kütüphane eklemeden önce issue açıp tartışalım — proje bilinçli olarak bağımlılıksızdır.

---

### Lisans

[MIT](LICENSE) — ticari kullanım dahil serbesttir.

#### Önce bir deneyin

<a href="https://cilginyazilim.com/kutuphane/uygulama/nested-categories/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane"><img src="https://img.shields.io/badge/D%C4%B0%C4%9EER_%C3%96RNEKLER-061321?style=for-the-badge&logo=bookstack&logoColor=white&labelColor=061321" alt="Diğer Örnekler" height="42"></a>

**[cilginyazilim.com](https://cilginyazilim.com)** tarafından ❤ ile geliştirildi

## Sık Sorulan Sorular

### Neden nested set (lft/rgt) yerine adjacency list kullanılıyor?

Nested set okumada hızlıdır ama yazmada felakettir: tek bir kategori eklemek, o noktadan sonraki bütün satırların lft/rgt değerlerini güncellemeyi gerektirir. 500 düğümlük bir ağaçta bir ekleme 250 satırlık UPDATE eder. Sürükle-bırakla sürekli değişen bir yönetim ekranında bu yanlış ödünleşimdir. MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını —"tüm alt ağacı getir"— ortadan kaldırdığı için karar artık nettir.

### Döngü koruması olmazsa gerçekten ne olur?

Hiçbir hata almazsınız; sorun tam olarak budur. parent_id'yi kendi torununa yazmak hiçbir kısıtı ihlal etmez, çünkü gösterilen id gerçekten vardır. Ama ağaç o an köke bağlı olmayan bir halkaya döner. Kökten inen sorgu o halkaya hiç ulaşamadığı için dal ekrandan tamamen kaybolur; veritabanında durmaya devam eder ama arayüzde yoktur. Genellikle aylar sonra "şu ürünler nerede?" diye sorulduğunda fark edilir.

### Derinlik kontrolü neden hedefin seviyesine bakmakla yetinmiyor?

Çünkü taşınan şey tek bir düğüm değil, bir alt ağaçtır. Üç seviyelik bir dalı üçüncü seviyeye taşımak, o dalın en alt yaprağını altıncı seviyeye indirir. Yalnızca hedefe bakan bir kontrol bunu kaçırır ve sınır işe yaramaz hâle gelir. Bu örnekte hedefin derinliği ile taşınan dalın kendi yüksekliği toplanır ve hata mesajı somut yazılır: "bu dal 7. seviyeye inerdi".

### Slug neden global değil de ebeveyn altında benzersiz?

Çünkü "elektronik/aksesuar" ile "telefon/aksesuar" aynı anda var olabilmelidir; URL yolları farklı olduğu için ortada çakışma yoktur. Global benzersizlik dayatmak kullanıcıyı "aksesuar-2" gibi anlamsız adlara zorlardı. Bunun bir bedeli vardır: bir dalı başka bir ebeveynin altına taşımak slug çakışması üretebilir. Doğru davranış taşımayı reddetmek değil, hedefte çakışmayan bir slug üretmektir — kullanıcının niyeti dalı taşımaktı, slug bir yan üründür.

### Bir kategoriyi silince ürünlerine ne olur?

Hiçbir şey, çünkü silme gerçekleşmez. Ürünü olan bir kategori hiçbir stratejide silinemez; uygulama önce sayar ve anlaşılır bir mesajla HTTP 409 döner, veritabanı da ON DELETE RESTRICT ile aynı kuralı bağımsız olarak zorlar. "Tüm dalı sil" stratejisinde kontrol alt ağacın tamamını kapsar. Kategori bir etikettir, ürün ise veridir; etiketi silmek veriyi yok etmemelidir.

### MySQL 5.7 kullanıyorum, recursive CTE yok. Ne yapabilirim?

Üç seçenek var. Birincisi tüm tabloyu tek sorguyla çekip ağacı PHP'de kurmak; birkaç bin düğüme kadar tamamen makuldür ve yine tek sorgudur, tek fark işin nerede yapıldığıdır. İkincisi her satıra "1/5/11/" gibi materyalize bir yol sütunu yazmak; okuması ucuzdur ama taşımada tüm alt ağacın yolunu güncellemek gerekir. Üçüncüsü closure table kullanmaktır. Bu proje bilinçli olarak CTE'yi seçti, çünkü anlatmak istediği şey MySQL 8'in bu işi artık veritabanında yapabilmesidir.

---

Kaynak: [İç İçe Kategoriler (Ağaç Yapı)](https://cilginyazilim.com/kutuphane/php-nested-categories)
