عندما ينتظر زائر موقعك ست ثوانٍ بعد ضغط زر «إرسال» لمجرد أن التطبيق يحاول الاتصال بخادم بريد بطيء، فأنت لا تخسر ثوانٍ فقط، بل تخسر تجربة المستخدم وتخسر نقاطًا في مؤشرات الأداء التي تعتمد عليها محركات البحث. الحل في لارافيل هو طوابير الانتظار (Queues): تنقل المهمة الثقيلة إلى الخلفية، وتُعيد الاستجابة للمستخدم فورًا.
في هذا الدليل نبدأ من الصفر: ما هي الطوابير وكيف تعمل داخليًا، ثم نبني مثالًا كاملًا لإرسال البريد الإلكتروني بالخلفية، ثم ننتقل إلى ما تحتاجه فعليًا على خادم الإنتاج — تشغيل الـ Worker، معالجة المهام الفاشلة، المحاولات وإعادة المحاولة، ومراقبة الطوابير.
النسخة المعتمدة في هذا الدليل: لارافيل 11 و12 و13 (الأمثلة مكتوبة بأسلوب لارافيل 13). أشرنا في كل موضع تغيّر فيه شيء عن لارافيل 10 وما قبله حتى يستفيد أصحاب المشاريع القديمة أيضًا.
ما هي Queue في لارافيل ولماذا نحتاجها؟
طابور الانتظار (Queue) هو آلية لتأجيل تنفيذ مهمة إلى ما بعد انتهاء طلب المستخدم. بدلًا من أن ينفّذ الـ Controller المهمة الثقيلة بنفسه ويجعل المتصفح ينتظر، يكتفي بتسجيل «بطاقة عمل» في مخزن (قاعدة بيانات أو Redis) ثم يُعيد الاستجابة فورًا. تأتي عملية أخرى منفصلة تُسمى Worker فتلتقط البطاقة وتنفّذها في الخلفية.
المشكلة عمليًا
انظر إلى هذا الكود التقليدي في صفحة «اتصل بنا». المستخدم يضغط إرسال، ثم ينتظر… وينتظر:
public function send(ContactRequest $request)
{
Mail::to('support@example.com')->send(
new ContactMessage($request->validated())
);
return redirect('/contact')->with('success', 'تم إرسال رسالتك بنجاح');
}
الاتصال بخادم SMTP قد يستغرق من ثانيتين إلى عشر ثوانٍ حسب المزوّد وحالة الشبكة. طوال هذه المدة تبقى عملية PHP محجوزة، ويبقى المستخدم أمام شاشة تحميل، ولا تظهر رسالة النجاح إلا بعد اكتمال الإرسال فعليًا.
ما الذي يغيّره استخدام Queue؟
نقطة مهمة يُساء فهمها كثيرًا: الطابور لا يجعل إرسال البريد أسرع. البريد سيستغرق نفس الثواني الست. ما يتغيّر هو أن هذه الثواني لم تعد داخل دورة الطلب: يستجيب التطبيق للمستخدم خلال أجزاء من الثانية، ويجري الإرسال في الخلفية دون أن يشعر به أحد.
متى تستخدم الطوابير؟
- إرسال البريد الإلكتروني والإشعارات ورسائل SMS.
- معالجة الوسائط: ضغط الفيديو، توليد الصور المصغّرة، تحويل الصيغ.
- استيراد أو تصدير ملفات CSV وExcel الكبيرة.
- الاتصال بواجهات برمجية خارجية بطيئة أو غير مستقرة (بوابات دفع، خدمات شحن).
- توليد التقارير وملفات PDF الثقيلة.
- تحديث فهارس البحث والإحصائيات بعد كل عملية.
متى لا تستخدمها؟
- عندما يحتاج المستخدم نتيجة العملية فورًا على الشاشة (تسجيل الدخول، عملية دفع متزامنة).
- عندما تكون المهمة أسرع من تكلفة وضعها في الطابور أصلًا (استعلام بسيط، حفظ سجل واحد).
- عندما لا تملك بيئة تسمح بتشغيل عملية دائمة في الخلفية — فالطابور بلا Worker يعمل يعني مهام لا تُنفّذ أبدًا.
كيف تعمل الطوابير من الداخل؟
فهم دورة الحياة يوفّر عليك ساعات من التصحيح لاحقًا. الرحلة تمر بست مراحل:
- الإرسال (Dispatch): تستدعي
MyJob::dispatch($data)داخل الـ Controller. - التسلسل (Serialization): يحوّل لارافيل الكائن وبياناته إلى نص JSON يسمّى
payload. - التخزين: يُخزَّن الـ payload في صف جديد داخل جدول
jobs(أو في قائمة داخل Redis). - الاستجابة: ينتهي الطلب ويعود المستخدم إلى الصفحة فورًا.
- الالتقاط: عملية
queue:workالعاملة في الخلفية تسحب أقدم صف، وتحجزه بوضع طابع زمني فيreserved_atحتى لا يلتقطه Worker آخر. - التنفيذ: يُعاد بناء الكائن وتُنفَّذ دالة
handle(). عند النجاح يُحذف الصف، وعند الفشل يُعاد للطابور أو يُنقل إلى جدولfailed_jobs.
لاحظ الخطوة الخامسة جيدًا: لا شيء يحدث بلا Worker. إذا وضعت QUEUE_CONNECTION=database ولم تشغّل php artisan queue:work، فستمتلئ صفوف جدول jobs دون أن يصل بريد واحد. هذا أكثر سؤال يتكرر بين
المبتدئين.
المفاهيم الأساسية: Connection وQueue وJob وWorker
هذه المصطلحات تتشابه وتُخلط كثيرًا، وتوضيحها مبكرًا يجعل بقية الدليل أسهل:
| المصطلح | المعنى |
|---|---|
| Connection | الوسيط الذي يُخزَّن فيه الطابور: database، redis، sqs… يُعرَّف في config/queue.php. |
| Queue | مسار منطقي داخل نفس الاتصال. يمكن أن يحتوي اتصال واحد على طوابير متعددة مثل emails وhigh وdefault، لتوزيع الأولويات. |
| Job | كلاس يمثّل مهمة واحدة قابلة للتنفيذ، يعيش في app/Jobs وينفّذ الواجهة ShouldQueue. |
| Payload | التمثيل النصي (JSON) للمهمة وبياناتها كما يُخزَّن في الوسيط. |
| Worker | عملية PHP طويلة العمر تُشغَّل بأمر queue:work وتستهلك المهام باستمرار. |
| Attempt | محاولة تنفيذ واحدة. تُستهلك عند التنفيذ، أو عند رمي استثناء، أو عند إعادة المهمة للطابور، أو عند انتهاء المهلة. |
| Failed Job | مهمة استنفدت كل محاولاتها المسموحة، فانتقلت إلى جدول failed_jobs بانتظار مراجعتك. |
باختصار: الاتصال يحدد أين تُخزَّن المهام، والطابور يحدد في أي رتل تقف داخل ذلك المخزن.
خيارات التنفيذ Queue Drivers ومقارنة بينها
تُعرَّف كل الاتصالات مسبقًا في ملف config/queue.php، وتختار بينها عبر المتغير QUEUE_CONNECTION في ملف .env. إليك ما يقدّمه كل خيار:
| الـ Driver | متطلباته | الأنسب لـ | ملاحظات |
|---|---|---|---|
sync |
لا شيء | التطوير والاختبار | ينفّذ المهمة فورًا داخل نفس الطلب، أي أنه لا يؤجّل شيئًا فعليًا. |
database |
جدول jobs |
المشاريع الصغيرة والمتوسطة | الأسهل إعدادًا ولا يحتاج خدمة إضافية، لكنه يضيف حِملًا على قاعدة البيانات عند الأحجام الكبيرة. |
redis |
خادم Redis + phpredis أو predis/predis |
الإنتاج والأحمال العالية | الأسرع والأكثر شيوعًا، ويفتح لك الباب لاستخدام لوحة Laravel Horizon. |
sqs |
حساب AWS + aws/aws-sdk-php |
البنى السحابية الموزّعة | خدمة مُدارة بالكامل تتوسّع تلقائيًا، وتدعم طوابير FIFO. |
beanstalkd |
خادم Beanstalkd + pda/pheanstalk |
حالات خاصة | خفيف وسريع، لكنه أقل شيوعًا اليوم. |
null |
لا شيء | الاختبارات | يتجاهل كل المهام المرسلة إليه. |
اتصالات إضافية في الإصدارات الحديثة
أضافت الإصدارات الأخيرة ثلاثة اتصالات تحلّ مشكلات عملية متكررة:
deferred: ينفّذ المهمة بعد إرسال استجابة HTTP للمستخدم لكن داخل نفس العملية — مفيد جدًا عندما لا تملك Worker يعمل وتريد مع ذلك استجابة سريعة.background: مثل السابق، لكنه يفتح عملية PHP منفصلة، فيتحرر عامل PHP-FPM لاستقبال طلب جديد.failover: يجرّب قائمة اتصالات بالترتيب، فإذا فشل الأول انتقل تلقائيًا إلى الذي يليه.
// config/queue.php
'failover' => [
'driver' => 'failover',
'connections' => ['redis', 'database', 'sync'],
],
إعداد الطوابير خطوة بخطوة
1. تحديد الاتصال الافتراضي
يقرأ ملف config/queue.php قيمته من البيئة، ولذلك لا تعدّل ملف الإعدادات مباشرة بل عدّل .env:
// config/queue.php — لا تلمس هذا السطر
'default' => env('QUEUE_CONNECTION', 'database'),
# .env
QUEUE_CONNECTION=database
في مشاريع لارافيل الجديدة صارت القيمة الافتراضية database بدلًا من sync. إذا كان مشروعك قديمًا فستجدها sync ويجب تغييرها بنفسك.
وبعد أي تعديل على .env نظّف ذاكرة الإعدادات:
php artisan config:clear
2. تجهيز جدول jobs
اعتبارًا من لارافيل 11، يأتي جدول jobs جاهزًا ضمن الترحيل الافتراضي 0001_01_01_000002_create_jobs_table.php، فيكفيك تنفيذ migrate. وإن كان مشروعك لا يحتوي هذا الملف:
# لارافيل 11 وما بعده
php artisan make:queue-table
php artisan migrate
# لارافيل 10 وما قبله
php artisan queue:table
php artisan migrate
وهذه بنية الجدول الناتج:
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
معنى كل عمود:
queue: اسم الطابور الذي تنتمي إليه المهمة، ولهذا هو مفهرس.payload: المهمة مسلسلة بصيغة JSON.attempts: عدد المحاولات المستهلكة حتى الآن.reserved_at: لحظة حجز المهمة من قِبل Worker، ووجود قيمة فيه يمنع Worker آخر من التقاطها.available_at: أول لحظة يُسمح فيها بتنفيذ المهمة، وهو ما يجعل التأجيلdelay()ممكنًا.
3. تجهيز جدول failed_jobs
لا تتخطَّ هذه الخطوة؛ بدونها تختفي المهام الفاشلة بلا أثر:
php artisan make:queue-failed-table # queue:failed-table في الإصدارات الأقدم
php artisan migrate
إنشاء أول Job وفهم بنيته
php artisan make:job SendContactEmail
يُنشئ الأمر الكلاس في app/Jobs/SendContactEmail.php بهذا الشكل:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendContactEmail implements ShouldQueue
{
use Queueable;
public function __construct()
{
}
public function handle(): void
{
}
}
عنصران يستحقان الانتباه:
- الواجهة
ShouldQueueهي التي تخبر لارافيل أن هذه المهمة تُدفع إلى الطابور وتُنفَّذ لاحقًا. لو حذفتها لنُفِّذت المهمة فورًا وبشكل متزامن. - الـ trait
Queueableيجمع في لارافيل 11 وما بعده ما كان موزّعًا على أربعة traits (DispatchableوInteractsWithQueueوQueueableوSerializesModels). إن كنت على إصدار أقدم فستجدها الأربعة مكتوبة صراحةً، وهذا سليم تمامًا.
تمرير البيانات إلى المهمة
استخدم constructor property promotion للاختصار. أي وسيط تمرّره إلى dispatch() يصل إلى الـ constructor بنفس الترتيب:
public function __construct(
public array $data,
) {}
تحذير مهم: تسلسل الـ Models
عند تمرير Eloquent Model إلى المهمة، لا يُخزَّن الكائن كاملًا بل معرّفه فقط، ثم يُعاد جلبه من قاعدة البيانات لحظة التنفيذ. لهذا نتيجتان عمليتان:
- المهمة ترى أحدث نسخة من السجل وقت التنفيذ، لا نسخته وقت الإرسال. تجنّب الاعتماد على قيمة قديمة في الذاكرة.
- إذا حُذف السجل قبل تنفيذ المهمة فسيرمي لارافيل استثناءً. لتجاهل هذه الحالة بهدوء، أضف الخاصية
public $deleteWhenMissingModels = true;إلى الكلاس.
كذلك تُسلسَل العلاقات المحمّلة مع الـ model، وقد يتضخم حجم الـ payload بلا داعٍ. لتفادي ذلك استخدم $model->withoutRelations() أو خاصية #[WithoutRelations] في الإصدارات الحديثة. وإذا احتجت تمرير بيانات ثنائية مثل محتوى
صورة خام، مرّرها عبر base64_encode() أولًا.
مثال عملي: إرسال البريد الإلكتروني بالخلفية
نطبّق الآن ما سبق على نموذج «اتصل بنا» الذي بدأنا به.
1. الـ Controller بعد التعديل
بدل إنشاء نسخة من الـ Mailable وإرسالها مباشرة، ندفع المهمة إلى الطابور. لاحظ أن نوع الاستجابة لم يتغيّر إطلاقًا من وجهة نظر المستخدم، لكن زمنها انخفض إلى أجزاء من الثانية:
<?php
namespace App\Http\Controllers;
use App\Jobs\SendContactEmail;
use App\Http\Requests\ContactRequest;
use Illuminate\Http\RedirectResponse;
class ContactController extends Controller
{
public function send(ContactRequest $request): RedirectResponse
{
SendContactEmail::dispatch($request->validated());
return redirect('/contact')
->with('success', 'تم إرسال رسالتك بنجاح');
}
}
استخدام ContactRequest مع validated() بدل قراءة الحقول يدويًا من $request ليس تفصيلًا تجميليًا: هو ما يضمن ألّا تُخزَّن مدخلات غير مُتحقَّق منها في الطابور ثم تُنفَّذ لاحقًا بعيدًا عن أي رقابة.
2. كلاس المهمة
<?php
namespace App\Jobs;
use App\Mail\ContactMessage;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class SendContactEmail implements ShouldQueue
{
use Queueable;
public function __construct(
public array $data,
) {}
public function handle(): void
{
Mail::to(config('mail.contact_address'))
->send(new ContactMessage($this->data));
}
public function failed(?Throwable $exception): void
{
Log::error('فشل إرسال رسالة نموذج الاتصال', [
'email' => $this->data['email'] ?? null,
'error' => $exception?->getMessage(),
]);
}
}
دالة failed() تُستدعى بعد استنفاد كل المحاولات. استعملها للتنظيف أو التنبيه أو التسجيل. وانتبه: يُنشئ لارافيل نسخة جديدة من الكلاس قبل استدعائها، لذلك أي تعديل أجريته على خصائص الكائن داخل handle() لن يكون موجودًا هنا.
3. الطريقة الأقصر: طابور على مستوى الـ Mailable
إن كانت المهمة إرسال بريد لا أكثر، فلست مضطرًا لإنشاء Job أصلًا. استبدل send() بـ queue():
Mail::to($request->email)->queue(new ContactMessage($data));
// أو مع تأخير خمس دقائق
Mail::to($request->email)->later(
now()->addMinutes(5),
new ContactMessage($data)
);
وإذا أردت أن يُرسَل بريد معيّن عبر الطابور دائمًا أينما استُدعي، اجعل الـ Mailable نفسه ينفّذ ShouldQueue:
class ContactMessage extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
// ...
}
فمتى تنشئ Job إذن؟ عندما تكون المهمة أكثر من مجرد إرسال: تحديث سجل، استدعاء خدمة خارجية، منطق إعادة محاولة مخصص، أو سلسلة خطوات مترابطة. أما البريد المفرد فـ queue() يكفيه.
4. التجربة
php artisan queue:work
أرسل النموذج الآن: ستظهر رسالة النجاح فورًا، وسترى في نافذة الـ Worker سطرًا يوضح التقاط المهمة ثم إنجازها. ولو أوقفت الـ Worker وأرسلت مرة أخرى، ستجد المهمة منتظرة في جدول jobs حتى تشغّله.
تشغيل الـ Queue Worker
الـ Worker عملية دائمة تدور في حلقة: تسحب مهمة، تنفّذها، ثم تعود لتسحب التالية.
php artisan queue:work
الخيارات التي ستستخدمها فعليًا
| الخيار | وظيفته |
|---|---|
--queue=high,default |
تحديد الطوابير وترتيب أولوياتها؛ لا يُلتقط شيء من default قبل إفراغ high. |
--tries=3 |
أقصى عدد محاولات قبل اعتبار المهمة فاشلة. القيمة الافتراضية محاولة واحدة فقط. |
--backoff=10 |
عدد الثواني قبل إعادة المحاولة بعد استثناء. |
--timeout=60 |
أقصى مدة تنفيذ للمهمة الواحدة قبل إنهاء العملية. الافتراضي 60 ثانية. |
--sleep=3 |
مدة السكون حين يكون الطابور فارغًا. |
--max-time=3600 |
إنهاء العملية بعد ساعة لتحرير الذاكرة، ويتولى Supervisor إعادة تشغيلها. |
--max-jobs=1000 |
إنهاء العملية بعد عدد محدد من المهام، لنفس الغرض السابق. |
--stop-when-empty |
الخروج بمجرد إفراغ الطابور، وهو مفيد داخل حاويات Docker المؤقتة. |
queue:work أم queue:listen؟
الفرق جوهري: يُحمّل queue:work التطبيق في الذاكرة مرة واحدة، فهو سريع لكنه لا يرى تعديلاتك على الكود بعد تشغيله. أما queue:listen فيعيد تحميل الإطار مع كل مهمة، فيلتقط أي تغيير فورًا لكنه أبطأ بكثير.
القاعدة العملية: listen أثناء التطوير، وwork على الإنتاج.
كلما عدّلت كلاس Job وأنت تستخدم
queue:work، أوقف العملية وأعد تشغيلها. وإلا ستبقى تُنفَّذ النسخة القديمة من الكود وتظن أن تعديلك لم يعمل.
الأولويات وتعدد الـ Workers
وجّه المهام الحساسة إلى طابور مستقل ثم امنحه الأولوية:
ProcessPayment::dispatch($order)->onQueue('high');
GenerateReport::dispatch($order)->onQueue('low');
php artisan queue:work --queue=high,low
ولمعالجة عدة مهام في وقت واحد، شغّل أكثر من عملية queue:work بالتوازي — وهذا بالضبط ما يفعله خيار numprocs في Supervisor كما سنرى بعد قليل.
المحاولات والمهام الفاشلة Failed Jobs
الشبكات تنقطع وواجهات الطرف الثالث تتعطل. التعامل مع الفشل ليس ترفًا بل جزء من التصميم.
ضبط سلوك المهمة على مستوى الكلاس
يمكن ضبط الإعدادات عبر الأمر السطري، لكن تحديدها داخل الكلاس أدق لأنها تتبع المهمة أينما نُفِّذت وتتقدّم على قيم سطر الأوامر. تستخدم الإصدارات الحديثة خصائص PHP (Attributes):
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
#[Tries(5)]
#[Backoff([10, 30, 60])]
#[Timeout(120)]
#[MaxExceptions(3)]
class SyncInvoiceWithErp implements ShouldQueue
{
use Queueable;
// ...
}
Backoff بمصفوفة يعطيك تراجعًا تصاعديًا: عشر ثوانٍ قبل المحاولة الثانية، ثلاثون قبل الثالثة، ثم ستون لما بعدها. هذا هو السلوك الصحيح مع خدمة خارجية متعثّرة، فالإلحاح الفوري يزيد الأمر سوءًا.
وفي لارافيل 10 وما قبله، تُكتب الإعدادات نفسها كخصائص عادية:
public $tries = 5;
public $backoff = [10, 30, 60];
public $timeout = 120;
public $maxExceptions = 3;
وبدل تحديد عدد المحاولات، يمكنك تحديد مهلة زمنية نهائية:
public function retryUntil(): \DateTime
{
return now()->addMinutes(30);
}
علاقة timeout بـ retry_after
هذه نقطة تُسبب مشكلة «تنفيذ المهمة مرتين» الشهيرة. القيمة retry_after في config/queue.php تحدد بعد كم ثانية يُعتبر الحجز منتهيًا فتُعاد المهمة للطابور. لذلك يجب أن تكون timeout أقل من retry_after بعدة ثوانٍ دائمًا، وإلا فقد يلتقط Worker آخر المهمة قبل أن تنتهي فتُنفَّذ مرتين.
إدارة المهام الفاشلة
# عرض كل المهام الفاشلة
php artisan queue:failed
# إعادة محاولة مهمة واحدة بمعرّفها
php artisan queue:retry 5eda1e28-cf5a-4b74-a8b1-fd0e0f0f7cfb
# إعادة محاولة الجميع
php artisan queue:retry all
# حذف مهمة فاشلة واحدة
php artisan queue:forget 5eda1e28-cf5a-4b74-a8b1-fd0e0f0f7cfb
# تفريغ الجدول بالكامل
php artisan queue:flush
# حذف السجلات الأقدم من 48 ساعة (اجدولها يوميًا)
php artisan queue:prune-failed --hours=48
وللمراقبة الاستباقية، سجّل تنبيهًا يُطلق عند كل فشل داخل AppServiceProvider:
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobFailed;
Queue::failing(function (JobFailed $event) {
// $event->connectionName, $event->job, $event->exception
});
ميزات متقدمة تستحق المعرفة
التأجيل والتوجيه
// تنفيذ بعد عشر دقائق
SendReminder::dispatch($user)->delay(now()->addMinutes(10));
// تحديد الطابور والاتصال معًا
ProcessVideo::dispatch($video)->onConnection('redis')->onQueue('media');
// إرسال مشروط
SendInvoice::dispatchIf($order->isPaid(), $order);
السلاسل: مهام تُنفَّذ بالترتيب
إن فشلت حلقة في السلسلة، توقّف ما بعدها. مثالي للعمليات التي تعتمد نتائج بعضها:
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessVideo($video),
new GenerateThumbnails($video),
new NotifySubscribers($video),
])->dispatch();
الدفعات: مهام متوازية بنتيجة واحدة
تحتاج أولًا جدولًا لتتبّع الدفعات:
php artisan make:queue-batches-table
php artisan migrate
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
$batch = Bus::batch([
new ImportRows(1, 1000),
new ImportRows(1001, 2000),
new ImportRows(2001, 3000),
])->name('استيراد العملاء')
->then(fn (Batch $batch) => /* اكتملت جميع المهام */ null)
->catch(fn (Batch $batch, Throwable $e) => /* فشلت إحداها */ null)
->dispatch();
return $batch->id; // لعرض نسبة التقدّم للمستخدم
خاصية $batch->progress() تعيد نسبة إنجاز من 0 إلى 100، ما يتيح لك بناء شريط تقدّم حقيقي في الواجهة أثناء معالجة ملف كبير.
منع التكرار والتداخل
ShouldBeUnique: يمنع وجود نسختين من نفس المهمة في الطابور في آن واحد، وهو ما تحتاجه مع أزرار يضغطها المستخدم مرتين.WithoutOverlapping: يمنع تنفيذ مهمتين على نفس المورد في اللحظة ذاتها.RateLimited: يحدّ من معدل التنفيذ، وهو ضروري مع واجهات برمجية لها حصص.
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [(new WithoutOverlapping($this->user->id))->releaseAfter(60)];
}
لاحظ أن هذه الميزات تعتمد على أقفال الـ Cache، فتحتاج driver يدعمها مثل Redis أو database أو file.
المهام داخل معاملات قاعدة البيانات
مشكلة كلاسيكية: تُرسل مهمة داخل DB::transaction()، فيلتقطها Worker قبل تثبيت المعاملة، فلا يجد السجل الذي أنشأته للتو. العلاج إما ضبط 'after_commit' => true على الاتصال، أو تحديدها عند الإرسال:
SendWelcomeEmail::dispatch($user)->afterCommit();
تشفير المهام الحساسة
محتوى payload يُخزَّن كنص واضح يقرأه كل من يملك وصولًا لقاعدة البيانات. إذا كانت المهمة تحمل بيانات حساسة، نفّذ الواجهة ShouldBeEncrypted وسيتولى لارافيل التشفير تلقائيًا.
النشر على الإنتاج: Supervisor وHorizon والمراقبة
لماذا Supervisor؟
تشغيل queue:work في نافذة طرفية ينتهي بمجرد إغلاقها. والعملية نفسها قد تتوقف لأسباب مشروعة: انتهاء مهلة، أو أمر queue:restart، أو خطأ فادح. Supervisor هو مراقب عمليات يعيد تشغيلها تلقائيًا ويشغّل عدة نسخ منها.
sudo apt-get install supervisor
أنشئ الملف /etc/supervisor/conf.d/laravel-worker.conf:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/example.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/example.com/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"
اضبط numprocs بحسب حجم العمل وموارد الخادم، واحرص على أن تكون قيمة stopwaitsecs أكبر من مدة أطول مهمة لديك، وإلا قتلها Supervisor قبل أن تكتمل.
خطوة لا تُنسى في كل عملية نشر
لأن الـ Workers تحتفظ بالكود في الذاكرة، فهي ستظل تنفّذ النسخة القديمة بعد كل تحديث. أضف هذا السطر إلى سكربت النشر:
php artisan queue:restart
يطلب الأمر من كل Worker أن ينهي مهمته الحالية ثم يخرج بهدوء، فيعيد Supervisor تشغيله بالكود الجديد دون ضياع أي مهمة. وبما أن الإشارة تُخزَّن في الـ Cache، تأكد من إعداد driver مناسب للـ cache.
إيقاف الطوابير مؤقتًا
خلال أعمال الصيانة، يمكنك إيقاف الالتقاط دون إيقاف العمليات نفسها:
php artisan queue:pause database:default
php artisan queue:continue database:default
Laravel Horizon
إذا كنت تستخدم Redis، فحزمة Horizon الرسمية تمنحك لوحة تحكم بصرية: معدل الإنتاجية، أزمنة الانتظار، المهام الفاشلة مع إمكانية إعادة تشغيلها بضغطة، وإدارة إعدادات الـ Workers من ملف واحد. هي المعيار العملي لأي مشروع جدّي يعتمد على الطوابير.
المراقبة
راقب مؤشرين على الأقل: عدد الصفوف في جدول jobs — تزايدها المستمر يعني أن الـ Workers متوقفة أو غير كافية — وعدد الصفوف في failed_jobs، فارتفاعه المفاجئ يشير إلى عطل في
خدمة تعتمد عليها.
أخطاء شائعة وكيف تتجنبها
- نسيان تشغيل الـ Worker. العَرَض: صفوف تتراكم في جدول
jobsولا يصل بريد. الحل:queue:workمحليًا وSupervisor على الخادم. - تعديل الكود دون إعادة تشغيل الـ Worker. العَرَض: تعديلك لا يظهر أثره. الحل:
queue:restartبعد كل نشر، وqueue:listenأثناء التطوير. - تعديل
config/queue.phpبدل.env. يضيع التعديل مع أي تحديث، ويختلف بين البيئات. اضبطQUEUE_CONNECTIONفي البيئة ثم نفّذconfig:clear. - تجاهل جدول
failed_jobs. مهام تفشل بصمت دون أن يعلم أحد. أنشئ الجدول، وأضفfailed()، وراقب العدد. - جعل
timeoutأكبر منretry_after. النتيجة تنفيذ مزدوج ورسائل مكرّرة للمستخدم. - مهام غير قابلة لإعادة التنفيذ (Not idempotent). اكتب مهامك بحيث لا يسبب تنفيذها مرتين ضررًا — تحقق من الحالة قبل الخصم من رصيد أو إنشاء سجل.
- الاعتماد على
syncفي الإنتاج. يبدو أن كل شيء يعمل، لكنك في الواقع لم تؤجّل شيئًا وما زال المستخدم ينتظر. - حشو الـ payload ببيانات ضخمة. مرّر معرّفات لا كائنات كاملة، واستخدم
withoutRelations()عند الحاجة.
مرجع سريع لأوامر Artisan
| الأمر | الوظيفة |
|---|---|
make:queue-table |
إنشاء ترحيل جدول jobs. |
make:queue-failed-table |
إنشاء ترحيل جدول failed_jobs. |
make:queue-batches-table |
إنشاء ترحيل جدول job_batches. |
make:job JobName |
إنشاء كلاس مهمة جديد. |
queue:work |
تشغيل Worker للإنتاج. |
queue:listen |
تشغيل Worker يعيد تحميل الكود، للتطوير. |
queue:restart |
إعادة تشغيل جميع الـ Workers بهدوء بعد النشر. |
queue:failed |
عرض قائمة المهام الفاشلة. |
queue:retry all |
إعادة محاولة كل المهام الفاشلة. |
queue:forget {id} |
حذف مهمة فاشلة محددة. |
queue:flush |
حذف كل المهام الفاشلة. |
queue:clear |
تفريغ طابور من المهام المنتظرة. |
queue:monitor |
تنبيه عند تجاوز الطابور حجمًا معيّنًا. |
queue:pause / queue:continue |
إيقاف واستئناف التقاط المهام مؤقتًا. |
أسئلة شائعة
هل تجعل الطوابير موقعي أسرع فعلًا؟
تجعل الاستجابة أسرع، وهو ما يقيسه المستخدم ومحرك البحث. المهمة نفسها تستغرق المدة ذاتها لكنها خرجت من دورة الطلب.
database أم redis؟
ابدأ بـ database إن كان حجم مهامك محدودًا ولا تريد خدمة إضافية. انتقل إلى redis حين يصبح عدد المهام بالآلاف يوميًا أو حين تبدأ استعلامات الطابور بالتأثير على قاعدة البيانات.
لماذا يبقى جدول jobs ممتلئًا ولا يُنفَّذ شيء؟
في 90% من الحالات لا يوجد Worker يعمل. تحقق بـ ps aux | grep queue:work، أو راجع حالة Supervisor بـ sudo supervisorctl status.
كيف أختبر المهام في الـ Tests؟
استخدم Queue::fake() ثم تحقق بـ Queue::assertPushed(SendContactEmail::class) دون تنفيذ فعلي. وبديلًا عن ذلك، اضبط QUEUE_CONNECTION=sync في ملف phpunit.xml لتُنفَّذ المهام فورًا داخل الاختبار.
هل يمكن تشغيل الطوابير على استضافة مشتركة؟
غالبًا لا تسمح الاستضافات المشتركة بعمليات دائمة. الحلان العمليان: جدولة queue:work --stop-when-empty عبر Cron كل دقيقة، أو استخدام اتصال deferred الذي ينفّذ المهمة بعد إرسال الاستجابة مباشرة.
ما الفرق بين Job وQueued Listener؟
كلاهما ينفَّذ في الخلفية. الـ Job وحدة عمل مستقلة تستدعيها متى شئت، بينما الـ Listener مرتبط بحدث Event معيّن ويُستدعى تلقائيًا عند وقوعه. استخدم الأحداث حين يهمك «ماذا حدث»، والمهام حين يهمك «ماذا يجب أن يُنجَز».
الخلاصة
الطريق من الصفر إلى طابور يعمل على الإنتاج يمر بست خطوات:
- اضبط
QUEUE_CONNECTION=databaseفي.envثم نفّذconfig:clear. - جهّز الجداول:
make:queue-tableوmake:queue-failed-tableثمmigrate. - أنشئ المهمة بـ
make:jobواكتب منطقها داخلhandle(). - ادفعها من الـ Controller بـ
MyJob::dispatch($data). - شغّل الـ Worker:
queue:workمحليًا، وSupervisor على الخادم. - اضبط
triesوbackoffوtimeout، وأضفfailed()، وراقبfailed_jobs.
وأهم درس عملي في كل ما سبق: الطابور ليس ميزة تُفعّل، بل مكوّن يُدار. المهمة التي تُرسَل ولا تُنفَّذ أسوأ من مهمة لم تُؤجَّل أصلًا. تأكد دائمًا أن الـ Worker يعمل، وأن إعادة تشغيله جزء من عملية النشر، وأن أحدًا ما — أنت أو نظام تنبيه — يراقب المهام الفاشلة.
شغل عالي العال عاش نضال الشعب الفلسطيني
شكرًا لك على كلماتك الطيبة، وسعيد جدًا أن المحتوى وصل ونفع 🌿
شكرا يا غالي
العفو، ويسعدني أن المقال أفادك. لو واجهتك أي مشكلة أثناء التطبيق اكتبها هنا ونشوفها سوا.
عمل جميل
شكرًا لك 🙏 هذا النوع من التعليقات هو ما يشجّع على الاستمرار في الكتابة.
شرح مميز شكرا
شكرًا لك، وسعيد أن الشرح كان واضحًا. إن كان هناك جزء تحب لو تناولناه بتفصيل أكبر — مثل
HorizonأوJob Batching— أخبرني وسأضعه ضمن الخطة.شكرا على المقالات الرائعة لو سمحت في شي قائمة بأهم الاشياء الضرورية في أي موقع لنعملها ضمن queue يعني افضل اذا بتساعدنا و نقدر نحصر هذي الامور بمقالة او تعليق بتسهل علينا العمل و شكرا لحضرتك
سؤال ممتاز وفي محلّه، وهذه القائمة تستحق مقالًا مستقلًا سأعمل عليه قريبًا بإذن الله. لكن إليك الخلاصة العملية الآن:
القاعدة قبل القائمة: ضع المهمة في الطابور إذا كانت تستغرق وقتًا ملموسًا، و لا ينتظر المستخدم نتيجتها على الشاشة. إن اختلّ أحد الشرطين فالأفضل تنفيذها مباشرة.
1. المراسلات والإشعارات
2. الملفات والوسائط
3. الاتصال بالخدمات الخارجية
webhooksالواردة: استقبلها وأعد200فورًا، ثم عالج محتواها في مهمة.webhooksإلى أنظمة عملائك.4. العمليات الخلفية الدورية
ما لا يُنصح بوضعه في الطابور
نصيحة عملية أخيرة: افصل هذه الأنواع على طوابير مختلفة بدل وضعها كلها في
default، ثم امنح الأولوية للأهم:بهذا الشكل لا تتأخر رسالة استعادة كلمة المرور خلف تقرير يستغرق دقيقتين. وشكرًا على الاقتراح، سيكون موضوع المقال القادم.