Blog'a Dön

Laravel'de döviz çevirici nasıl yapılır: kur API'si tam rehberi (2026)

V
Vlado Grigirov
May 05, 2026
Laravel Currency API Exchange Rates PHP Tutorial Finexly

Para işleyen Laravel uygulamalarının çoğu eninde sonunda döviz kurlarına ihtiyaç duyar — üç para biriminde fatura kesmek, Stripe ödemelerini ana paranıza çevirmek, ya da ödeme sayfasında lokalize fiyat göstermek olsun. En basit yol bir Laravel kur API'si takıp FX'i çözülmüş bir mesele olarak görmek; o kadar basit olmayan kısım ise işi doğru yapmak: kotanızı yakmamak için agresif cache'lemek, upstream'den gelen 5xx checkout'u yere sermesin diye kurları kalıcı olarak saklamak, yenilemeleri Avrupa Merkez Bankası'nın 16:00 CET fixing'ine göre zamanlamak ve ağa dokunmayan testler yazmak.

Bu rehber bunların hepsini geçecek. Küçük bir döviz çeviriciyi Laravel 11, Finexly API, kalıcılık için Eloquent, sıcak okumalar için Cache facade'ı, yenileme için scheduler, ISO 4217 için özel bir doğrulama kuralı ve Http::fake() ile Pest testleri kullanarak kuracağız. Bittiğinde herhangi bir Laravel uygulamasına — SaaS faturalandırma, e-ticaret, muhasebe, paranın sınırı geçtiği her yere — taşıyabileceğiniz bir service class mimariniz olacak.

Özel kur API'si neden hardcode değerleri yener

Kurları config dosyasına gömmek ilk yanlış cevaptır. Her istekte upstream'i çağırmak ikinci yanlış cevap. Doğru kullanılan özel bir döviz API'si, sabit değerlerin veremediği dört şey verir:

  • Talep üzerine tazelik. Kurlar pazar saatlerinde sürekli hareket eder. Müşterilerden tahsilat yapıyorsanız, USD/JPY ya da EUR/TRY gibi volatil çiftlerde 24 saat eski bir kur bile %1-2 hareket edebilir — bir SaaS planının marjını silip atar.
  • Geniş kapsam. Finexly gelişen piyasalar ve CBDC referanslarıyla birlikte 170+ para birimi kapsar. Laravel Swap ile gelen ECB feed'i yaklaşık 32 majör kapsar. Tek bir müşteri Arjantin pesosu veya Türk lirasıyla ödüyorsa fark hissedilir.
  • Tek sözleşme. latest, historical ve convert arasında aynı JSON şekli; üç farklı upstream'i bantla yapıştırmak yerine.
  • Tahmin edilebilir limitler. Üzerine akıl yürütebileceğiniz dokümante kotalar, "ECB IP'mizi çok polling yaptığımız için bloke etti"nin yerine.

Seçenekleri karşılaştırmak için 2026 ücretsiz vs ücretli döviz API'leri karşılaştırması ve ExchangeRate-API vs CurrencyLayer vs Finexly yazılarımıza bakın.

Proje kurulumu: Laravel 11 + Composer

Bu rehber temiz bir Laravel 11 kurulumunu varsayar (Laravel 10 da aynı çalışır; tek fark 5. adımda dokunduğumuz bootstrap/app.php):

laravel new fx-converter
cd fx-converter
php artisan migrate

Finexly API anahtarını .env dosyasına ekleyin:

FINEXLY_API_KEY=anahtariniz_buraya
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

15 dakika (900 saniye) B2B faturalandırma için makul bir cache TTL varsayılanıdır. Tüketici ödeme akışı genelde 60-300 saniye ister; bir hazine dashboard'u 3.600'e uzayabilir. Aleyhine kur hareketinde göze alabileceğiniz riske göre seçin.

Eşleşen bloğu config/services.php içine ekleyin:

'finexly' => [
    'key' => env('FINEXLY_API_KEY'),
    'base' => env('FINEXLY_BASE_URL', 'https://finexly.com/api/v1'),
    'cache_ttl' => (int) env('FX_CACHE_TTL', 900),
    'default_base' => env('FX_DEFAULT_BASE', 'USD'),
],

