أغلب تطبيقات Laravel التي تتعامل مع المال تحتاج إلى أسعار صرف عاجلًا أم آجلًا — سواء كنت تُصدر فواتير بثلاث عملات، أو تحوّل تحويلات Stripe إلى عملتك المحلية، أو تعرض أسعارًا مُعرَّبة في صفحة الدفع. أبسط طريق هو توصيل API أسعار الصرف لـ Laravel والتعامل مع الـ FX كمشكلة محلولة. الجزء غير البسيط هو فعل ذلك بشكل صحيح: تخزين مؤقت قوي حتى لا تستنزف حصتك، حفظ الأسعار حتى لا يُسقط 5xx من المصدر صفحة الدفع، جدولة التحديثات حول تثبيت البنك المركزي الأوروبي عند 16:00 CET، وكتابة اختبارات لا تلامس الشبكة.
هذا الدليل يغطي كل ذلك. سنبني محول عملات صغيرًا باستخدام Laravel 11، وAPI Finexly، وEloquent للحفظ، وواجهة Cache للقراءات السريعة، وscheduler للتحديث، وقاعدة تحقق مخصصة لرموز ISO 4217، وPest مع Http::fake() للاختبارات. في النهاية ستحصل على بنية معتمدة على service classes يمكن نسخها إلى أي تطبيق Laravel — فوترة SaaS، تجارة إلكترونية، محاسبة، وأي مكان تعبر فيه الأموال الحدود.
لماذا API مخصص لأسعار الصرف يتفوق على القيم المُكوَّدة
تثبيت أسعار في ملف config هو الإجابة الخاطئة الأولى. استدعاء المصدر مع كل طلب هو الإجابة الخاطئة الثانية. API مخصصة للعملات تُستخدم بشكل صحيح تمنحك أربعة أمور لا تستطيع القيم الثابتة منحها:
- حداثة عند الطلب. الأسعار تتحرك باستمرار خلال ساعات السوق. إن كنت تخصم من العملاء، فحتى سعر متأخر 24 ساعة قد يتحرك 1-2% على أزواج متقلبة مثل USD/JPY أو EUR/TRY — ما يكفي لمحو هامش خطة SaaS.
- تغطية واسعة. Finexly تغطي 170+ عملة بما في ذلك عملات الأسواق الناشئة ومرجعيات CBDC. تغذية البنك المركزي الأوروبي المضمنة مع Laravel Swap تغطي 32 عملة رئيسية تقريبًا. زبون واحد يدفع بالبيزو الأرجنتيني أو الليرة التركية يكفي ليجعل الفرق ملموسًا.
- عقد واحد. شكل JSON واحد عبر
latestوhistoricalوconvert، بدلًا من ثلاث تغذيات مختلفة ملصقة باللاصق. - حدود معدل قابلة للتنبؤ. حصة موثقة يمكنك التخطيط عليها، لا "البنك حظر IP لكثرة polling".
للمقارنة بين الخيارات، غطّينا الموضوع بتفصيل في مقارنة 2026: API عملات مجاني مقابل مدفوع وExchangeRate-API vs CurrencyLayer vs Finexly.
إعداد المشروع: Laravel 11 + Composer
يفترض هذا الشرح تثبيت Laravel 11 جديدًا (Laravel 10 يعمل بشكل مماثل، الفرق الوحيد هو ملف bootstrap/app.php الذي نلمسه في الخطوة 5):
laravel new fx-converter
cd fx-converter
php artisan migrateأضف مفتاح Finexly إلى .env:
FINEXLY_API_KEY=your_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15 دقيقة (900 ثانية) قيمة افتراضية معقولة للفوترة B2B. الدفع للمستهلكين يطلب عادةً 60-300 ثانية، ولوحة الخزانة قد تمتد إلى 3,600. اختر بحسب المخاطر التي تتقبلها على التحركات السلبية.
أضف الكتلة المقابلة في config/services.php:
'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'),
],ليس لديك مفتاح بعد؟ سجّل في Finexly — الخطة المجانية تمنحك 1,000 طلب شهريًا، أكثر من كافٍ لمشروع جانبي أو SaaS صغير.
بناء كلاس ExchangeRateService
القاعدة التي نلتزم بها: لا يتحدث أي controller أو job أو Blade view مع Finexly مباشرة. كل شيء يمر عبر service class وحيدة، ما يجعل الـ mocks تافهة في الاختبارات ويترك مكانًا واحدًا لتغيير المزود أو إضافة منطق fallback.
أنشئ app/Services/ExchangeRateService.php:
<?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'];
}
}نقاط جديرة بالملاحظة. السطر retry(2, 250) يعطيك إعادتي محاولة تلقائيتين بفاصل 250 مللي ثانية، ما يستوعب معظم 502/503 العابرة دون كتابة منطق إعادة محاولة. timeout(8) متحفظ — أقل من 5 ثوانٍ ضيق إذا كنت تعبر القارات إلى المصدر، وأكثر من 10 ثوانٍ يجعل مصدرًا غير مستقر يدمر ميزانية تأخيرك. withToken() يضيف ترويسة Authorization: Bearer ... التي يتوقعها Finexly.
تسجيل الـ Service Provider
اربط الخدمة في app/Providers/AppServiceProvider.php ليعمل حقن التبعية:
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 مناسب — لا توجد حالة لكل طلب داخل الكلاس، وsingleton يسمح بإعادة استخدام اتصالات HTTP داخل الطلب نفسه.
التخزين المؤقت بـ Laravel Cache (Redis مفضل)
Cache::remember داخل latest() يفعل أكثر مما يبدو. مع driver الملف السلوك صحيح لكن cache stampede عند الانتهاء يضرب Finexly بدفعة. مع Redis يساعد علمان:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisفي النقاط التي لا يمكنك تحمل stampede فيها — صفحة الدفع مثلًا — لُف الاستدعاء بـ Cache::lock:
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));
}عملية واحدة فقط تعيد جلب المصدر؛ البقية تنتظر حتى 3 ثوانٍ ليُعاد ملء الكاش. غطّينا نمط stampede + retry بعمق في دليل التخزين المؤقت ومعالجة الأخطاء لـ API العملات.
الحفظ بـ Eloquent
الكاش للقراءات السريعة. قاعدة البيانات للـ متانة — حين يبرد الكاش (إعادة تشغيل Redis، نشر يفرّغ store)، تريد أن تكون الأسعار الأخيرة متاحة دون رحلة إلى المصدر. أنشئ migration:
php artisan make:model ExchangeRate -mالجدول:
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) مهمة. تخزين الأسعار كـ float يقتطع pips الكسرية على أزواج JPY ويُنتج أخطاء توفيق ظاهرة عند الحجم. ثماني خانات عشرية تغطي أي سعر تقاطع منشور.
النموذج:
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);
}
}أضف للخدمة دالة تأخذ snapshot للاستجابة في الجدول — مفيد للتدقيق والتحليل التاريخي:
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 يتجنب الذهاب-والإياب المزدوج التقليدي «أدخل أو حدّث» — جملة واحدة، idempotent على المفتاح الفريد، مثالية للتحديث اليومي أدناه.
جدولة تحديث يومي
scheduler في Laravel هو المكان المناسب. افتح routes/console.php (Laravel 11) أو app/Console/Kernel.php (Laravel 10):
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 تلتقط التثبيت المرجعي للبنك المركزي الأوروبي عند 16:00 CET — خمس عشرة دقيقة فسحة لاستيعاب أي تأخير من المزود. onOneServer() بالغ الأهمية إن كان لديك أكثر من worker، إذ بدونه يستدعي نشر أفقي الـ snapshot N مرة ويضاعف استهلاك الحصة. لا تنسَ إدخال cron على الخادم:
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1نقطة نهاية المحول
أَوصِل المسار والـ controller. 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(),
],
]);
}
}عرض Blade مبسط 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">حوّل</button>
</form>
@isset($result)
<p>{{ $result['amount'] }} {{ $result['from'] }} =
{{ $result['converted'] }} {{ $result['to'] }}
(السعر {{ $result['rate'] }}، حتى {{ $result['as_of'] }})</p>
@endissetأربعون سطر Blade وcontroller واحد — المحول كاملًا من جهة المستخدم. إن أردت نسخة مستضافة جاهزة، فإن محول العملات لدينا على finexly.com مبني على نفس النقاط.
قاعدة تحقق مخصصة لـ ISO 4217
التحقق اليدوي بـ 'currencies' => 'in:USD,EUR,GBP,...' يصمد لعشرة رموز ويتفكك عند 170. أنشئ قاعدة جدية:
php artisan make:rule IsoCurrencynamespace 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.");
}
}
}تريد تحققًا أصرم؟ خزّن قائمة الرموز الحية:
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});ثم تحقق in_array(strtoupper($value), $symbols, true) داخل القاعدة. مرجع المعيار الكامل في دليل رموز ISO 4217.
أخطاء API وإعادة المحاولات وحدود المعدل
retry/timeout في الخدمة يعالج التذبذبات العابرة. للأعطال المستمرة، تحتاج سلسلة fallback: cache → DB → upstream. أعد ترتيب latest():
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 503 لساعة، يستمر دفعك بآخر snapshot من قاعدة البيانات. اقترن ذلك بـ خطة تسعير تمنحك هامشًا في حد المعدل — العمل عند 95% من الحصة دعوة لانقلاب الـ throttle إلى 429 عند أول ارتفاع.
التطبيقات التي تهمها الميلي ثانية — لوحات التداول مثلًا — يكون نمط snapshot بطيئًا فيها. اذهب إلى streaming؛ قارنّا الموازنات في REST مقابل WebSocket لتحويل العملات.
اختبارات بـ Pest وHttp::fake()
يجب ألا تلامس الاختبارات API الحي. Http::fake() يجعل ذلك تافهًا. أنشئ 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. ثلاث اختبارات تغطي المسار السعيد ومسار التحقق وfallback — وصفر استدعاءات شبكية.
قائمة فحص ما قبل الإنتاج
قائمة قصيرة لما يُنسى عادةً عند أول نشر:
- ضع snapshot في طابور. لُف استدعاء scheduler بـ
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')كي لا يعرقل مصدر بطيء scheduler. - راقب 4xx و5xx بشكل منفصل. 401 يعني تدوير المفتاح، 429 يعني حاجة لخطة أكبر، 503 يعني سقوط المصدر. ثلاث تنبيهات مختلفة.
- ثبّت
User-Agent. الترافيك المجهول هو أول من يُؤجَّل عند الحمل لدى مزودي الخدمة. - لا تسجّل مفتاح API. أضف
FINEXLY_API_KEYإلى قائمة التعتيم فيconfig/logging.php. - ضع circuit breaker. خمس إخفاقات متتالية في 60 ثانية → توقف عن استدعاء المصدر، اخدم من الكاش، أبلغ المناوب.
- التقط snapshot يومي العطلة أيضًا. يجمّد معظم المزودين الأسعار في عطلة الأسبوع، لكن صفوف السبت والأحد في القاعدة تبسّط تقارير نهاية الشهر.
أسئلة شائعة
أي إصدار Laravel يستهدفه هذا الشرح؟
Laravel 11. الكود يعمل بدون تعديل على Laravel 10، الفرق الوحيد هو مكان تسجيل المهام المجدولة (app/Console/Kernel.php في 10، routes/console.php في 11). Laravel 12 (الصادر مارس 2025) متوافق بالكامل أيضًا.
هل يمكن استخدامه مع Lumen؟
الـ service class مستقل وينقل مباشرة. ستحتاج إلى تسجيل الـ binding يدويًا لأن Lumen لا يكتشف providers تلقائيًا، وAPI الـ scheduler يختلف قليلًا — استخدم Lumen\Framework\Console\Scheduling\Schedule.
ما تواتر تحديث الأسعار؟ لفوترة B2B، مرة يوميًا بعد تثبيت 16:00 CET للبنك المركزي الأوروبي هو المعيار. لدفع المستهلكين، كل 5-15 دقيقة خلال ساعات السوق. للتداول أو لوحات الخزانة، WebSocket لحظي — polling بـ REST سيتأخر دائمًا. تفاصيل الموازنة في دليل API العملات المجاني.
ماذا يحدث إن سقط API المصدر؟ بسلسلة fallback التي بنيناها، يخدم الكاش الأسعار الأخيرة حتى انتهاء TTL، ثم تخدم القاعدة أحدث snapshot حتى 7 أيام، ثم فقط يفشل الطلب. عمليًا، يصمد دفعك أمام أي عطل في اليوم نفسه.
هل أحتاج خطة مدفوعة لمتابعة هذا الشرح؟ لا. الطبقة المجانية في Finexly — 1,000 طلب شهريًا دون بطاقة ائتمان — كافية لكل خطوة. مع TTL 15 دقيقة، يدعم ذلك حوالي 30,000 مشاهدة صفحة يوميًا قبل الوصول إلى الحصة.
جاهز لتوصيل أسعار صرف لحظية بتطبيق Laravel الخاص بك؟ احصل على مفتاح Finexly مجانًا — 1,000 طلب شهريًا، بدون بطاقة، 30 ثانية. إن تجاوزت الطبقة المجانية، تبدأ خطط الأسعار من 9 دولار شهريًا وتمتد إلى enterprise. أو قارن APIs العملات جنبًا إلى جنب قبل الاختيار.
Explore More
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 →