---
title: "Zamanlanmış Görevler (Scheduler)"
url: "https://cilginyazilim.com/kutuphane/php-scheduler-cron"
description: "Bu örnek, dağınık cron satırları yerine tek bir cron girdisiyle yönetilen bir zamanlanmış görev sistemi kurar: sunucuya yazılan tek satır her dakika bin/scheduler.php dosyasını çağırır, hangi görevin ne zaman çalışacağı ise veritabanında durur ve panelden yönetilir. Projenin içinde harici kütüphane olmadan yazılmış bir CronExpression sınıfı vardır; ifadeyi doğrular, çalışma zamanının gelip gelmediğini söyler, bir sonraki çalışmayı ileriye doğru hesaplar ve ifadeyi insan diline çevirir. Sonraki çalışma tarihinin ekranda görünmesi, yanlış kurulmuş bir ifadeyi anında ele verir. Üst üste binme sorunu atomik bir kilitle çözülür: kilidi almak ve kontrol etmek tek bir UPDATE ifadesindedir, böylece iki süreç aynı anda geçemez. Kilidin her zaman bir zaman aşımı vardır; aksi hâlde çöken bir süreç görevi sonsuza kadar kilitler ve görev bir daha hiç çalışmaz. Her çalışma; durumu, milisaniye cinsinden süresi ve çıktısıyla kaydedilir — süre alanı, hata oluşmadan önce bozulmayı gösteren en pratik erken uyarı sinyalidir. Görev çıktısı ob_start ile yakalanır, ayrı bir günlük API'si öğrenmek gerekmez."
published: "2026-09-03T09:44:21+03:00"
modified: "2026-09-05T02:26:58+03:00"
category: "Uygulama İskeleti"
type: "kod örneği"
difficulty: "Orta"
tech: ["PHP 8", "PDO", "MySQL", "Bootstrap 5", "Ajax", "Oturum Girişi"]
tags: ["Çalışma Geçmişi", "Cron", "Kilit", "Scheduler", "Zamanlanmış Görev"]
license: "MIT"
site: "CılgınYazılım"
language: "tr"
---

# Zamanlanmış Görevler (Scheduler)

Bu örnek, dağınık cron satırları yerine tek bir cron girdisiyle yönetilen bir zamanlanmış görev sistemi kurar: sunucuya yazılan tek satır her dakika bin/scheduler.php dosyasını çağırır, hangi görevin ne zaman çalışacağı ise veritabanında durur ve panelden yönetilir. Projenin içinde harici kütüphane olmadan yazılmış bir CronExpression sınıfı vardır; ifadeyi doğrular, çalışma zamanının gelip gelmediğini söyler, bir sonraki çalışmayı ileriye doğru hesaplar ve ifadeyi insan diline çevirir. Sonraki çalışma tarihinin ekranda görünmesi, yanlış kurulmuş bir ifadeyi anında ele verir. Üst üste binme sorunu atomik bir kilitle çözülür: kilidi almak ve kontrol etmek tek bir UPDATE ifadesindedir, böylece iki süreç aynı anda geçemez. Kilidin her zaman bir zaman aşımı vardır; aksi hâlde çöken bir süreç görevi sonsuza kadar kilitler ve görev bir daha hiç çalışmaz. Her çalışma; durumu, milisaniye cinsinden süresi ve çıktısıyla kaydedilir — süre alanı, hata oluşmadan önce bozulmayı gösteren en pratik erken uyarı sinyalidir. Görev çıktısı ob_start ile yakalanır, ayrı bir günlük API'si öğrenmek gerekmez.

- **Gereksinim:** PHP 8.0+ (CLI erişimi) · MySQL 5.7+ / MariaDB 10.3+ · pdo_mysql · mbstring · cron veya Görev Zamanlayıcı · Apache mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/scheduler-system

## Öne Çıkan Özellikler

- Sunucuda tek cron satırı; görevler veritabanından, panelden yönetilir
- Kendi cron ayrıştırıcısı: isValid, isDue, nextRun ve describe
- Operatörler: yıldız, */n, a-b, a,b,c ve a-b/n
- "Sıradaki çalışma" gerçek bir tarih olarak hesaplanır — yanlış kurulan görev anında belli olur
- İfade insan diline çevrilir ("Her 15 dakikada bir")
- Üst üste binmeyi atomik kilit engeller: kontrol ve yazma tek UPDATE ifadesinde
- Görev başına kilit zaman aşımı — çöken süreç görevi sonsuza kadar kilitlemez
- Her çalışma durum, milisaniye cinsinden süre ve çıktısıyla kaydedilir
- Başarısız görev durdurulmaz; bir kez patlayan görev, hiç çalışmayandan iyidir

## Kullanım Senaryoları

- Birden fazla zamanlanmış işi tek yerden görmek isteyenler
- "Gece raporu çalıştı mı?" sorusunu cevaplamak zorunda kalanlar
- Üst üste binen görevlerden dolayı çift kayıt üretenler
- Cron ifadelerinin PHP'de nasıl ayrıştırıldığını öğrenmek isteyenler

## Kurulum

1. Depoyu klonlayın veya ZIP olarak indirip web kökünüze açın
2. database.sql dosyasını içe aktarın (veritabanını kendisi oluşturur)
3. .env.example dosyasını .env adıyla kopyalayıp veritabanı bilgilerinizi yazın
4. Sunucuya tek cron satırını ekleyin: * * * * * php bin/scheduler.php
5. Tarayıcıdan açıp admin@cilginyazilim.com / Admin1234 ile giriş yapın

## Ayrıntılı Anlatım

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

## Zamanlanmış Görevler (Scheduler)