Henüz anahtarınız yok mu? Finexly'a kaydolun — ücretsiz plan ayda 1.000 istek verir, side proje veya küçük SaaS için fazlasıyla yeter.

ExchangeRateService sınıfı

Baştan sona uyacağımız kural: hiçbir controller, job ya da Blade view Finexly ile doğrudan konuşmaz. Her şey tek bir service class'ından geçer; bu testlerde mock'ları önemsiz kılar ve sağlayıcıyı değiştirmek ya da fallback eklemek için tek bir yer bırakır.

app/Services/ExchangeRateService.php dosyasını oluşturun:

<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class ExchangeRateService
{
    public function __construct(
        protected string $baseUrl,
        protected string $apiKey,
        protected int $cacheTtl,
        protected string $defaultBase,
    ) {}

    public function rate(string $from, string $to): float
    {
        $from = strtoupper($from);
        $to   = strtoupper($to);

        if ($from === $to) {
            return 1.0;
        }

        $rates = $this->latest($from);
        if (!isset($rates[$to])) {
            throw new RuntimeException("Unsupported currency pair: {$from}->{$to}");
        }

        return (float) $rates[$to];
    }

    public function convert(float $amount, string $from, string $to): float
    {
        return round($amount * $this->rate($from, $to), 4);
    }

    public function latest(string $base): array
    {
        $base = strtoupper($base ?: $this->defaultBase);
        $key  = "fx:latest:{$base}";

        return Cache::remember($key, $this->cacheTtl, function () use ($base) {
            $response = Http::withToken($this->apiKey)
                ->acceptJson()
                ->retry(2, 250, throw: false)
                ->timeout(8)
                ->get("{$this->baseUrl}/latest", ['base' => $base]);

            return $this->extractRates($response, $base);
        });
    }

    protected function extractRates(Response $response, string $base): array
    {
        if ($response->failed()) {
            Log::warning('Finexly fetch failed', [
                'base'   => $base,
                'status' => $response->status(),
                'body'   => $response->body(),
            ]);
            throw new RuntimeException("Exchange rate fetch failed: HTTP {$response->status()}");
        }

        $payload = $response->json();
        if (!is_array($payload['rates'] ?? null)) {
            throw new RuntimeException('Malformed exchange rate response');
        }

        return $payload['rates'];
    }
}

Birkaç noktayı belirtelim. retry(2, 250) satırı 250 ms backoff ile iki otomatik tekrar verir; bu, retry kodu yazmadan geçici 502/503'lerin büyük çoğunluğunu emer. timeout(8) muhafazakârdır — 5 saniyenin altı upstream başka kıtadaysa darken, 10 saniyenin üstü dengesiz bir upstream'in latency bütçenizi parçalamasına neden olur. withToken() Finexly'ın beklediği Authorization: Bearer ... başlığını ekler.

Service provider kaydı

Servisi app/Providers/AppServiceProvider.php içine bağlayın ki DI çalışsın:

use App\Services\ExchangeRateService;

public function register(): void
{
    $this->app->singleton(ExchangeRateService::class, function ($app) {
        $cfg = $app['config']['services.finexly'];
        return new ExchangeRateService(
            baseUrl: $cfg['base'],
            apiKey: $cfg['key'],
            cacheTtl: $cfg['cache_ttl'],
            defaultBase: $cfg['default_base'],
        );
    });
}

Singleton kapsamı burada doğru — sınıfta istek başına state yok, ve singleton aynı istek içinde alttaki HTTP istemcisinin bağlantıları yeniden kullanmasına izin verir.

Laravel Cache ile cache (Redis önerilir)

latest() içindeki Cache::remember çağrısı göründüğünden fazlasını yapar. File cache driver ile davranış doğru ama TTL dolduğunda cache stampede Finexly'a salvo şeklinde vurur. Redis ile iki bayrak işe yarar:

// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis

Hiç stampede tolere etmeyen birkaç endpoint için — örneğin checkout — çağrıyı Cache::lock ile sarın:

public function rateWithLock(string $from, string $to): float
{
    $lock = Cache::lock("fx:lock:{$from}:{$to}", 5);
    return $lock->block(3, fn () => $this->rate($from, $to));
}

Aynı anda yalnızca bir süreç upstream'i yeniden çeker; diğerleri cache dolana kadar 3 saniyeye kadar bekler. Stampede + retry desenini daha derin döviz API caching ve hata yönetimi rehberi içinde anlattık.

