İleri
uygulama-iskeleti/php-baslangic-sablonu
PHP Başlangıç Şablonu (CY PHP Starter)
- PHP 8.1
- PDO
- MySQL
- MariaDB
- +4
309 inceleme 150 canlı açılış 6 beğeni 2 yorum
Uygulama İskeleti İleri
Kullanıcıyı bekletmeyin: işi kuyruğa bırakın, arka planda bir worker çalıştırsın. Yarışsız iş kapma, üstel geri çekilmeli yeniden deneme, ölü mektup kutusu ve takılmış işlerin kurtarılması — Redis gerekmeden, elinizdeki MySQL ile.
Veritabanı şeması projedeki database.sql dosyasında.
Kayıt formuna basan kullanıcı, hoş geldin e-postası gönderilirken bekler. SMTP sunucusu yavaşsa 4 saniye bekler. SMTP sunucusu düşmüşse 30 saniye bekler ve sonunda bir hata sayfası görür — üstelik kaydı başarıyla oluşturulmuş olduğu hâlde.
Sorun e-postada değil, işin yanlış yerde yapılmasındadır. Kullanıcının isteği ile e-postanın gönderilmesi aynı anda olmak zorunda değildir. İkisini ayırırsanız istek milisaniyelerde biter, e-posta arka planda gider ve gitmezse tekrar denenir.
Bu proje o ayrımı MySQL üzerinde kurulmuş bir iş kuyruğuyla gösteriyor: jobs tablosuna bir satır bırakırsınız, ayrı bir süreç (worker) o satırı kapar, çalıştırır ve siler. Hata olursa iş üstel geri çekilmeyle yeniden denenir; deneme hakkı dolarsa failed_jobs tablosuna — ölü mektup kutusuna — düşer ve orada sizi bekler.
Kuyruğun zor kısmı iş çalıştırmak değildir; aynı işin iki kez çalışmamasını sağlamaktır. İki worker aynı satırı aynı anda görürse e-posta iki kez gider. Bu proje o yarışı, koşullu bir UPDATE ile — kilitsiz ve MySQL 5.7 uyumlu — çözer.
Kimler için uygun?
Klonla, database.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, Redis yok.
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
push() reserve() handle()
│ │ │
▼ ▼ ▼
┌────────┐ zamanı geldi ┌──────────┐ başarılı ┌─────────┐
│BEKLİYOR│ ──────────────► │ ÇALIŞIYOR│ ───────────► │ SİLİN │
└────────┘ └──────────┘ └─────────┘
▲ │
│ │ hata
│ ▼
│ deneme < max ?
│ │ │
│ 10 sn → 20 sn → 40sn │ evet │ hayır
└───────────────────────┘ ▼
(üstel geri çekilme) ┌──────────────┐
│ BAŞARISIZ │ ölü mektup kutusu
│ failed_jobs │ → "Yeniden dene" ile geri döner
└──────────────┘
Worker çöktüyse: ÇALIŞIYOR durumunda 5 dakikadan uzun kalan iş
TAKILMIŞ sayılır → recoverStuck() onu BEKLİYOR'a döndürür| Durum | Nasıl anlaşılır |
|---|---|
| Bekliyor | reserved_at IS NULL ve available_at <= NOW() |
| Ertelenmiş | reserved_at IS NULL ve available_at > NOW() |
| Çalışıyor | reserved_at dolu ve 5 dakikadan yeni |
| Takılmış | reserved_at dolu ve 5 dakikadan eski |
En sık yapılan hata şudur:
// YANLIŞ: iki worker aynı satırı görebilir
$job = $db->query('SELECT * FROM jobs WHERE reserved_at IS NULL LIMIT 1')->fetch();
$db->exec("UPDATE jobs SET reserved_at = NOW() WHERE id = {$job['id']}");SELECT'i aynı anda çalıştırırsa ikisi de aynı satırı alır ve e-posta iki kez gider. "Önce bak, sonra yaz" her zaman bir yarış koşuludur.
Doğrusu, kontrolü ve yazmayı tek ifadeye koymaktır:
UPDATE jobs
SET reserved_at = NOW(), reserved_by = :worker, attempts = attempts + 1
WHERE id = :id
AND reserved_at IS NULL -- ◄── kritik satırreserved_at IS NULL koşulu UPDATE'in içindedir; veritabanı satırı yazarken zaten kilitler. İki worker aynı satırı denerse yalnızca birinin rowCount() değeri 1 olur:
if ($claim->rowCount() === 0) {
continue; // başka bir worker önce davrandı, sıradakine geç
}SELECT ... FOR UPDATE SKIP LOCKED neden kullanılmadı?Kullanılabilirdi ve MySQL 8.0'da daha zarif olurdu. Ama SKIP LOCKED, MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur — paylaşımlı hosting'lerin büyük kısmı hâlâ orada.
Koşullu UPDATE yöntemi her sürümde çalışır, ek bir işlem (transaction) yönetimi gerektirmez ve aynı güvenceyi verir. Taşınabilirlik, buradaki zarafetten daha değerli görüldü.
Hata alan işi hemen tekrar denemek, çoğu zaman zarar verir: düşmüş bir SMTP sunucusuna saniyede üç istek göndermek onu daha da boğar.
// app/Core/Queue.php — release()
$delay = min(3600, 10 * (2 ** ($attempts - 1)));Üç kez denenip başaramayan bir işi silmek, veriyi kaybetmektir. Bu iş failed_jobs tablosuna taşınır ve orada sizi bekler.
Neden ayrı tablo? Çünkü jobs tablosu worker'ın en sıcak sorgusudur; saniyede birkaç kez taranır. Başarısız işleri orada tutmak, o tabloyu asla temizlenmeyen ölü satırlarla şişirirdi.
Servisi düzelttiğinizde "Yeniden dene" düğmesi işi geri gönderir — kaybolmuş bir e-posta yerine, gönderilmeyi bekleyen bir e-postanız olur.
'error' VARCHAR(2000) NOT NULL DEFAULT ''Worker çökerse (sunucu yeniden başladı, bellek doldu, deploy sırasında kapatıldı) reserved_at dolu kalır ve iş sonsuza kadar "çalışıyor" görünür — oysa onu çalıştıran kimse yoktur.
Bu yüzden 5 dakikadan eski rezervasyonlar arayüzde ayrı bir Takılmış rozetiyle gösterilir. Eşik Queue::STUCK_AFTER sabitindedir ve hem rozet hem kurtarma sorgusu onu kullanır. İkisi ayrışsaydı, ekranda takılmış görünen bir işi kurtarma düğmesi bulamazdı.
job_class: App\Jobs\SendEmailJob
payload: {"to":"...","subject":"..."}Job sınıfından türediğini doğrular ve handle($payload, $db) çağırır:
if (!class_exists($class) || !is_subclass_of($class, Job::class)) {
// tanınmayan sınıf çalıştırılmaz
}job_class alanına elle bir değer yazılsa bile rastgele bir sınıf örneklenip çalıştırılamaz.
Redis kuyruk için mükemmeldir ama bir bağımlılıktır: kurulacak, izlenecek, yedeklenecek ve paylaşımlı hosting'de çoğu zaman bulunmayan bir servistir.
Günde on binlerce işe kadar MySQL fazlasıyla yeter — özellikle idx_jobs_reserve bileşik indeksi varken. Üstelik işleriniz veritabanı yedeğinizin içinde durur; ayrıca yedeklemeniz gereken ikinci bir depo olmaz.
Milyonlarca işe çıktığınızda Redis'e geçin. O gün gelene kadar kurmayın.
Kuyruk çekirdeği
push() — iş bırak, gecikme verilebilirreserve() — yarışsız kapma (koşullu UPDATE)release() — üstel geri çekilmeyle iadefail() — ölü mektup kutusuna taşırecoverStuck() — çökmüş worker'ın kilidini açdefault, reports, …)Worker (CLI)
--queue= belirli kuyruğu dinle--max=N N iş sonra dur--once tek iş çalıştır ve çık--sleep=N boşta bekleme süresiPHP_SAPI kontrolü — tarayıcıdan çalışmazPanel
Ortak altyapı
script-src 'self'), X-Frame-Options: DENYİşler ancak bir worker çalışıyorken işlenir. Proje klasöründe ayrı bir terminal açın:
php bin/worker.phpCtrl+C.
| Seçenek | Anlamı |
|---|---|
--queue=reports | Yalnızca bu kuyruğu dinle (varsayılan: default) |
--max=5 | 5 iş çalıştırdıktan sonra çık — denemek için ideal |
--once | Tek iş çalıştır ve çık (cron'dan tetiklemek için) |
--sleep=3 | Kuyruk boşken kaç saniye beklesin (varsayılan: 1) |
--help | Seçenekleri yazdır |
Worker bir gün mutlaka ölür: bellek dolar, deploy sırasında kapatılır, sunucu yeniden başlar. Bu yüzden onu kendiliğinden yeniden başlatan bir yönetici altında çalıştırın.
systemd (/etc/systemd/system/cy-worker.service):
[Unit]
Description=CY Queue Worker
After=network.target mysql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/uygulama
ExecStart=/usr/bin/php bin/worker.php
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now cy-worker--once verin.
* * * * * cd /var/www/uygulama && php bin/worker.php --once >> /dev/null 2>&1
Deploy sırasında: worker'ı önce durdurun, kodu güncelleyin, sonra başlatın. Aksi hâlde çalışan süreç eski kodu, yeni gelen işler yeni şemayı kullanır.
Bir iş, Job sınıfından türeyen ve iki metot uygulayan bir sınıftır.
<?php
namespace App\Jobs;
use PDO;
final class SendSmsJob extends Job
{
/** Arayüzde ve günlükte görünecek ad. */
public static function label(): string
{
return 'SMS gönderimi';
}
/** Kaç kez denensin? (varsayılan 3) */
public static function maxAttempts(): int
{
return 5;
}
/**
* İşin kendisi. İstisna fırlatırsa iş başarısız sayılır ve
* geri çekilmeyle yeniden denenir.
*/
public function handle(array $payload, PDO $db): void
{
$numara = (string) ($payload['numara'] ?? '');
if ($numara === '') {
// Kalıcı hata: tekrar denemenin anlamı yok.
throw new \InvalidArgumentException('Numara boş.');
}
// ... SMS sağlayıcısına istek ...
}
}(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…']);
// 10 dakika sonra çalışsın:
(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…'], Queue::DEFAULT, 600);
// Ayrı bir kuyruğa:
(new Queue($pdo))->push(SendSmsJob::class, [...], 'sms');handle() yeniden çalıştırılabilir olsun. İş, ağ koptuğu için tam bittiği anda da başarısız sayılabilir ve tekrar denenir. "Bu işi ikinci kez çalıştırırsam ne olur?" sorusunun cevabı zararsız olmalıdır — sipariş iki kez oluşturulmamalıdır.['user_id' => 42] yazın, kullanıcı nesnesinin tamamını değil. İş çalıştığında veri değişmiş olabilir; taze hâlini handle() içinde okuyun.| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| Rastgele sınıf çalıştırma | (new $payload['class'])->run() | Sınıf Job'dan türemiyorsa çalıştırılmaz (is_subclass_of) |
| Yarış koşulu (çift işleme) | "Önce SELECT, sonra UPDATE" | Kontrol ve yazma tek UPDATE ifadesinde; rowCount() ile doğrulanır |
| Yığın izi sızıntısı | Hata nesnesinin tamamını loglamak | Yalnızca mesaj saklanır; dosya yolu ve sınıf yapısı 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; karşılaştırma hash_equals() ile |
| XSS | echo $row['payload'] | Sunucuda e(); ayrıca CSP script-src 'self' |
| Parola sızıntısı | md5($parola) | password_hash() / password_verify(); otomatik yeniden özetleme |
| Kaba kuvvet | Sınırsız deneme | login_attempts tablosuyla 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; gerçek alan adında kendiliğinden kapanır |
| Sonsuz büyüyen kuyruk | Tavansız geri çekilme | Geri çekilme 1 saatte tavanlanır; deneme hakkı dolan iş ölü mektup kutusuna taşınır |
| Bozuk UTF-8'de sessiz JSON kaybı | json_encode($v) | JSON_INVALID_UTF8_SUBSTITUTE |
APP_DEBUG=true # silerseniz: yerelde açık, canlıda kapalı
APP_URL=
APP_PRETTY_URLS=true # false → /index.php?r=queue
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=cy_queue_worker
DB_USER=root
DB_PASS=
••••••••
Kuyruk davranışını belirleyen sabitler koddadır:
| Sabit | Yeri | Varsayılan | Ne yapar |
|---|---|---|---|
| `Queue::DEFAULT` | `app/Core/Queue.php` | `'default'` | Kuyruk adı verilmezse kullanılan |
| `Queue::STUCK_AFTER` | `app/Core/Queue.php` | `300` | Kaç saniye sonra iş "takılmış" sayılır |
| `Job::maxAttempts()` | `app/Jobs/Job.php` | `3` | Deneme hakkı (iş sınıfında değiştirilebilir) |
| geri çekilme | `Queue::release()` | `10 · 2ⁿ`, tavan `3600` | Yeniden deneme aralığı |
> **`STUCK_AFTER`'ı işlerinize göre ayarlayın.** İşleriniz dakikalarca sürüyorsa 300 saniye çok kısadır: hâlâ çalışan bir iş "takılmış" sayılıp ikinci kez kuyruğa girebilir.
---
## Dosya Yapısı
---
## Nasıl Çalışıyor?
---
## Veritabanı Şeması
### `jobs` — bekleyen ve çalışan işler
| Sütun | Tip | İşi |
|---|---|---|
| `id` | BIGINT UNSIGNED | Birincil anahtar |
| `queue` | VARCHAR(60) | Kuyruk adı; farklı önceliklere ayrı worker atanabilir |
| `job_class` | VARCHAR(190) | Çalıştırılacak sınıf (`App\Jobs\SendEmailJob`) |
| `payload` | TEXT | İşin verisi (JSON) |
| `attempts` / `max_attempts` | SMALLINT | Kaç kez denendi / en fazla kaç kez |
| `available_at` | DATETIME | "Bu zamandan önce çalıştırma" — gecikme **ve** geri çekilme |
| `reserved_at` / `reserved_by` | DATETIME / VARCHAR | Kapan worker ve kapma anı; `NULL` = boşta |
| `last_error` | VARCHAR(1000) | Son hata; iş hâlâ kuyrukta ama sorunlu |
| `created_at` | DATETIME | Bırakılma anı |
**Tek indeks, en sıcak sorgu için:**
```sql
KEY idx_jobs_reserve (queue, reserved_at, available_at)WHERE queue = ? AND reserved_at IS NULL AND available_at <= NOW() ORDER BY idqueue), sonra NULL kontrolü, en sonda aralık karşılaştırması.
failed_jobs — ölü mektup kutusu| Sütun | Tip | İşi |
|---|---|---|
id | BIGINT UNSIGNED | Birincil anahtar |
queue · job_class · payload | — | jobs'tan taşınan alanlar |
attempts | SMALLINT | Kaç kez denendi |
error | VARCHAR(2000) | Yalnızca mesaj — yığın izi saklanmaz |
failed_at | DATETIME | Pes edilen an |
| Karar | Neden |
|---|---|
| Ayrı tablo | jobs, worker'ın en sıcak sorgusudur; ölü satırlarla şişmemeli |
| Yığın izi yok | Dosya yolu ve sınıf yapısı veritabanına yazılmamalı |
payload korunur | "Yeniden dene" işi aynı veriyle geri gönderebilsin diye |
Worker'ı sürekli açık tutmak zorunda mıyım?
Hayır, iki seçeneğiniz var.
Sürekli süreç (önerilen): php bin/worker.php bir kez başlar ve dinler. İşler saniyeler içinde işlenir. systemd veya supervisor ile yeniden başlatılmasını garanti edin.
Cron ile: her dakika php bin/worker.php --once çalıştırın. Kurulumu daha kolaydır ama işler en fazla bir dakika bekler. Paylaşımlı hosting'de genellikle tek seçenek budur.
Aynı e-posta iki kez gitti, neden?
Neredeyse her zaman şu sebeptendir: iş, gönderimi bitirdikten sonra ama satır silinmeden önce başarısız oldu (ağ koptu, süreç öldürüldü). Kuyruk onu bitmemiş sayar ve tekrar dener.
Bu, kuyrukların yapısal bir gerçeğidir: "en az bir kez" teslim garantisi verilir, "tam olarak bir kez" değil. Çözüm işin kendisindedir — handle() yeniden çalıştırıldığında zararsız olmalıdır. Gönderdiğiniz her e-postaya bir kimlik verip "bu kimlik daha önce gönderildi mi?" diye bakmak en yaygın yöntemdir.
Birden fazla worker çalıştırabilir miyim?
Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu UPDATE bunu garanti eder.
Farklı kuyruklara farklı worker atamak da yaygındır:
php bin/worker.php --queue=default # hızlı işler
php bin/worker.php --queue=reports # uzun süren raporlarİşler birikiyor ama işlenmiyor
Sırayla kontrol edin:
ps aux | grep worker.php)reports kuyruğuna bırakıp default dinliyorsanız hiçbir şey olmaz.available_at gelecekteyse worker onları görmez.Neden SKIP LOCKED kullanmadınız?
MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada.
Koşullu UPDATE yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz reserve() metodunu SKIP LOCKED ile değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.
Ölü mektup kutusu ne zaman temizlenir?
Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır.
Panelden tek tek "Unut" diyebilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce neden başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.
.env içinde APP_DEBUG=false (veya satırı tümüyle silin)Restart=always olsunSTUCK_AFTER değerini en uzun işinizden büyük seçinbin/ klasörünün web'den erişilemediğini doğrulayın (403 dönmeli)storage/ klasörünün web'den erişilemediğini doğrulayınfailed_jobs tablosunu izleyin — büyüyorsa bir şey bozuktur| Belirti | Sebep | Çözüm |
|---|---|---|
| İşler hep "Bekliyor" | Worker çalışmıyor | php bin/worker.php başlatın |
| İşler hep "Ertelenmiş" | available_at gelecekte | Gecikme verdiniz ya da geri çekilme sürüyor; bekleyin |
| Hepsi "Takılmış" | Worker çökmüş, kilit üzerinde kalmış | "Takılanları kurtar" düğmesine basın |
| Uzun iş ikinci kez çalışıyor | STUCK_AFTER işten kısa | Sabiti yükseltin |
Class not found hatası | job_class yanlış veya sınıf silinmiş | Tam sınıf adını ve app/Jobs/ içeriğini kontrol edin |
bin/worker.php tarayıcıda 403 | Bilerek | CLI betiği web'e kapalıdır; terminalden çalıştırın |
| Tüm adresler 404 | mod_rewrite kapalı | Açın veya APP_PRETTY_URLS=false yapın |
| Sayfa boş / beyaz | PHP hatası, display_errors kapalı | .env içinde APP_DEBUG=true yapıp tekrar deneyin |
SKIP LOCKED sürücüsü (isteğe bağlı)Hata bildirimi ve öneriler için issue açabilirsiniz.
MIT — ticari projelerinizde de özgürce kullanabilirsiniz.
İncelemek istediğiniz dosyayı ağaçtan seçin.
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).
Hayır, iki seçeneğiniz var. Sürekli süreç önerilendir: php bin/worker.php bir kez başlar ve dinler, işler saniyeler içinde işlenir; systemd veya supervisor ile yeniden başlatılmasını garanti edin. Alternatif olarak her dakika php bin/worker.php --once çalıştıran bir cron kurabilirsiniz; kurulumu kolaydır ama işler en fazla bir dakika bekler.
Neredeyse her zaman şu sebeptendir: iş gönderimi bitirdikten sonra ama satır silinmeden önce başarısız oldu. Kuyruk onu bitmemiş sayar ve tekrar dener. Bu kuyrukların yapısal bir gerçeğidir; "en az bir kez" teslim garantisi verilir. Çözüm işin kendisindedir: handle() yeniden çalıştırıldığında zararsız olmalıdır.
Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu UPDATE bunu garanti eder. Farklı kuyruklara farklı worker atamak da yaygındır: böylece dört dakika süren bir rapor, hoş geldin e-postasını bekletmez.
MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada. Koşullu UPDATE yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz reserve() metodunu değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.
Demo sunucusunda sürekli çalışan bir worker yoktur ve bu bilinçlidir: kuyruğun dolu görünmesi, işleri bekleten şeyin ne olduğunu somutlaştırır. Panel de bunu söyler ve çalıştırılacak komutu ekrana yazar. Projeyi indirip php bin/worker.php çalıştırdığınızda akışın tamamını canlı izlersiniz.
Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır. Panelden tek tek unutabilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce neden başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.
Bu örneğin daha gelişmişi projenizde mi lazım?
İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.
Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.
İleri
uygulama-iskeleti/php-baslangic-sablonu
309 inceleme 150 canlı açılış 6 beğeni 2 yorum
Orta
uygulama-iskeleti/php-scheduler-cron
178 inceleme 50 canlı açılış
Orta
uygulama-iskeleti/php-multi-language-i18n
150 inceleme 48 canlı açılış