Laravel’i geliştirmek bir saat, production’a ciddi şekilde koymak bir hafta sürer. Bu yazıda kullandığım stack’i ve her bileşenin niye orada olduğunu anlatıyorum. “Her şey gerekli” demiyorum — gerekli olduğunu anlayana kadar minimum tutmayı savunan biriyim — ama her bileşenin kazandırdığı somut.
Bileşenler
HTTP → Nginx → PHP-FPM ┬─→ PostgreSQL (pgBouncer)
├─→ Redis (cache, session, queue, lock)
└─→ Supervisor → Queue Workers + Horizon
Nginx — neden Apache değil?
İkisi de çalışıyor. Nginx tercih ediyorum çünkü:
- Asenkron event loop modeli düşük gecikmede daha tutarlı.
- TLS termination’ı, static asset serving’i, fastcgi’yi tek configde tertemiz.
try_files $uri $uri/ /index.php?$query_stringdeyimi Laravel için kanonik.
Tipik server block:
server {
listen 443 ssl http2;
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 25M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/app.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffering on;
fastcgi_read_timeout 60s;
}
location ~ /\.(?!well-known).* {
deny all;
}
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
}
X-Forwarded-For doğru çalışsın diye set_real_ip_from ve real_ip_header CF-Connecting-IP (Cloudflare arkasında) ekleyin.
PHP-FPM — pool yapılandırması
Detayını ayrı yazıda ele aldım. Production’a özel iki opcache ayarı:
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; deploy'da reset gerek
opcache.revalidate_freq=0
opcache.fast_shutdown=1
opcache.preload=/var/www/app/current/preload.php
validate_timestamps=0 ile dosya değişikliklerini PHP otomatik algılamıyor —
deploy sonrası kill -USR2 ile FPM reload zorunlu. Karşılığında saniyede
yüzlerce request farkı.
PostgreSQL — neden MySQL değil?
İkisi de çalışıyor. PostgreSQL’i tercih sebebim:
- JSON/JSONB desteği MySQL’inkinden olgun (GIN index, query operators).
- CTE’ler, window function’lar, materialized view’lar native.
- Logical replication ve PITR tooling’i daha temiz.
- Transactional DDL — migration ortasında crash olsa veritabanı temiz kalır.
Karşılığında — kurulum biraz daha disiplin ister. Tipik postgresql.conf
hassasiyetleri:
shared_buffers = 2GB # toplam RAM'in ~%25'i
effective_cache_size = 6GB # RAM'in ~%75'i
work_mem = 16MB
maintenance_work_mem = 256MB
wal_buffers = 16MB
max_connections = 200
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1 # SSD için
effective_io_concurrency = 200
pgBouncer — bağlantı havuzu
PHP per-request bağlantı açar, kapatır. PostgreSQL bağlantı başlatma maliyetli
(fork edilen process). pgBouncer önünde transaction mode ile bunu absorb
ediyoruz. auth_query notu.
Redis — dört şapka
Aynı Redis instance dört rol oynuyor:
- Cache —
Cache::remember(...). - Session —
SESSION_DRIVER=redis. - Queue —
QUEUE_CONNECTION=redis. - Lock —
Cache::lock(...)ile distributed mutex.
Hepsi tek Redis’te güvenle çalışır — yeterli ki:
- Maxmemory policy:
volatile-lru(TTL’i olanları LRU ile sil; persistent queue verisi silinmesin). appendonly no(cache + queue için RDB yeterli).save 900 1 300 10 60 10000(RDB snapshot kuralları).
Supervisor + Horizon
Queue worker’ları Supervisor altında çalışıyor. Horizon Laravel’in queue
dashboard’u — ona göre orchestrate edebilmek için Supervisor horizon
process’ini çalıştırıyor:
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/current/artisan horizon
user=app
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600
Horizon kendi içinde worker process’lerini fork ediyor — siz config/horizon.php
ile her queue’nun worker sayısını ve memory limit’ini ayarlıyorsunuz.
Scheduler
Tek bir cron yetiyor:
* * * * * app cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
Laravel kendi scheduler’ını içeride yönetiyor. --withoutOverlapping() ve
onOneServer() modifier’larına dikkat — çoklu sunucuda race condition önler.
Logging
JSON formatında structured log:
// config/logging.php
'channels' => [
'production' => [
'driver' => 'stack',
'channels' => ['daily', 'stderr'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => 14,
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
],
/etc/logrotate.d/laravel-app:
/var/www/app/shared/storage/logs/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 app app
sharedscripts
}
.env management
.env her zaman shared/ dizininde, deploy’lara symlink. Hiçbir zaman
repo’da değil. Secrets için bunu basitliğin gücüyle yapıyorum — vault gerekirse
ekleriz, başta gerek yok.
Deploy
Symlink swap modeli. Detaylar çoklu proje mimarisi yazısının “Deploy” bölümünde.
Hangi parçası kaldırılırsa ne kaybedersin?
| Bileşen | Çıkarınca olan |
|---|---|
| pgBouncer | Connection storm’larında PostgreSQL bağlantısı tükenir, requestler 500 atar |
| Supervisor | Worker’lar crash sonrası geri gelmez, alarm kurmak zorundasın |
| Horizon | Görünürlük çöker, “queue neden yavaş” sorusunu kör cevaplayrısın |
| opcache | Her request PHP dosyasını disk’ten okur ve parse eder, ~5x yavaşlama |
| pgBackRest | pg_dump kalır — PITR kaybedersin, gerçek bir incident’ta hata payın tek bir günlük yedek |
| Redis lock | Race condition’lara açıksın, distributed-safe mutex’in kalmaz |
Boring stack’in gücü tam burada: her parça nicel olarak değerli ve değişimi kolay. “Bütün cluster çöktü” senaryosu, yerine “Redis maxmemory ayarını kaçırmışım” senaryosu yaşarsınız — ki bunu da düzeltmek beş dakika.