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_string deyimi 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:

  1. CacheCache::remember(...).
  2. SessionSESSION_DRIVER=redis.
  3. QueueQUEUE_CONNECTION=redis.
  4. LockCache::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
pgBouncerConnection storm’larında PostgreSQL bağlantısı tükenir, requestler 500 atar
SupervisorWorker’lar crash sonrası geri gelmez, alarm kurmak zorundasın
HorizonGörünürlük çöker, “queue neden yavaş” sorusunu kör cevaplayrısın
opcacheHer request PHP dosyasını disk’ten okur ve parse eder, ~5x yavaşlama
pgBackRestpg_dump kalır — PITR kaybedersin, gerçek bir incident’ta hata payın tek bir günlük yedek
Redis lockRace 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.