---
title: "Laravel Production Stack: Her Bileşen Neden Orada?"
description: "Nginx + PHP-FPM + Redis + PostgreSQL + Supervisor + Horizon ile production-grade bir Laravel kurulumu. Her bileşenin işi ve değiştirildiğinde ne kaybedileceği."
url: https://sade.dev/tr/systems/laravel-production-stack/
lang: tr
author: "Muhammet Şafak"
published: 2026-04-22
updated: 2026-09-06
section: System
tags: ["laravel","php","production","architecture"]
summary: "Üretimde gerçekten yıllarca dayanan bir Laravel stack: her bileşenin niye orada olduğu ve değiştirildiğinde ne kaybedildiği."
---

# Laravel Production Stack: Her Bileşen Neden Orada?

> Üretimde gerçekten yıllarca dayanan bir Laravel stack: her bileşenin niye orada olduğu ve değiştirildiğinde ne kaybedildiği.

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.

## Neden sunucuları Cloudflare arkasına koyuyorum?

- DDoS koruması, WAF, rate limiting gibi özellikler out-of-the-box.
- Gerçek client IP'sini `X-Forwarded-For` ve `CF-Connecting-IP` header'larıyla geçiriyor; aşağıdaki `real_ip` yönergeleriyle uygulamanın gördüğü IP bu oluyor.
- SSL termination'ı Cloudflare'da yapıp backend'e `http` ile konuşmak mümkün.
- CDN özellikleriyle static asset'leri Cloudflare cache'inden servis etmek kolay.
- Ücretsiz 15 yıllık SSL sertifikası sağlıyor, yönetimi basitleştiriyor.

## 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 hallediyor.
- `try_files $uri $uri/ /index.php?$query_string` deyimi Laravel için kanonik.

Tipik server block:

```nginx
# -----------------------------
# HTTP → HTTPS redirect
# -----------------------------
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

# -----------------------------
# HTTPS
# -----------------------------
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;

    access_log /var/log/nginx/app.example.com.access.log;
    error_log  /var/log/nginx/app.example.com.error.log warn;

    # Cloudflare arkasında gerçek client IP, her aralık için bir satır: https://www.cloudflare.com/ips/
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    # ... kalan Cloudflare aralıkları
    real_ip_header CF-Connecting-IP;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.4-fpm-app-example.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 ~* \.(css|js|png|jpg|jpeg|gif|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        # bu seviye kendi add_header'ını tanımlayınca üsttekiler miras alınmıyor
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        access_log off;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
```

`set_real_ip_from` listesi bütün Cloudflare aralıklarını kapsamalı; kapsamazsa Nginx client'ın değil Cloudflare'in IP'sini loglamayı sürdürür. Nokta dosyası bloğunun tek olması da bilinçli: Nginx eşleşen ilk regex location'da durur, dolayısıyla bunun ardına konan istisnasız bir `location ~ /\.` bloğu `/.well-known/` isteklerini de yutar ve ACME doğrulamasını kırar.

## PHP-FPM — pool yapılandırması

[Detayını ayrı yazıda](/tr/notes/nginx-php-fpm-pool-ayrimi) ele aldım. Production'a özel opcache ayarları:

```ini
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.preload=/var/www/app/current/preload.php
opcache.preload_user=app        ; php-fpm master root başlar, bu olmadan preload reddedilir
```

`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 etme 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 = 4GB                # toplam RAM'in ~%25'i
effective_cache_size = 12GB          # RAM'in ~%75'i
work_mem = 16MB
maintenance_work_mem = 256MB
wal_buffers = 16MB
max_connections = 500
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1              # SSD için
effective_io_concurrency = 200
```

Yedekler `pg_dump` ile değil pgBackRest ile alınıyor: full + incremental, üstüne WAL arşivinden PITR.

## pgBouncer — bağlantı havuzu

PHP per-request bağlantı açar, kapatır. PostgreSQL'de bağlantı başlatmak maliyetli (fork edilen process). Önüne pgBouncer koyup `transaction` mode ile bunu absorb ediyoruz. [auth_query notu](/tr/notes/pgbouncer-auth-query).

## Redis — üç şapka

Aynı Redis instance üç rol oynuyor:

1. **Cache** — `Cache::remember(...)`.
2. **Session** — `SESSION_DRIVER=redis`.
3. **Lock** — `Cache::lock(...)` ile distributed mutex.

Hepsi tek Redis'te güvenle çalışır — yeter ki:

- Maxmemory policy: `volatile-lru` (yalnızca TTL'i olan key'leri LRU ile sil).
- `appendonly no` (cache, session ve lock için RDB yeterli).
- `save 900 1 300 10 60 10000` (RDB snapshot kuralları).

## RabbitMQ (opsiyonel)

Laravel'in queue driver'ı olarak `rabbitmq` kullanıyorum — Database ya da Redis yerine. Neden?

**Queue** — `QUEUE_CONNECTION=rabbitmq`.

Redis queue driver'ı basit ama RabbitMQ'nun özellikleriyle karşılaştırıldığında eksik kalıyor:

- **Persistence** — RabbitMQ kalıcı mesajları diske yazar; Redis kuyruğu bellekten servis eder, diske yalnızca RDB snapshot'ları ya da AOF ile iner.
- **Acknowledgements** — RabbitMQ'da mesaj, consumer ack verdikten sonra siliniyor; Laravel'in Redis driver'ı da biten job'ı reserved kümesinden kendi siliyor. Fark temizlikte değil yeniden teslimde: Redis `retry_after` zaman aşımına, RabbitMQ consumer'ın bağlantısına bakıyor.
- **Routing** — RabbitMQ'da mesajın hangi queue'ya düşeceğine exchange'ler karar veriyor; Redis'te `onQueue()` ile istediğiniz kadar adlandırılmış queue açabilirsiniz ama yönlendirme kararı uygulamada kalıyor.
- **Monitoring** — RabbitMQ yönetim paneli queue'ları, mesajları, consumer'ları broker düzeyinde gösteriyor; Redis tarafında aynı görünürlüğü Horizon veriyor, o da uygulamanın dispatch ettiğiyle sınırlı.

## Supervisor + Horizon

Queue worker'ları Supervisor altında çalışıyor. Horizon Laravel'in queue dashboard'u — worker'ları ona orchestrate ettirebilmek için Supervisor `horizon` process'ini çalıştırıyor:

```ini
[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 kutudan çıktığı hâliyle yalnızca `redis` queue bağlantısını sürüyor; yukarıdaki `rabbitmq` bağlantısında `vladimir-yuldashev/laravel-queue-rabbitmq` paketinin Horizon modu gerekiyor — `RABBITMQ_WORKER=horizon`, bağlantı böylece Horizon'un okuduğu event'leri yayıyor. 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:

```php
// 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](/tr/systems/tek-vps-coklu-proje-mimarisi) yazısının "Deploy" bölümünde.

## Hangi parçası kaldırılırsa ne kaybedersiniz?

| Bileşen | Çıkarınca olan |
|---|---|
| pgBouncer | Connection storm'larında PostgreSQL bağlantısı tükenir, request'ler 500 atar |
| Supervisor | Worker'lar crash sonrası geri gelmez, alarm kurmak zorundasınız |
| Horizon | Görünürlük çöker, "queue neden yavaş" sorusunu kör cevaplarsınız |
| opcache | Her request PHP dosyasını disk'ten okur ve parse eder, ~5x yavaşlama |
| pgBackRest | `pg_dump` kalır — PITR kaybedersiniz, gerçek bir incident'ta hata payınız tek bir günlük yedek |
| Redis lock | Race condition'lara açıksınız, distributed-safe mutex'iniz 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.

## Sık sorulanlar

**opcache.validate_timestamps=0 neden her deploy sonrası reload gerektiriyor?**

Bu ayarla PHP dosya değişikliklerini artık kontrol etmiyor, dolayısıyla yeni release cache sıfırlanana kadar görünmez kalıyor. Symlink swap sonrası PHP-FPM'e kill -USR2 ile graceful reload göndermek zorunlu. Bedeli fazladan bir deploy adımı; karşılığı saniyede yüzlerce request.

**Tek bir Redis instance aynı anda cache, session ve lock taşıyabilir mi?**

Evet, üç koşulla: yalnızca TTL taşıyan key'lerin silinmesi için volatile-lru maxmemory policy, cache/session/lock verisine RDB yettiği için appendonly kapalı ve açıkça tanımlanmış RDB snapshot kuralları. Eviction policy kaçarsa lock key'leri de cache ile birlikte gider.

**Horizon RabbitMQ queue bağlantısıyla çalışır mı?**

Kutudan çıktığı hâliyle hayır. Horizon yalnızca redis queue bağlantısını sürüyor; rabbitmq bağlantısında vladimir-yuldashev/laravel-queue-rabbitmq paketinin Horizon modu gerekiyor. RABBITMQ_WORKER=horizon ile bağlantı, Horizon'un okuduğu event'leri yaymaya başlıyor.


## Kaynaklar

- [OPcache configuration: validate_timestamps, preload and preload_user](https://www.php.net/manual/en/opcache.configuration.php) — PHP
- [ngx_http_core_module: location — regular expressions are checked in order and the search terminates on the first match](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) — nginx
- [Laravel Horizon: Horizon requires Redis to power your queue](https://laravel.com/docs/12.x/horizon) — Laravel
- [Key eviction: volatile-lru evicts only keys that carry an expiration](https://redis.io/docs/latest/develop/reference/eviction/) — Redis