Eloquent ile kalıcılık

Cache sıcak okumalar için. Veritabanı dayanıklılık için — cache soğuduğunda (Redis yeniden başlaması, store'u temizleyen deploy) en güncel kurları upstream'e gitmeden istersiniz. Migration üretin:

php artisan make:model ExchangeRate -m

İçeriği:

Schema::create('exchange_rates', function (Blueprint $table) {
    $table->id();
    $table->string('base', 3)->index();
    $table->string('quote', 3)->index();
    $table->decimal('rate', 18, 8);
    $table->date('quoted_on')->index();
    $table->timestamps();

    $table->unique(['base', 'quote', 'quoted_on']);
});

decimal(18,8) hassasiyeti önemlidir. Kurları float olarak saklamak JPY çiftlerinde kesirli pip'leri keser ve ölçek büyüdükçe görünür mutabakat hatalarına yol açar. Sekiz ondalık basamak yayımlanan her cross kurunu kapsar.

Model:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ExchangeRate extends Model
{
    protected $fillable = ['base', 'quote', 'rate', 'quoted_on'];
    protected $casts = [
        'rate' => 'decimal:8',
        'quoted_on' => 'date',
    ];

    public function scopeLatestFor($query, string $base, string $quote)
    {
        return $query->where('base', $base)
                     ->where('quote', $quote)
                     ->orderByDesc('quoted_on')
                     ->limit(1);
    }
}

Servise yanıtı tabloya snapshot eden bir metod ekleyin — denetim ve geçmiş analizi için yararlıdır:

public function snapshot(string $base): int
{
    $rates = $this->latest($base);
    $today = now()->toDateString();
    $rows = collect($rates)->map(fn ($v, $k) => [
        'base'       => $base,
        'quote'      => $k,
        'rate'       => $v,
        'quoted_on'  => $today,
        'created_at' => now(),
        'updated_at' => now(),
    ])->values()->all();

    \App\Models\ExchangeRate::upsert(
        $rows,
        ['base', 'quote', 'quoted_on'],
        ['rate', 'updated_at']
    );

    return count($rows);
}

upsert klasik "ekle ya da güncelle" çift gidiş-gelişini ortadan kaldırır — tek statement, benzersiz anahtar üzerinde idempotent, aşağıdaki günlük yenileme için ideal.

Günlük yenileme zamanlama

Laravel scheduler snapshot için doğru yer. routes/console.php (Laravel 11) ya da app/Console/Kernel.php (Laravel 10) açın:

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    app(ExchangeRateService::class)->snapshot('USD');
    app(ExchangeRateService::class)->snapshot('EUR');
})
->dailyAt('16:30')
->timezone('Europe/Berlin')
->name('fx:snapshot')
->withoutOverlapping()
->onOneServer();

16:30 CET aralığı 16:00 CET ECB referans fixing'ini yakalar — 15 dakikalık pay sağlayıcının her gecikmesini kapsar. Birden fazla worker çalıştırıyorsanız onOneServer() kritiktir; bu olmadan yatay deploy snapshot'ı N kez çalıştırır ve API kotanızı iki katına çıkarır. Sunucudaki cron'u unutmayın:

* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1

Çevirici endpoint'i

Route ve controller'ı bağlayın. routes/web.php:

use App\Http\Controllers\ConverterController;

Route::get('/convert', [ConverterController::class, 'show'])->name('convert.show');
Route::post('/convert', [ConverterController::class, 'convert'])->name('convert.do');

Controller:

namespace App\Http\Controllers;

use App\Rules\IsoCurrency;
use App\Services\ExchangeRateService;
use Illuminate\Http\Request;

class ConverterController extends Controller
{
    public function show()
    {
        return view('convert');
    }

    public function convert(Request $request, ExchangeRateService $fx)
    {
        $data = $request->validate([
            'amount' => ['required', 'numeric', 'min:0', 'max:1000000000'],
            'from'   => ['required', new IsoCurrency()],
            'to'     => ['required', new IsoCurrency()],
        ]);

        $converted = $fx->convert(
            (float) $data['amount'],
            $data['from'],
            $data['to'],
        );

        return view('convert', [
            'result' => [
                'from'      => strtoupper($data['from']),
                'to'        => strtoupper($data['to']),
                'amount'    => $data['amount'],
                'converted' => $converted,
                'rate'      => $fx->rate($data['from'], $data['to']),
                'as_of'     => now()->toIso8601String(),
            ],
        ]);
    }
}

