عندما ينتظر زائر موقعك ست ثوانٍ بعد ضغط زر «إرسال» لمجرد أن التطبيق يحاول الاتصال بخادم بريد بطيء، فأنت لا تخسر ثوانٍ فقط، بل تخسر تجربة المستخدم وتخسر نقاطًا في مؤشرات الأداء التي تعتمد عليها محركات البحث. الحل في لارافيل هو طوابير الانتظار (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 في لارافيل
زمن انتظار يقارب ست ثوانٍ بين ضغط زر الإرسال وظهور رسالة النجاح.

ما الذي يغيّره استخدام Queue؟

نقطة مهمة يُساء فهمها كثيرًا: الطابور لا يجعل إرسال البريد أسرع. البريد سيستغرق نفس الثواني الست. ما يتغيّر هو أن هذه الثواني لم تعد داخل دورة الطلب: يستجيب التطبيق للمستخدم خلال أجزاء من الثانية، ويجري الإرسال في الخلفية دون أن يشعر به أحد.

متى تستخدم الطوابير؟

  • إرسال البريد الإلكتروني والإشعارات ورسائل SMS.
  • معالجة الوسائط: ضغط الفيديو، توليد الصور المصغّرة، تحويل الصيغ.
  • استيراد أو تصدير ملفات CSV وExcel الكبيرة.
  • الاتصال بواجهات برمجية خارجية بطيئة أو غير مستقرة (بوابات دفع، خدمات شحن).
  • توليد التقارير وملفات PDF الثقيلة.
  • تحديث فهارس البحث والإحصائيات بعد كل عملية.

متى لا تستخدمها؟

  • عندما يحتاج المستخدم نتيجة العملية فورًا على الشاشة (تسجيل الدخول، عملية دفع متزامنة).
  • عندما تكون المهمة أسرع من تكلفة وضعها في الطابور أصلًا (استعلام بسيط، حفظ سجل واحد).
  • عندما لا تملك بيئة تسمح بتشغيل عملية دائمة في الخلفية — فالطابور بلا Worker يعمل يعني مهام لا تُنفّذ أبدًا.

كيف تعمل الطوابير من الداخل؟

فهم دورة الحياة يوفّر عليك ساعات من التصحيح لاحقًا. الرحلة تمر بست مراحل:

  1. الإرسال (Dispatch): تستدعي MyJob::dispatch($data) داخل الـ Controller.
  2. التسلسل (Serialization): يحوّل لارافيل الكائن وبياناته إلى نص JSON يسمّى payload.
  3. التخزين: يُخزَّن الـ payload في صف جديد داخل جدول jobs (أو في قائمة داخل Redis).
  4. الاستجابة: ينتهي الطلب ويعود المستخدم إلى الصفحة فورًا.
  5. الالتقاط: عملية queue:work العاملة في الخلفية تسحب أقدم صف، وتحجزه بوضع طابع زمني في reserved_at حتى لا يلتقطه Worker آخر.
  6. التنفيذ: يُعاد بناء الكائن وتُنفَّذ دالة 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. إليك ما يقدّمه كل خيار:

مقارنة بين Queue Drivers في لارافيل
الـ 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: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، فارتفاعه المفاجئ يشير إلى عطل في خدمة تعتمد عليها.


أخطاء شائعة وكيف تتجنبها

  1. نسيان تشغيل الـ Worker. العَرَض: صفوف تتراكم في جدول jobs ولا يصل بريد. الحل: queue:work محليًا وSupervisor على الخادم.
  2. تعديل الكود دون إعادة تشغيل الـ Worker. العَرَض: تعديلك لا يظهر أثره. الحل: queue:restart بعد كل نشر، وqueue:listen أثناء التطوير.
  3. تعديل config/queue.php بدل .env. يضيع التعديل مع أي تحديث، ويختلف بين البيئات. اضبط QUEUE_CONNECTION في البيئة ثم نفّذ config:clear.
  4. تجاهل جدول failed_jobs. مهام تفشل بصمت دون أن يعلم أحد. أنشئ الجدول، وأضف failed()، وراقب العدد.
  5. جعل timeout أكبر من retry_after. النتيجة تنفيذ مزدوج ورسائل مكرّرة للمستخدم.
  6. مهام غير قابلة لإعادة التنفيذ (Not idempotent). اكتب مهامك بحيث لا يسبب تنفيذها مرتين ضررًا — تحقق من الحالة قبل الخصم من رصيد أو إنشاء سجل.
  7. الاعتماد على sync في الإنتاج. يبدو أن كل شيء يعمل، لكنك في الواقع لم تؤجّل شيئًا وما زال المستخدم ينتظر.
  8. حشو الـ 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 معيّن ويُستدعى تلقائيًا عند وقوعه. استخدم الأحداث حين يهمك «ماذا حدث»، والمهام حين يهمك «ماذا يجب أن يُنجَز».


الخلاصة

الطريق من الصفر إلى طابور يعمل على الإنتاج يمر بست خطوات:

  1. اضبط QUEUE_CONNECTION=database في .env ثم نفّذ config:clear.
  2. جهّز الجداول: make:queue-table وmake:queue-failed-table ثم migrate.
  3. أنشئ المهمة بـ make:job واكتب منطقها داخل handle().
  4. ادفعها من الـ Controller بـ MyJob::dispatch($data).
  5. شغّل الـ Worker: queue:work محليًا، وSupervisor على الخادم.
  6. اضبط tries وbackoff وtimeout، وأضف failed()، وراقب failed_jobs.

وأهم درس عملي في كل ما سبق: الطابور ليس ميزة تُفعّل، بل مكوّن يُدار. المهمة التي تُرسَل ولا تُنفَّذ أسوأ من مهمة لم تُؤجَّل أصلًا. تأكد دائمًا أن الـ Worker يعمل، وأن إعادة تشغيله جزء من عملية النشر، وأن أحدًا ما — أنت أو نظام تنبيه — يراقب المهام الفاشلة.