#### PHP 8 · PDO · MySQL · Oturum Girişli Panel · Cron Ayrıştırıcı · Kilit · Çalışma Geçmişi · Çılgın Yazılım Tasarım Kalıbı

**Sunucuya tek bir cron satırı yazın; görevleri panelden yönetin.**

[![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-5.7%2B-4479A1?style=flat-square&logo=mysql&logoColor=white)](https://mysql.com)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-5.3-7952B3?style=flat-square&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![Composer](https://img.shields.io/badge/Composer-gerekmiyor-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/scheduler-system/) · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane/php-scheduler-cron) · [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/scheduler-system/"><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-scheduler-cron"><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/scheduler-system/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/scheduler-system/" title="Canlı demoyu açmak için tıklayın">
  <img src="docs/screenshots/03-gorevler.png" alt="Zamanlanmış görevler canlı demo önizlemesi" width="860">
</a>

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

<br>

#### Demo hesapları

| Rol | E-posta | Parola |
|---|---|---|
| Yönetici | `admin@cilginyazilim.com` | `Admin1234` |
| Kullanıcı | `demo@cilginyazilim.com` | `Demo1234` |

#### Demoda 60 saniyede neleri deneyebilirsiniz?

| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|---|---|
| **1** | **Zamanlanmış Görevler** sayfasını açın, "Sıradaki" sütununa bakın | Her satırda **gerçek bir tarih** yazar. Bu, cron ifadesinin PHP'de ayrıştırılıp ileriye doğru hesaplanmasıyla bulunur — sabit bir metin değildir |
| **2** | "Günlük temizliği" satırının ifadesini `0 3 * * *` yerine `*/5 * * * *` yapıp **Kaydet**e basın | Altındaki açıklama anında **"Her 5 dakikada bir"**e döner ve "Sıradaki" tarihi yeniden hesaplanır. İnsan diline çeviri de aynı ayrıştırıcıdan gelir |
| **3** | İfadeye `abc` yazıp kaydetmeyi deneyin | Reddedilir. `CronExpression::isValid()` kullanıcıdan gelen her ifadeyi süzer; geçersiz bir ifade veritabanına **yazılmaz** |
| **4** | "Haftalık özet e-postası" satırına bakın: **Sıradaki** sütunu boş, "durduruldu" diyor | Görev pasiftir. Zamanı gelse bile çalıştırılmaz ve üstteki "etkin görev" sayacına dahil edilmez |
| **5** | Bir görevin **Durdur / Başlat** anahtarına basın | `is_active` değişir; sayaç anında güncellenir |
| **6** | **"Şimdi çalıştır"** düğmesine basın | Görev zamanına bakılmadan tetiklenir. Sonuç saniyeler içinde **Çalışma Geçmişi**'ne düşer: süre, çıktı ve durum |
| **7** | **Çalışma Geçmişi** sayfasını açın | Son 24 saatte 16 çalışma, 2'si hatalı. Başarısız satırlar **kırmızı** rozetli ve okunabilir bir hata mesajı taşır |
| **8** | "Kullanıcı yedeği" satırının son durumuna bakın: **Hata** | Bir görev başarısız olduğunda `fail_count` artar ama görev **durdurulmaz**. Bir kez patlayan görev, bir daha hiç çalışmayan görevden iyidir |
| **9** | Sayfanın altındaki **Cron Sözdizimi** kartına bakın | Beş alanın ne olduğu ve `*/15`, `30 9-17/2 * * 1-5` gibi ifadelerin ne anlama geldiği örneklerle yazılıdır |
| **10** | Telefonunuzdan açın | Görev tablosu daralır, sayaçlar alt alta dizilir; sayfa gövdesinde **yatay kaydırma yoktur** |

> **Not:** Demo sunucusunda dakikada bir çalışan gerçek bir cron **yoktur**; geçmişteki kayıtlar örnek veridir. "Şimdi çalıştır" düğmesi ise gerçekten çalışır ve geçmişe yeni bir satır ekler.

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

| Konu | Durum |
|---|---|
| **Veriler** | `database.sql` içindeki **51 kullanıcı + 5 görev + 21 çalışma kaydı**. Gerçek kişi verisi yoktur. |
| **Sıfırlama** | Demo veritabanı **düzenli aralıklarla** başlangıç hâline döner. |
| **Cron** | Demoda kurulu **değil**. Yerelde tek satırla kurulur. |
| **Kimlik doğrulama** | **Vardır.** Oturum, "beni hatırla", hız sınırı ve CSRF. |
| **`APP_DEBUG`** | Canlıda **kendiliğinden `false`** — sunucu adından türetilir. |
| **Bağımlılık** | **Sıfır.** Composer yok, npm yok, CDN yok. |

---

### Bu Proje Nedir?

Bir projede zamanlanmış işler çoğalmaya başlar: gece günlük temizliği, 15 dakikada bir sağlık kontrolü, pazar sabahı yedek. Her biri için sunucuya ayrı bir cron satırı yazılır. Sonra şu sorunlar sırayla gelir:

- **Hangi görevler var?** Cevap `crontab -e` çıktısındadır; ekipteki çoğu kişi ona erişemez.
- **Dün gece çalıştı mı?** Cevap yoktur. Cron çıktısı `/dev/null`'a gidiyorsa hiçbir iz kalmaz.
- **Neden iki kez çalıştı?** Önceki çalışma bitmeden yenisi başladı; ikisi aynı tabloyu yazdı.
- **Bir görevi geçici olarak durdurmak** için sunucuya SSH ile bağlanmak gerekir.

Bu proje sorunu tersine çevirir: sunucuya **tek bir** cron satırı yazılır ve o satır her dakika `bin/scheduler.php` dosyasını çağırır. Hangi görevin ne zaman çalışacağı artık veritabanındadır, panelden yönetilir; her çalışma süresi, çıktısı ve durumuyla birlikte **kaydedilir**.

Cron ifadelerini ayrıştıran, ne zaman çalışacağını hesaplayan ve insan diline çeviren `CronExpression` sınıfı da projenin içindedir — harici bir kütüphane kullanılmaz.

**Kimler için uygun?**

- Birden fazla zamanlanmış işi olan ve hepsini tek yerden görmek isteyenler
- "Gece raporu çalıştı mı?" sorusunu cevaplamak zorunda kalanlar
- Üst üste binen (overlap) görevlerden dolayı çift kayıt üretenler
- Cron ifadelerinin PHP'de nasıl ayrıştırıldığını öğrenmek isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir panel kalıbı arayanlar

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)
- [Bu Proje Nedir?](#bu-proje-nedir)
- [Ekran Görüntüleri](#ekran-görüntüleri)
- [Tek cron satırı](#tek-cron-satırı)
- [Kritik Kararlar](#kritik-kararlar)
- [Neler Var?](#neler-var)
- [Cron sözdizimi](#cron-sözdizimi)
- [Kendi görevinizi yazmak](#kendi-görevinizi-yazmak)
- [Güvenlik: Neyi, Nasıl Kapattık?](#güvenlik-neyi-nasıl-kapattık)
- [Kurulum](#kurulum)
- [Yapılandırma](#yapılandırma)
- [Dosya Yapısı](#dosya-yapısı)
- [Nasıl Çalışıyor?](#nasıl-çalışıyor)
- [Veritabanı Şeması](#veritabanı-şeması)
- [SSS](#sss)
- [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

#### Zamanlanmış görevler

Cron ifadesi satırın içinde düzenlenir; altında ifadenin insan dilindeki karşılığı ("Her 15 dakikada bir") anında yazar. **Sıradaki** sütunu gerçek bir tarihtir — yanlış kurulmuş bir görev, aylar sonra değil o an belli olur. Son durum hücresi görevin toplam çalışma ve hata sayısını taşır; altta cron sözdizimi kartı alanları ve sık kullanılan ifadeleri gösterir.

![Zamanlanmış görevler: yerinde düzenlenen cron ifadesi, sıradaki çalışma ve son durum](docs/screenshots/03-gorevler.png)

#### Çalışma geçmişi

Her çalışma; durumu, **milisaniye cinsinden süresi** ve zamanıyla kaydedilir. "çıktıyı göster" satırı görevin bastığı her şeyi açar — ayrı bir günlük API'si yoktur, çıktı `ob_start()` ile yakalanır.

![Çalışma geçmişi: başarılı ve hatalı çalışmalar, süreleri ve açılabilir çıktıları](docs/screenshots/04-calisma-gecmisi.png)

#### Kontrol paneli

Sunucuya yazılacak **tek** cron satırı burada, kopyalanmaya hazır durur. Sayaç şeridi zamanlayıcının nabzını verir: kaç görev etkin, son 24 saatte kaç çalışma oldu, kaçı hata verdi.

![Kontrol paneli: sayaç şeridi ve sunucuya yazılacak tek cron satırı](docs/screenshots/02-kontrol-paneli.png)

#### Giriş ekranı

Demo hesapları tek tıkla doldurulur. Giriş denemeleri hız sınırına tabidir; art arda başarısız denemeden sonra hesap geçici olarak kilitlenir.

![Giriş ekranı: demo hesapları tek tıkla doldurulur](docs/screenshots/01-giris.png)

#### Koyu tema

Tema tarayıcıda değil **kullanıcı hesabında** saklanır; başka bir cihazdan girdiğinizde de aynı gelir.

![Koyu tema görünümü](docs/screenshots/05-koyu-tema.png)

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

390px genişlikte alt navigasyon devreye girer. Sayfa gövdesinde yatay kaydırma yoktur; geniş tablolar yalnızca kendi kapsayıcılarında kayar.

<img src="docs/screenshots/06-mobil.png" alt="390px genişlikte mobil görünüm" width="360">

---

### Tek cron satırı

Sunucuya yazılacak **tek** satır budur:

```cron
* * * * * cd /var/www/uygulama && php bin/scheduler.php >> /dev/null 2>&1
```

Her dakika çalışır, veritabanındaki görevlere bakar ve **zamanı gelenleri** çalıştırır. Yeni bir görev eklediğinizde sunucuya dokunmanız gerekmez; panele bir satır eklemeniz yeter.

**Windows'ta** Görev Zamanlayıcı'dan her dakika tekrarlanan bir görev tanımlayın:

| Alan | Değer |
|---|---|
| Program | `C:\xampp\php\php.exe` |
| Bağımsız değişken | `C:\xampp\htdocs\scheduler-system\bin\scheduler.php` |
| Tekrarlama | Her 1 dakikada bir |

**Elle denemek için:**

```bash
php bin/scheduler.php --list      # görevler ve sıradaki çalışma zamanları
php bin/scheduler.php             # zamanı gelenleri çalıştır
php bin/scheduler.php --run=3     # 3 numaralı görevi zamanına bakmadan çalıştır
php bin/scheduler.php --quiet     # çıktı verme (cron için)
php bin/scheduler.php --help
```

---

### Kritik Kararlar

#### 1. Üst üste binmeyi kilit engeller — ve kilidin zaman aşımı vardır

Cron her dakika çalışır. Görev 90 saniye sürerse ikinci çalışma, birincisi bitmeden başlar. İkisi aynı tabloyu yazarsa çift kayıt üretirsiniz.

Klasik ve **yanlış** çözüm:

```php
if ($task['locked_at'] === null) {        // ◄── yarış koşulu
    $db->exec("UPDATE tasks SET locked_at = NOW() WHERE id = {$id}");
    // ...
}
```

İki süreç `if`'i aynı anda geçebilir. Doğrusu, kontrolü ve yazmayı **tek ifadede** yapmaktır:

```sql
UPDATE scheduled_tasks
   SET locked_at = NOW()
 WHERE id = :id
   AND (locked_at IS NULL OR locked_at < (NOW() - INTERVAL :timeout SECOND))
```

```php
if ($lock->rowCount() === 0) {
    return false;   // kilidi başkası aldı, bu çalışmayı atla
}
```

**Zaman aşımı olmazsa ne olur?** Süreç çökerse `locked_at` sonsuza kadar dolu kalır ve görev bir daha **hiç** çalışmaz — üstelik sessizce. Bu yüzden kilit her zaman "şu kadar saniyeden eskiyse geçersiz say" kuralıyla birlikte kurulur. Süre her görev için ayrı ayarlanabilir (`lock_timeout`); yedekleme görevine 3600, sağlık kontrolüne 300 saniye verilmiştir.

#### 2. Cron ifadesi PHP'de ayrıştırılır, kütüphane kullanılmaz

`CronExpression` sınıfı beş alanı ayrıştırır ve üç soruya cevap verir:

| Metot | Cevabı |
|---|---|
| `isValid()` | Bu ifade geçerli mi? (kullanıcı girdisini süzmek için) |
| `isDue()` | Şu an çalışmalı mı? |
| `nextRun()` | Bir sonraki çalışma ne zaman? |
| `describe()` | İnsan diliyle ne demek? |

`nextRun()` sayesinde panel "Sıradaki: 04.09.2026 03:00" yazabilir. Bu, cron'un normalde vermediği bir bilgidir ve bir görevin **yanlış kurulduğunu** anında gösterir: beklediğiniz tarih değilse ifade hatalıdır.

`describe()` ise `*/15 * * * *` ifadesini "Her 15 dakikada bir" diye yazar. Cron sözdizimi, günde bir kez bakan biri için hiçbir zaman sezgisel olmaz.

**Ayrıştırma ayrıntısı:** alanlar `explode(' ')` ile değil `preg_split('/\s+/')` ile ayrılır. Kullanıcı `0  3 * * *` gibi çift boşluk yazarsa `explode` boş parçalar üretir ve ifade geçersiz sayılırdı.

#### 3. Görev tanımı veritabanında, görev **kodu** dosyada

```
scheduled_tasks.task_class   →  App\Tasks\PruneLogsTask
scheduled_tasks.payload      →  {"days":90}
```

Ne zaman çalışacağı ve hangi parametreyle çalışacağı veritabanındadır — bunlar sık değişir ve panelden yönetilmelidir. **Ne yapacağı** ise koddadır; sürüm kontrolünde durur, kod incelemesinden geçer.

Worker sınıfı yüklerken `Task` sınıfından türediğini doğrular; `task_class` alanına elle bir değer yazılsa bile rastgele bir sınıf çalıştırılamaz.

#### 4. Başarısız görev durdurulmaz

Bir görev hata verdiğinde `fail_count` artar, `last_status` `failed` olur ve çalışma geçmişine hata mesajıyla bir satır düşer. Ama görev **pasife alınmaz**.

Sebep: geçici bir hata (disk doldu, veritabanı bir saniye yanıt vermedi) yüzünden görevi kalıcı olarak durdurmak, bir sonraki gerçek çalışmayı da engellerdi. Bir kez patlayan görev, bir daha hiç çalışmayan görevden iyidir. Sayaçlar zaten sorunu görünür kılar.

#### 5. Her çalışma kaydedilir — süresi ve çıktısıyla

`task_runs` tablosu her çalışma için bir satır tutar: durum, **milisaniye cinsinden süre** ve çıktı.

Süre en çok işe yarayan alandır: normalde 40 ms süren sağlık kontrolü bir gün 5 saniye sürüyorsa, henüz hata vermeden önce bir şeyin bozulduğunu anlarsınız.

#### 6. "Şimdi çalıştır" düğmesi zamanı yok sayar, kilidi yok saymaz

Panelden elle tetiklenen görev, `isDue()` kontrolünden geçmez — zaten amaç zamanına bakmadan çalıştırmaktır. Ama **kilidi** alır: cron o sırada aynı görevi çalıştırıyorsa düğme onu ikinci kez başlatmaz.

---

### Neler Var?

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

**Zamanlayıcı çekirdeği**

- `CronExpression` — ayrıştırma, doğrulama, sonraki çalışma, açıklama
- `*`, `*/n`, `a-b`, `a,b,c`, `a-b/n` desteği
- Atomik kilit + görev başına zaman aşımı
- Çalışma geçmişi: durum, süre (ms), çıktı
- Çalışma ve hata sayaçları
- Görev başına etkin/pasif anahtarı

**CLI**

- `--list` görevler ve sıradaki zamanlar
- `--run=<id>` tek görevi şimdi çalıştır
- `--quiet` sessiz mod (cron için)
- `PHP_SAPI` kontrolü — tarayıcıdan çalışmaz

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

**Panel**

- Görev listesi, satır içinde ifade düzenleme
- "Sıradaki" sütunu — hesaplanmış gerçek tarih
- İnsan dilinde açıklama (`Her 15 dakikada bir`)
- Şimdi çalıştır / Durdur / Başlat
- Çalışma geçmişi, başarılı ve hatalı ayrımı
- Cron sözdizimi başvuru kartı

**Ortak altyapı**

- Oturum girişi, "beni hatırla", hız sınırı, CSRF
- CSP (`script-src 'self'`), `X-Frame-Options: DENY`
- Açık / koyu tema, hesaba kayıtlı
- Mobilde alt navigasyon, yatay kaydırma yok

</td></tr>

---

### Cron sözdizimi

```
┌───────────── dakika        (0-59)
│ ┌─────────── saat          (0-23)
│ │ ┌───────── ayın günü     (1-31)
│ │ │ ┌─────── ay            (1-12)
│ │ │ │ ┌───── haftanın günü (0-6, 0 = Pazar)
│ │ │ │ │
* * * * *
```

| İfade | Anlamı |
|---|---|
| `* * * * *` | Her dakika |
| `*/15 * * * *` | Her 15 dakikada bir |
| `0 * * * *` | Her saat başı |
| `0 3 * * *` | Her gün saat 03:00 |
| `30 2 * * 0` | Her Pazar 02:30 |
| `0 9 * * 1` | Her Pazartesi 09:00 |
| `0 0 1 * *` | Her ayın 1'i gece yarısı |
| `30 9-17/2 * * 1-5` | Hafta içi 09:30, 11:30, 13:30, 15:30, 17:30 |

Dört operatör desteklenir:

| Operatör | Örnek | Anlamı |
|---|---|---|
| `*` | `*` | Her değer |
| `,` | `1,15` | Listedeki değerler |
| `-` | `9-17` | Aralık |
| `/` | `*/15`, `9-17/2` | Adım (aralıkla birlikte de kullanılabilir) |

---

### Kendi görevinizi yazmak

Bir görev, `Task` sınıfından türeyen ve üç metot uygulayan bir sınıftır.

```php
<?php
namespace App\Tasks;

use PDO;

final class ExpireCouponsTask extends Task
{
    public static function label(): string
    {
        return 'Süresi dolan kuponlar';
    }

    public static function description(): string
    {
        return 'Geçerlilik süresi dolmuş kuponları pasife alır.';
    }

    /**
     * Görevin kendisi.
     *
     * EKRANA YAZDIĞINIZ HER ŞEY geçmişe kaydedilir. Scheduler,
     * handle() çağrısını ob_start() / ob_get_clean() arasına alır ve
     * yakaladığı metni task_runs.output sütununa yazar. Yani ayrı bir
     * "günlüğe yaz" API'si öğrenmenize gerek yok: echo yeter.
     *
     * Ne olduğunu SÖYLEYEN bir cümle yazın. "Tamamlandı" yerine
     * "14 kupon pasife alındı" yazmak, altı ay sonra size çok şey anlatır.
     *
     * İstisna fırlatırsa çalışma BAŞARISIZ sayılır: hata mesajı çıktının
     * sonuna "HATA: ..." olarak eklenir ve fail_count artar.
     */
    public function handle(array $payload, PDO $db): void
    {
        $gun = (int) ($payload['days'] ?? 0);

        $stmt = $db->prepare(
            'UPDATE kuponlar SET aktif = 0
              WHERE aktif = 1 AND bitis_tarihi < (NOW() - INTERVAL :gun DAY)'
        );
        $stmt->execute([':gun' => $gun]);

        printf('%d kupon pasife alındı.' . PHP_EOL, $stmt->rowCount());
    }
}
```

Sonra panelden ya da doğrudan veritabanından kaydını ekleyin:

```sql
INSERT INTO scheduled_tasks
    (name, description, task_class, payload, expression, is_active, lock_timeout, created_at)
VALUES
    ('Süresi dolan kuponlar',
     'Geçerlilik süresi dolmuş kuponları pasife alır.',
     'App\\Tasks\\ExpireCouponsTask',
     '{"days":0}',
     '0 4 * * *',
     1, 300, NOW());
```

**Üç kural:**

1. **Görev kısa sürsün.** Uzun süren işi burada değil, [iş kuyruğunda](https://cilginyazilim.com/kutuphane/php-queue-worker) çalıştırın; zamanlanmış görev yalnızca kuyruğa iş bıraksın. Cron zamanı belirler, kuyruk dayanıklılığı sağlar.
2. **`lock_timeout`'u işin süresinden büyük seçin.** Aksi hâlde hâlâ çalışan bir görev "takılmış" sayılıp ikinci kez başlatılır.
3. **Çıktıyı anlamlı yazın.** `echo`/`printf` ile yazdığınız her şey geçmişe düşer. Altı ay sonra geçmişe baktığınızda "Tamamlandı" hiçbir şey anlatmaz; "2.318 kayıt silindi" anlatır.

---

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

| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| **Rastgele sınıf çalıştırma** | `(new $row['class'])->run()` | Sınıf `Task`'tan türemiyorsa **çalıştırılmaz** |
| **Yarış koşulu (üst üste binme)** | "Önce bak, sonra kilitle" | Kontrol ve yazma **tek `UPDATE` ifadesinde**; `rowCount()` ile doğrulanır |
| **Sonsuza kadar kilitli görev** | Zaman aşımsız kilit | Kilit `lock_timeout` saniyeden eskiyse **geçersiz** sayılır |
| **Geçersiz cron ifadesi** | Doğrulamadan kaydetmek | `CronExpression::isValid()` — geçersiz ifade veritabanına yazılmaz |
| **SQL enjeksiyonu** | `"... WHERE id = $id"` | Tüm sorgular hazır ifade; `ATTR_EMULATE_PREPARES = false` |
| **CLI betiğine web'den erişim** | `bin/` klasörü açık | `bin/.htaccess` ile kapalı **ve** betikte `PHP_SAPI === 'cli'` kontrolü |
| **CSRF** | Gizli alan yok | Her POST'ta jeton; `hash_equals()` |
| **XSS** | `echo $run['output']` | Sunucuda `e()`; ayrıca CSP `script-src 'self'` |
| **Parola sızıntısı** | `md5($parola)` | `password_hash()` / `password_verify()` |
| **Kaba kuvvet** | Sınırsız deneme | `login_attempts` ile e-posta + IP başına hız sınırı |
| **Hata sızıntısı** | Canlıda `display_errors=On` | `APP_DEBUG` **ortamdan türetilir** |
| **Sınırsız büyüyen geçmiş** | Temizlenmeyen log tablosu | `task_runs` için indeksli tarih sütunu; örnek temizlik görevi projede hazır |

---

### Kurulum

#### Gereksinimler

| | |
|---|---|
| PHP | 8.0 veya üzeri (CLI erişimi gerekir) |
| MySQL / MariaDB | 5.7+ / 10.3+ |
| Web sunucusu | Apache (`mod_rewrite`) veya Nginx |
| PHP eklentileri | `pdo_mysql`, `mbstring` |

#### Adımlar

```bash
git clone https://github.com/CilginYazilim/scheduler-system.git
cd scheduler-system

mysql -u root -p < database.sql
cp .env.example .env        # Windows: copy .env.example .env
```

Açın: `http://localhost/scheduler-system/` · Giriş: `admin@cilginyazilim.com` / `Admin1234`

Sonra tek cron satırını kurun → [Tek cron satırı](#tek-cron-satırı)

---

### Yapılandırma

```env
APP_DEBUG=true          # silerseniz: yerelde açık, canlıda kapalı
APP_URL=
APP_PRETTY_URLS=true

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=cy_scheduler
DB_USER=root
DB_PASS=
••••••••

> **Zaman dilimi önemlidir.** `config/config.php` içindeki `app.timezone` (`Europe/Istanbul`) ile MySQL sunucusunun zaman dilimi aynı olmalıdır. Farklıysa "gece 03:00" dediğiniz görev başka bir saatte çalışır. Kontrol için: `SELECT NOW();` çıktısı ile `date('Y-m-d H:i:s')` çıktısını karşılaştırın.

---

## Dosya Yapısı

```
scheduler-system/
│
├── index.php                  Ön denetleyici — TEK giriş noktası
├── database.sql               Şema + 5 görev + 21 çalışma kaydı
├── .env.example
│
├── bin/
│   ├── scheduler.php          
 Cron'un çağırdığı tek dosya
│   └── .htaccess              Web erişimine kapalı
│
├── app/
│   ├── Core/
│   │   ├── CronExpression.php 
 Ayrıştırma · isDue · nextRun · describe
│   │   ├── Scheduler.php      
 Kilit alma, çalıştırma, geçmişe yazma
│   │   ├── Auth.php · Session.php · Csrf.php · RateLimiter.php
│   │   ├── Database.php       PDO (EMULATE_PREPARES = false)
│   │   ├── Env.php            .env okuyucu + isLocalHost()
│   │   └── ...
│   │
│   ├── Tasks/
│   │   ├── Task.php           
 Soyut taban: handle() · label() · description()
│   │   ├── PruneLogsTask.php  Örnek: eski günlük kayıtlarını sil
│   │   ├── HealthCheckTask.php Örnek: veritabanı/disk/izin denetimi
│   │   └── BackupTask.php     Örnek: CSV yedeği al, en yeni 5'i sakla
│   │
│   ├── Http/Controllers/      Auth · Dashboard · Schedule · User
│   ├── Repositories/          ScheduleRepository · UserRepository · ActivityRepository
│   └── Support/helpers.php
│
├── storage/                   Yedek ve rapor çıktıları (web'e kapalı)
├── views/                     Düzenler, görev ve geçmiş sayfaları
├── assets/                    css · js · images
├── config/config.php
├── routes/web.php
└── docs/screenshots/
```

---

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

```
crontab:  * * * * *  php bin/scheduler.php
                │
                ▼  (her dakika)
      Scheduler::runDue()
                │
                ▼
      SELECT * FROM scheduled_tasks WHERE is_active = 1
                │
                ▼  her görev için
        ┌───────────────────────────────────┐
        │ CronExpression($task['expression'])│
        │        ->isDue(now)                │
        └───────────────────────────────────┘
                │              │
          hayır │              │ evet
                ▼              ▼
             ATLA      ┌──────────────────────────────────┐
                       │ ATOMİK KİLİT                     │
                       │ UPDATE scheduled_tasks           │
                       │    SET locked_at = NOW()         │
                       │  WHERE id = ?                    │
                       │    AND (locked_at IS NULL        │
                       │     OR  locked_at < NOW()        │
                       │         - INTERVAL timeout SEC)  │
                       └──────────────────────────────────┘
                                   │
                        rowCount() === 0 ?
                          │              │
                     evet │              │ hayır (kilit bizde)
                          ▼              ▼
                  ATLA (başkası    Task::handle($payload, $db)
                   çalıştırıyor)          │
                                          ├─ başarılı → last_status='success'
                                          │             run_count++
                                          │
                                          └─ istisna  → last_status='failed'
                                                        fail_count++
                                          │
                                          ▼
                                  task_runs tablosuna satır
                                  (durum · süre(ms) · çıktı)
                                          │
                                          ▼
                                  locked_at = NULL   ◄── kilit bırakılır
```

---

## Veritabanı Şeması

### `scheduled_tasks` — görev tanımları

| Sütun | Tip | İşi |
|---|---|---|
| `id` | INT UNSIGNED | Birincil anahtar |
| `name` · `description` | VARCHAR | Panelde görünen ad ve açıklama |
| `task_class` | VARCHAR(190) | Çalıştırılacak sınıf (`App\Tasks\PruneLogsTask`) |
| `payload` | TEXT | Görevin parametreleri (JSON) |
| `expression` | VARCHAR(100) | Cron ifadesi (`0 3 * * *`) |
| `is_active` | TINYINT(1) | Pasifse zamanı gelse bile çalışmaz |
| `locked_at` | DATETIME | Çalışıyorsa dolu; `NULL` = boşta |
| `lock_timeout` | INT UNSIGNED | Kilit kaç saniye sonra geçersiz sayılsın |
| `last_run_at` · `last_status` | DATETIME · ENUM | Son çalışma anı ve sonucu |
| `run_count` · `fail_count` | INT UNSIGNED | Toplam çalışma ve hata sayısı |

### `task_runs` — çalışma geçmişi

| Sütun | Tip | İşi |
|---|---|---|
| `id` | BIGINT UNSIGNED | Birincil anahtar |
| `task_id` | INT UNSIGNED | Hangi göreve ait (`ON DELETE CASCADE`) |
| `status` | ENUM('success','failed') | Sonuç |
| `duration_ms` | INT UNSIGNED | Süre — **erken uyarı sinyali** |
| `output` | TEXT | Görevin ekrana yazdığı metin (çıktı tamponundan yakalanır) ve varsa hata mesajı |
| `ran_at` | DATETIME | Çalışma anı (indeksli) |

| Karar | Neden |
|---|---|
| Geçmiş **ayrı tabloda** | `scheduled_tasks` her dakika okunur; geçmiş satırlarıyla şişmemeli |
| Süre **milisaniye** olarak | 40 ms → 5.000 ms değişimi, hata oluşmadan önce bozulmayı gösterir |
| `locked_at` görev satırında, ayrı tabloda değil | Kilidi almak ve görevi güncellemek **tek `UPDATE`** olsun diye; ayrı tablo ikinci bir yarış koşulu açardı |
| `last_status` özet olarak tutuluyor | Liste sayfası her satır için geçmiş tablosuna sorgu atmasın (N+1) |
| Sayaçlar (`run_count`) satırda | Aynı sebep: `COUNT(*)` yerine hazır sayı |

---

## SSS

<details>
<summary><b>Sunucumda cron yok, ne yapabilirim?</b></summary>

Üç seçenek:

1. **cPanel / Plesk**: panelde "Cron Jobs" bölümü vardır; komutu oraya yazın.
2. **Windows**: Görev Zamanlayıcı'dan her dakika tekrarlanan bir görev tanımlayın.
3. **Harici tetikleyici**: `bin/scheduler.php` yerine korumalı bir web adresi açıp cron-job.org gibi bir servisle her dakika çağırın. Bu durumda adrese **mutlaka** bir gizli anahtar koyun; herkese açık bir adres, görevlerinizi isteyen herkesin tetikleyebilmesi demektir.
</details>

<details>
<summary><b>Görev her dakika çalışıyor, oysa `0 3 * * *` yazdım</b></summary>

Neredeyse her zaman zaman dilimi farkıdır. PHP `Europe/Istanbul`, MySQL `UTC` ise `NOW()` üç saat geride olur ve hesaplar tutmaz.

`SELECT NOW();` çıktısı ile `php -r "echo date('Y-m-d H:i:s');"` çıktısını karşılaştırın. Farklıysa MySQL'in `time_zone` ayarını düzeltin veya `config/config.php` içindeki `app.timezone` değerini sunucuya uydurun.
</details>

<details>
<summary><b>Görev iki kez çalıştı, kilit çalışmıyor mu?</b></summary>

Kilit çalışıyordur; muhtemelen `lock_timeout` görevin süresinden **kısadır**.

Görev 400 saniye sürüyor ama `lock_timeout` 300 ise, 300. saniyede kilit "eski" sayılır ve ikinci çalışma başlar. Süreyi çalışma geçmişindeki `duration_ms` değerine bakarak ayarlayın; en uzun çalışmanın en az iki katı iyi bir başlangıçtır.
</details>

<details>
<summary><b>Uzun süren bir işi buradan çalıştırabilir miyim?</b></summary>

Çalıştırabilirsiniz ama önerilmez. Zamanlanmış görev **zamanı** belirler; dayanıklılık (yeniden deneme, geri çekilme, başarısız iş kutusu) sağlamaz. Cron'un içinde patlayan iş kaybolur.

Doğru kalıp: zamanlanmış görev kuyruğa bir iş bırakır, işi worker çalıştırır. Kuyruk örneği için [İş Kuyruğu ve Worker Sistemi](https://cilginyazilim.com/kutuphane/php-queue-worker)'ne bakın.
</details>

<details>
<summary><b>Çalışma geçmişi çok büyüdü</b></summary>

Beklenen bir durumdur: 15 dakikada bir çalışan bir görev yılda ~35.000 satır üretir.

Projedeki `PruneLogsTask` tam da bunun içindir; `task_runs` için de benzer bir temizlik görevi ekleyin ve gece çalıştırın. `ran_at` sütunu indekslidir, silme sorgusu hızlı çalışır.
</details>

<details>
<summary><b>Saniyede bir çalışan görev tanımlayabilir miyim?</b></summary>

Hayır — cron'un en küçük birimi dakikadır ve bu zamanlayıcı da dakika çözünürlüğünde çalışır.

Daha sık çalışması gereken bir iş varsa o bir "zamanlanmış görev" değil, sürekli çalışan bir süreçtir. Kuyruk worker'ı bu iş için doğru araçtır.
</details>

---

## Canlı Ortama Alırken

- [ ] `.env` içinde `APP_DEBUG=false` (veya satırı tümüyle silin)
- [ ] Tek cron satırını kurun ve `--quiet` ekleyin
- [ ] PHP ile MySQL'in **zaman dilimi aynı** mı, doğrulayın
- [ ] Her görevin `lock_timeout` değerini en uzun çalışmasının üstünde seçin
- [ ] `bin/` ve `storage/` klasörlerinin web'den erişilemediğini doğrulayın (403)
- [ ] `task_runs` için bir temizlik görevi ekleyin
- [ ] Veritabanı için **root olmayan** bir kullanıcı açın
- [ ] HTTPS zorunlu olsun
- [ ] Demo hesaplarının parolalarını değiştirin veya hesapları silin

---

## Sorun Giderme

| Belirti | Sebep | Çözüm |
|---|---|---|
| Hiçbir görev çalışmıyor | Cron kurulu değil | `crontab -l` ile kontrol edin |
| Görev yanlış saatte çalışıyor | PHP ile MySQL zaman dilimi farklı | İkisini eşitleyin |
| Görev hiç çalışmıyor, "çalışıyor" görünüyor | Süreç çökmüş, kilit üzerinde kalmış | `lock_timeout` süresi dolunca kendiliğinden çözülür; acilse `locked_at = NULL` yapın |
| Görev iki kez çalışıyor | `lock_timeout` çalışma süresinden kısa | Değeri yükseltin |
| "Sıradaki" boş görünüyor | Görev pasif | Başlat düğmesine basın |
| İfade kaydedilmiyor | Geçersiz cron sözdizimi | Beş alan var mı, operatörler doğru mu bakın |
| `bin/scheduler.php` tarayıcıda 403 | Bilerek | CLI betiği web'e kapalıdır |
| Tüm adresler 404 | `mod_rewrite` kapalı | Açın veya `APP_PRETTY_URLS=false` yapın |

---

## Yol Haritası

- [ ] Görev başarısız olunca e-posta / webhook bildirimi
- [ ] Panelden yeni görev ekleme formu (şu an SQL ile ekleniyor)
- [ ] Çalışma süresi grafiği (son 24 saat)
- [ ] `@daily`, `@hourly` gibi kısayol ifadeleri
- [ ] Görev bağımlılığı (A bitmeden B çalışmasın)

---

## Katkı

Hata bildirimi ve öneriler için [issue açabilirsiniz](https://github.com/CilginYazilim/scheduler-system/issues).

## Lisans

[MIT](LICENSE) — ticari projelerinizde de özgürce kullanabilirsiniz.

---

**[Çılgın Yazılım](https://cilginyazilim.com)** · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane) · [Tüm Örnekler](https://github.com/CilginYazilim)

## Sık Sorulan Sorular

### Sunucumda cron yok, ne yapabilirim?

Üç seçenek var. cPanel veya Plesk kullanıyorsanız panelde "Cron Jobs" bölümü vardır. Windows'ta Görev Zamanlayıcı'dan her dakika tekrarlanan bir görev tanımlayın. Üçüncüsü harici bir tetikleyicidir: korumalı bir web adresi açıp cron-job.org gibi bir servisle çağırın, ama adrese mutlaka bir gizli anahtar koyun.

### Görev yanlış saatte çalışıyor

Neredeyse her zaman zaman dilimi farkıdır. PHP Europe/Istanbul, MySQL UTC ise NOW() üç saat geride olur ve hesaplar tutmaz. SELECT NOW() çıktısı ile PHP'nin date() çıktısını karşılaştırın; farklıysa MySQL'in time_zone ayarını düzeltin veya config içindeki app.timezone değerini sunucuya uydurun.

### Görev iki kez çalıştı, kilit çalışmıyor mu?

Kilit çalışıyordur; muhtemelen lock_timeout görevin süresinden kısadır. Görev 400 saniye sürüyor ama zaman aşımı 300 ise, 300. saniyede kilit eski sayılır ve ikinci çalışma başlar. Süreyi çalışma geçmişindeki duration_ms değerine bakarak ayarlayın; en uzun çalışmanın en az iki katı iyi bir başlangıçtır.

### Uzun süren bir işi buradan çalıştırabilir miyim?

Çalıştırabilirsiniz ama önerilmez. Zamanlanmış görev zamanı belirler; dayanıklılık sağlamaz. Cron'un içinde patlayan iş kaybolur, tekrar denenmez. Doğru kalıp: zamanlanmış görev kuyruğa bir iş bırakır, işi worker çalıştırır. Cron zamanı belirler, kuyruk dayanıklılığı sağlar.

### Çalışma geçmişi çok büyüdü

Beklenen bir durumdur: 15 dakikada bir çalışan bir görev yılda yaklaşık 35 bin satır üretir. Projedeki PruneLogsTask tam da bunun içindir; task_runs için de benzer bir temizlik görevi ekleyip gece çalıştırın. ran_at sütunu indekslidir, silme sorgusu hızlı çalışır.

### Saniyede bir çalışan görev tanımlayabilir miyim?

Hayır. Cron'un en küçük birimi dakikadır ve bu zamanlayıcı da dakika çözünürlüğünde çalışır. Daha sık çalışması gereken bir iş varsa o bir zamanlanmış görev değil, sürekli çalışan bir süreçtir; kuyruk worker'ı bu iş için doğru araçtır.

---

Kaynak: [Zamanlanmış Görevler (Scheduler)](https://cilginyazilim.com/kutuphane/php-scheduler-cron)