Minimal Blade view resources/views/convert.blade.php:

<form method="post" action="{{ route('convert.do') }}">
    @csrf
    <input name="amount" type="number" step="0.01" required>
    <input name="from" maxlength="3" placeholder="USD" required>
    <input name="to" maxlength="3" placeholder="EUR" required>
    <button type="submit">Çevir</button>
</form>

@isset($result)
    <p>{{ $result['amount'] }} {{ $result['from'] }} =
       {{ $result['converted'] }} {{ $result['to'] }}
       (kur {{ $result['rate'] }}, tarih {{ $result['as_of'] }})</p>
@endisset

Kırk satır Blade ve bir controller — kullanıcı tarafındaki tüm çevirici. Cilalı, hosted bir sürüm isterseniz finexly.com'daki döviz çeviricimiz aynı endpoint'lerin üzerinde çalışır.

ISO 4217 için özel doğrulama kuralı

'currencies' => 'in:USD,EUR,GBP,...' ile elle doğrulama on koda kadar dayanır, 170'te kırılır. Düzgün bir kural üretin:

php artisan make:rule IsoCurrency
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class IsoCurrency implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || !preg_match('/^[A-Z]{3}$/', strtoupper($value))) {
            $fail("The :attribute must be a valid 3-letter ISO 4217 currency code.");
        }
    }
}

Daha sıkı mı istiyorsunuz? Canlı sembol listesini cache'leyin:

$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
    return array_keys($fx->latest('USD'));
});

Sonra kuralın içinde in_array(strtoupper($value), $symbols, true) kontrolünü yapın. Standart için tam referans ISO 4217 para kodları rehberi içinde.

API hataları, retry ve rate limit

Servisteki retry/timeout geçici sıkışmaları kapar. Sürekli kesintiler için fallback zinciri istersiniz: cache → DB → upstream. latest() fonksiyonunu yeniden düzenleyin:

public function latest(string $base): array
{
    $key = "fx:latest:{$base}";

    if ($cached = Cache::get($key)) {
        return $cached;
    }

    try {
        $rates = $this->fetchUpstream($base);
        Cache::put($key, $rates, $this->cacheTtl);
        return $rates;
    } catch (\Throwable $e) {
        Log::warning('Upstream FX failed, falling back to DB', ['error' => $e->getMessage()]);
        $rates = \App\Models\ExchangeRate::query()
            ->where('base', $base)
            ->where('quoted_on', '>=', now()->subDays(7))
            ->orderByDesc('quoted_on')
            ->get()
            ->groupBy('quote')
            ->map->first()
            ->mapWithKeys(fn ($r, $k) => [$k => (float) $r->rate])
            ->all();

        if (empty($rates)) {
            throw $e;
        }
        return $rates;
    }
}

Finexly bir saat boyunca 503 dönse bile checkout'unuz veritabanından gelen en son snapshot ile çalışmaya devam eder. Bunu rate limit'te marj bırakan bir fiyat planıyla eşleştirin — kotanın %95'inde gezmek, ilk trafik dalgasında throttle'ın 429'a dönmesini davet etmektir.

Milisaniyelerin önemli olduğu uygulamalarda — örneğin trading dashboard'ları — snapshot deseni yavaştır. Streaming'e geçin; trade-off'ları REST vs WebSocket döviz çevirme karşılaştırması içinde inceledik.

Pest ve Http::fake() ile testler

Testler canlı API'ya hiçbir zaman gitmemeli. Http::fake() bunu önemsiz kılar. tests/Feature/ExchangeRateServiceTest.php:

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Http;

it('converts USD to EUR using cached rates', function () {
    Http::fake([
        '*finexly.com/api/v1/latest*' => Http::response([
            'base'  => 'USD',
            'rates' => ['EUR' => 0.92, 'GBP' => 0.79, 'JPY' => 154.30],
        ], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    expect($fx->convert(100, 'USD', 'EUR'))->toBe(92.0);
    expect($fx->rate('USD', 'JPY'))->toBe(154.30);
});

it('throws on unsupported pair', function () {
    Http::fake([
        '*' => Http::response(['base' => 'USD', 'rates' => ['EUR' => 0.92]], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    $fx->convert(100, 'USD', 'XXX');
})->throws(RuntimeException::class, 'Unsupported currency pair');

it('falls back to database when upstream fails', function () {
    \App\Models\ExchangeRate::factory()->create([
        'base' => 'USD', 'quote' => 'EUR', 'rate' => 0.91, 'quoted_on' => now(),
    ]);
    Http::fake(['*' => Http::response('boom', 503)]);

    $fx = app(ExchangeRateService::class);
    expect($fx->rate('USD', 'EUR'))->toBe(0.91);
});

php artisan test ile çalıştırın. Üç test mutlu yolu, doğrulama yolunu ve fallback'i tamamen kapsar — sıfır ağ çağrısı.

Yayına almadan önce prodüksiyon kontrol listesi

İlk deploy'da unutulan birkaç şeyin kısa listesi:

  • Snapshot'ı kuyruğa alın. Scheduler çağrısını dispatch(new RefreshRatesJob('USD'))->onQueue('fx') ile sarın ki yavaş upstream scheduler'ı bloklamasın.
  • 4xx ve 5xx'i ayrı izleyin. 401 anahtar dönüşü, 429 daha büyük plan, 503 upstream çöktü demek. Üç ayrı uyarı.
  • User-Agent sabitleyin. Yük altında sağlayıcılar önce anonim trafiği geri plana atar.
  • API anahtarını loglamayın. config/logging.php redaction listesine FINEXLY_API_KEY ekleyin.
  • Bir circuit breaker koyun. 60 saniyede arka arkaya beş hata → upstream çağırmayı bırak, sadece cache'ten servis et, nöbetçiyi uyar.
  • Hafta sonları da snapshot alın. Sağlayıcıların çoğu hafta sonu kurları dondurur, ama veritabanında cumartesi/pazar satırları olması ay sonu raporlamayı kolaylaştırır.

Sıkça sorulan sorular

Bu rehber hangi Laravel sürümünü hedefliyor? Laravel 11. Kod Laravel 10'da değişmeden çalışır; tek fark zamanlanmış görevlerin nereye kayıtlı olduğu (10'da app/Console/Kernel.php, 11'de routes/console.php). Mart 2025'te yayımlanan Laravel 12 de tamamen uyumludur.

Lumen ile kullanılır mı? Service class Laravel'e özel bağımlılık içermez ve doğrudan taşınır. Lumen provider'ları otomatik bulmadığı için binding'i elle kaydetmeniz gerekir; scheduler API'si biraz farklıdır, Lumen\Framework\Console\Scheduling\Schedule kullanın.

Kurları ne sıklıkla yenilemeliyim? B2B faturalandırma için günde bir kez 16:00 CET ECB fixing'inden sonra endüstri standardıdır. B2C checkout için pazar saatlerinde her 5-15 dakikada bir. Trading veya hazine dashboard'ları için gerçek zamanlı WebSocket — REST polling her zaman geç kalır. Trade-off'lar ücretsiz döviz API rehberi içinde.

Upstream API çökerse ne olur? Kurduğumuz fallback zinciriyle cache TTL süresince son kurları sunar, ardından DB en güncel snapshot'ı 7 güne kadar sunar, sonra istek başarısız olur. Pratikte aynı gün içindeki herhangi bir kesintide checkout çalışmaya devam eder.

Rehberi takip etmek için ücretli plan gerekli mi? Hayır. Finexly ücretsiz katmanı — ayda 1.000 istek, kart yok — her adım için yeter. 15 dakikalık TTL ile bu, kotaya çarpmadan günde yaklaşık 30.000 sayfa görüntülemeyi kaldırır.


Laravel uygulamanıza canlı kurları takmaya hazır mısınız? Ücretsiz Finexly API anahtarınızı alın — ayda 1.000 istek, kart yok, 30 saniye. Ücretsiz katmanı aşarsanız fiyat planlarımız 9 $/ay'dan başlar ve kurumsal seviyeye ölçeklenir. Veya karar vermeden önce döviz API'lerini karşılaştırın.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →