تجربتي في بناء نظام Events وListeners مخصص في Laravel
من بين أكثر الأشياء اللي جذبتني في إطار عمل Laravel منذ بداياتي معه، هو نظام الأحداث (Events) والمستمعين (Listeners). في البداية، كنت أستخدمه بشكل بسيط جدًا – أُطلِق حدثًا هنا، وأستمع له هناك، وينتهي الأمر. لكن مع تطور المشاريع اللي شاركت فيها، بدأت أشعر أن النظام الافتراضي، رغم قوته، لا يغطي كل السيناريوهات اللي أحتاجها. خاصةً في المشاريع الكبيرة أو اللي تتطلب مرونة عالية في إدارة التدفقات المنطقية. فقررت أن أجرب بناء نظام Events وListeners مخصص خاص بي، يُعيد تعريف العلاقة بين الأحداث والمستمعين، ويمنحني تحكمًا أعمق دون أن أضحي بأي من مزايا Laravel الأساسية.
في هذه المقالة، هأشاركك رحلتي بالتفصيل: من الفكرة الأولى، مرورًا بالتحديات التقنية، ووصولًا إلى النظام المخصص اللي وصلت له. سأشرح لك لماذا قررت الخروج عن المسار الافتراضي، وكيف صمّمت الهيكل الجديد، وما الأخطاء اللي وقعت فيها، وأهم الدروس اللي تعلمتها. كل هذا مع أمثلة عملية، أكواد فعلية، ونصائح تطبيقية تنفعك لو فكرت تعمل نفس التجربة في مشروعك.
لماذا احتجت لنظام مخصص في المقام الأول؟
النظام الافتراضي في Laravel لمعالجة الأحداث قوي جدًا. يسمح لك بإطلاق حدث (Event) من أي مكان في التطبيق، ثم يُشغّل كل الـ Listeners المرتبطين به تلقائيًا. هذا رائع في السيناريوهات البسيطة، لكنه يبدأ يُظهر قيوده عندما:
- تحتاج إلى ترتيب محدد جدًا لتنفيذ الـ Listeners (ليس فقط حسب الترتيب في المصفوفة).
- تريد أن يعتمد تنفيذ listener معين على نتيجة listener سابق.
- تحتاج إلى إعادة استخدام نفس المنطق في أماكن متعددة دون تكرار الكود.
- تريد تتبّع سير الأحداث (Event Flow) بشكل ديناميكي، أو حتى إيقافها في منتصف الطريق بناءً على شروط معينة.
في أحد المشاريع اللي عملت عليها، كان لدينا نظام لإنشاء طلبات شراء (Purchase Orders). كل طلب يمر بعدة مراحل: الإنشاء، المراجعة، الموافقة، التأكيد، ثم الإرسال للمورّد. في كل مرحلة، كانت هناك مجموعة من الإجراءات المطلوبة: إرسال إشعارات، تحديث السجلات، التحقق من الرصيد، إلخ. في البداية، كنا نطلق حدثًا مثل PurchaseOrderCreated، ونربط به عدة Listeners. المشكلة ظهرت لما بدأنا نحتاج أن بعض الـ Listeners لا يُنفَّذ إلا لو نجح listener سابق في مهمته. مثلاً، لا نرسل إشعارًا للمُراجِع إلا لو تم التحقق من صحة البيانات بنجاح.
حاولت في البداية استخدام الـ Queues مع الـ Chaining، لكنه كان معقدًا ويصعب صيانته. ثم فكرت: ليش ما نبني نظامًا يسمح بتعريف "مسار" (Pipeline) خاص بكل حدث؟ بحيث يكون لكل حدث سلسلة من الخطوات (Steps)، وكل خطوة تُمثّل listener، ويمكن أن تقرر إن كانت تمرر التحكم للخطوة التالية أو تتوقف.
الفرق بين النظام الافتراضي والمخصص
في النظام الافتراضي، العلاقة بين الحدث والـ Listeners علاقة "واحد إلى كثير"، وكل listener يعمل بشكل مستقل. أما في النظام المخصص، فالعلاقة أصبحت "واحد إلى سلسلة"، والـ Listeners (أو Steps كما سأسمّيها لاحقًا) تعمل كسلسلة متصلة، حيث يمكن لكل منها التأثير على استمرار العملية.
بالنسبة لي، هذا التحوّل غيّر طريقة تفكيري في بناء المنطق المعقد. بدل ما أفكّر في "ما الذي يجب أن يحدث عند إنشاء الطلب؟"، صرت أفكّر في "ما هي خطوات معالجة هذا الطلب، وما الشروط اللي تسمح بالانتقال من خطوة لأخرى؟".
التصميم الأولي: رسم خريطة الطريق
قبل أن ألمس أي كود، قضيت وقتًا في رسم هيكل النظام الجديد. كنت أعرف أنني أريد الحفاظ على توافق مع Laravel قدر الإمكان، علشان ما أضطرّش أعيد اختراع العجلة. فقررت أن أبني النظام الجديد كطبقة فوق النظام الحالي، مش بديلًا له.
العناصر الأساسية اللي حددتها كانت:
- Custom Event: كلاس يُمثل الحدث، لكنه يحتوي على معلومات إضافية مثل اسم المسار (pipeline) اللي يجب اتباعه.
- Step Interface: واجهة تحدد الشكل العام لأي خطوة في المسار.
- Pipeline Manager: مدير يُنظّم تنفيذ الخطوات بالترتيب، ويمرّر الحالة (state) من خطوة لأخرى.
- Step Registry: مكان مركزي لتسجيل الخطوات وربطها بالأحداث أو المسارات.
الهدف كان بسيط: أطلق حدثًا مخصصًا، ويُفعّل Pipeline Manager الذي يبدأ بتنفيذ سلسلة الخطوات المسجّلة لهذا الحدث، مع إمكانية التحكّم في التدفق داخل كل خطوة.
البدء بالواجهات: Step Interface
الخطوة الأولى في الكود كانت تعريف الواجهة اللي كل خطوة لازم تنفذها. الهدف منها توحيد الشكل العام لأي خطوة، علشان Pipeline Manager يعرف إزاي يتعامل معها.
الكود كان كالتالي:
namespace App\Events\Pipeline;
interface StepInterface
{
public function handle(array $payload, callable $next): array;
}
الوظيفة handle تستقبل حمولتين: $payload اللي يحتوي على البيانات الأساسية للحدث (مثل معرّف الطلب، بيانات المستخدم، إلخ)، و$next اللي هو callback يُمرّر التحكم للخطوة التالية.
المفتاح هنا هو أن كل خطوة مسؤولة عن استدعاء $next($payload) إذا أرادت الاستمرار. لو ما استدعاهاش، يبقى التدفق متوقف. هذا يعطي مرونة هائلة.
بناء Pipeline Manager: قلب النظام
بعد ما عرفت شكل الخطوة، جيت أبني المدير اللي ينظم تنفيذها. الفكرة مستوحاة من Pipeline في Laravel نفسه، لكن معدلة لتناسب سياق الأحداث.
الكود الأولي لـ PipelineManager كان بسيط:
namespace App\Events\Pipeline;
class PipelineManager
{
protected array $steps = [];
public function through(array $steps): self
{
$this->steps = $steps;
return $this;
}
public function send(array $payload): array
{
$pipeline = array_reduce(
array_reverse($this->steps),
function ($stack, $step) {
return function ($payload) use ($stack, $step) {
/** @var StepInterface $stepInstance */
$stepInstance = app($step);
return $stepInstance->handle($payload, $stack);
};
},
function ($payload) {
return $payload;
}
);
return $pipeline($payload);
}
}
لاحظ هنا استخدام array_reduce مع array_reverse علشان نبني سلسلة من الـ closures، كل واحدة تستدعي اللي بعدها. الفكرة مش جديدة، لكن تكييفها مع سياق الأحداث هو اللي خلاها فعّالة.
في البداية، واجهت مشكلة في حقن الـ dependencies داخل الخطوات. لأن كل خطوة بتُنشَأ باستخدام app($step)، فكل الـ dependencies بتُحقن تلقائيًا من Laravel Service Container، وهذا كان ممتاز.
اختبار أولي بسيط
عشان أتأكد أن النظام شغال، عملت تجربة صغيرة:
// Step 1
class ValidateDataStep implements StepInterface
{
public function handle(array $payload, callable $next): array
{
if (empty($payload['order_id'])) {
// توقف التدفق
return $payload;
}
return $next($payload);
}
}
// Step 2
class NotifyReviewerStep implements StepInterface
{
public function handle(array $payload, callable $next): array
{
// أرسل إشعار
\Log::info('Notification sent for order: ' . $payload['order_id']);
return $next($payload);
}
}
ثم في مكان ما في الكود:
$payload = ['order_id' => 123];
$steps = [ValidateDataStep::class, NotifyReviewerStep::class];
$result = (new PipelineManager)
->through($steps)
->send($payload);
لما جربت الكود، شفت في اللوغ أن الإشعار اتُرسل. ولما غيرت order_id لـ null، ما اتُرسلش. النظام شغال!
دمج النظام مع Events الافتراضية في Laravel
الخطوة التالية كانت الأهم: كيف أدمج هذا النظام الجديد مع طريقة إطلاق الأحداث في Laravel؟ عايز أقدر أستخدم event(new MyCustomEvent($data)) زي ما أنا معتاد، لكن يشتغل النظام الجديد تحته.
الحل كان في إنشاء كلاس حدث مخصص يرث من ShouldBroadcast أو ShouldQueue حسب الحاجة، ويحتوي على منطق تشغيل الـ Pipeline.
أنشأت كلاس أساسي اسمه PipelineEvent:
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use App\Events\Pipeline\PipelineManager;
abstract class PipelineEvent
{
use Dispatchable;
abstract public function getSteps(): array;
abstract public function getPayload(): array;
public function handle()
{
return (new PipelineManager)
->through($this->getSteps())
->send($this->getPayload());
}
}
الآن، أي حدث جديد أبنيه، يرث من هذا الكلاس، ويُعرّف الخطوات والحمولة:
class PurchaseOrderCreated extends PipelineEvent
{
protected $order;
public function __construct($order)
{
$this->order = $order;
}
public function getSteps(): array
{
return [
ValidateOrderDataStep::class,
CheckInventoryStep::class,
NotifyReviewerStep::class,
LogOrderCreationStep::class,
];
}
public function getPayload(): array
{
return [
'order_id' => $this->order->id,
'user_id' => $this->order->user_id,
'items' => $this->order->items->toArray(),
];
}
}
لكن لازم ننسّق مع Laravel علشان يشغّل دالة handle لما يُطلَق الحدث. هنا جات الفكرة الذكية: استخدام EventServiceProvider لتوجيه الأحداث المخصصة لـ listener عام.
في ملف EventServiceProvider:
protected $listen = [
PurchaseOrderCreated::class => [
ProcessPipelineEventListener::class,
],
];
وأنشأت listener عام اسمه ProcessPipelineEventListener:
namespace App\Listeners;
class ProcessPipelineEventListener
{
public function handle($event)
{
if (method_exists($event, 'handle')) {
return $event->handle();
}
}
}
بهذا الشكل، كل ما أطلق حدثًا يرث من PipelineEvent، يُوجّه تلقائيًا لهذا الـ listener، اللي بدوره يشغّل الـ Pipeline. النظام دايمًا متوافق مع Laravel، ولا يكسر أي مبدأ من مبادئه.
إضافة ميزات متقدمة: التحكم في التدفق والتعامل مع الأخطاء
بعد ما شغّلت النظام الأساسي، بدأت أضيف ميزات تساعدني في المشاريع الحقيقية. أهمها:
1. إيقاف التدفق مع رسالة خطأ
في بعض الحالات، مش بس أوقف التدفق، لكن أريد أعرف ليش توقف. فعدّلت واجهة الـ Step علشان تدعم إرجاع حالة خطأ:
interface StepInterface
{
public function handle(array $payload, callable $next): array;
}
بقيت أستخدم استثناءات (Exceptions) للتعامل مع الأخطاء الحرجة:
class ValidateOrderDataStep implements StepInterface
{
public function handle(array $payload, callable $next): array
{
if (empty($payload['order_id'])) {
throw new InvalidOrderDataException('Order ID is required');
}
return $next($payload);
}
}
وبعدين في الـ listener العام:
public function handle($event)
{
try {
if (method_exists($event, 'handle')) {
return $event->handle();
}
} catch (\Exception $e) {
\Log::error('Pipeline failed: ' . $e->getMessage());
// هنا ممكن تبعت إشعار للمطور أو تحفظ الخطأ في قاعدة البيانات
throw $e; // أو تتعامل معه حسب سياسة المشروع
}
}
2. دعم الخطوات المشروطة (Conditional Steps)
في بعض الأحداث، مش كل الخطوات تنفذ دايمًا. مثلاً، لو الطلب من نوع معين، نطبّق خطوات إضافية. فأنشأت كلاس ConditionalStep:
abstract class ConditionalStep implements StepInterface
{
abstract public function condition(array $payload): bool;
public function handle(array $payload, callable $next): array
{
if ($this->condition($payload)) {
return $this->process($payload, $next);
}
return $next($payload);
}
abstract protected function process(array $payload, callable $next): array;
}
مثال على استخدامه:
class SendPremiumNotificationStep extends ConditionalStep
{
public function condition(array $payload): bool
{
return $payload['is_premium'] ?? false;
}
protected function process(array $payload, callable $next): array
{
// أرسل إشعار خاص للعملاء المميزين
return $next($payload);
}
}
3. تتبع الأداء (Performance Tracking)
في المشاريع الكبيرة، كنت أحتاج أعرف كم من الوقت تستغرق كل خطوة. فعدّلت الـ PipelineManager علشان يدعم الـ middleware:
class PipelineManager
{
// ... الكود السابق
public function send(array $payload): array
{
$pipeline = array_reduce(
array_reverse($this->steps),
function ($stack, $step) {
return function ($payload) use ($stack, $step) {
$start = microtime(true);
/** @var StepInterface $stepInstance */
$stepInstance = app($step);
$result = $stepInstance->handle($payload, $stack);
$duration = (microtime(true) - $start) * 1000;
\Log::debug("Step {$step} took {$duration}ms");
return $result;
};
},
function ($payload) {
return $payload;
}
);
return $pipeline($payload);
}
}
طبعًا، في الإنتاج، كنت أخلي التتبع ده يشتغل بس لو config('app.debug') true، علشان ما يأثرش على الأداء.
التعامل مع الـ Queues: تشغيل الخطوات بشكل غير متزامن
واحدة من أقوى ميزات Laravel هي دعم الـ Queues. في النظام الجديد، حابب أقدر أخلي بعض الخطوات تشتغل في الخلفية، خاصةً اللي بتستغرق وقت (مثل إرسال إيميلات أو معالجة ملفات).
الحل كان بسيط: أنشأت كلاس QueuedStep يرث من StepInterface، ويحتوي على منطق إرسال نفسه للـ queue:
abstract class QueuedStep implements ShouldQueue, StepInterface
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
abstract public function queuedHandle(array $payload): array;
public function handle(array $payload, callable $next): array
{
// أرسل الخطوة للـ queue، وارجع payload كما هو
$this->dispatchNow($payload);
return $next($payload);
}
public function __invoke(array $payload)
{
return $this->queuedHandle($payload);
}
}
الفرق هنا أن handle الأساسية ما بتشتغلش المنطق، بس بترسل المهمة للـ queue، وتشغل next مباشرةً. بينما queuedHandle هي اللي بتشتغل في الـ queue.
مثال:
class SendEmailConfirmationStep extends QueuedStep
{
public function queuedHandle(array $payload): array
{
\Mail::to($payload['email'])->send(new OrderConfirmationMail($payload));
return $payload;
}
}
بهذا الشكل، الخطوة دي هتشتغل في الخلفية، وما تأثرش على سرعة استجابة الطلب الأصلي.
تنظيم الكود: هيكلة المجلدات والتسجيل التلقائي
مع تزايد عدد الخطوات والأحداث، بدأ الكود يصير فوضوي. فقررت أعمل هيكلة واضحة:
app/ ├── Events/ │ ├── Pipeline/ │ │ ├── StepInterface.php │ │ ├── PipelineManager.php │ │ └── Steps/ │ │ ├── PurchaseOrders/ │ │ │ ├── ValidateOrderDataStep.php │ │ │ └── ... │ │ └── Users/ │ │ └── ... │ └── PurchaseOrderCreated.php ├── Listeners/ │ └── ProcessPipelineEventListener.php
وعلشان ما أضطرّش أسجّل كل خطوة يدويًا، عملت نظام تسجيل تلقائي باستخدام Laravel's auto-discovery. لكن لأن الخطوات مش Listeners، فاستخدمت Service Provider مخصص:
class PipelineServiceProvider extends ServiceProvider
{
public function boot()
{
// تسجيل تلقائي للخطوات لو احتجت في المستقبل
}
public function register()
{
// ممكن تستخدم هذا المكان لربط الـ PipelineManager كـ singleton
$this->app->singleton(PipelineManager::class, function () {
return new PipelineManager();
});
}
}
في الحقيقة، ما احتجتش تسجيل تلقائي للخطوات، لأن كل حدث بيحدد خطواته بنفسه. لكن وجود Service Provider خلا الكود أنظف، وسمح لي بحقن الـ PipelineManager في أي مكان بسهولة.
التحديات اللي واجهتني والدروس اللي تعلمتها
ما كانش الطريق سهل. واجهت عدة تحديات جعلتني أعيد التفكير في التصميم أكثر من مرة.
1. التعقيد الزائد في البداية
في أول محاولة، حاولت أبني نظامًا يدعم كل شيء: التحكم في التدفق، الشروط، الـ queues، التتبع، التراجع (rollback)... إلخ. النتيجة؟ نظام معقد جدًا، وصعب الصيانة، وما حد في الفريق فهمه. تعلمت أن أبدأ بأبسط حاجة ممكنة، وأضيف الميزات حسب الحاجة الفعلية، مش حسب التوقعات.
2. اختبار النظام
اختبار الـ Pipelines كان صعب في البداية. كيف أتأكد أن كل خطوة اتنفذت بالترتيب الصحيح؟ الحل كان في استخدام Mocks وAssertions ذكية:
public function test_purchase_order_pipeline_executes_correctly()
{
$payload = ['order_id' => 1, 'is_premium' => true];
$steps = [
ValidateOrderDataStep::class,
SendPremiumNotificationStep::class,
];
$result = (new PipelineManager)->through($steps)->send($payload);
// تأكد أن النتيجة تحتوي على البيانات المتوقعة
$this->assertArrayHasKey('order_id', $result);
}
وعلشان أتأكد من ترتيب التنفيذ، استخدمت Log أو Event خاص للـ testing:
class TestableStep implements StepInterface
{
public static $executed = false;
public function handle(array $payload, callable $next): array
{
self::$executed = true;
return $next($payload);
}
}
3. الأداء في السيناريوهات الكبيرة
لما زاد عدد الخطوات لأكثر من 20 في حدث واحد، لاحظت بطء بسيط. السبب كان في إنشاء instance جديد لكل خطوة في كل مرة. الحل كان بسيط: استخدام app()->makeShared() بدل app() لو الخطوة stateless. لكن في النهاية، قررت أقسّم الأحداث الكبيرة لعدة أحداث أصغر، كل واحد مسؤول عن جزء من المنطق. هذا كان أنظف من ناحية التصميم، وأسرع من ناحية الأداء.
مقارنة عملية: قبل النظام المخصص وبعده
في المشروع اللي ذكرته سابقًا (نظام طلبات الشراء)، كان الكود قبل النظام المخصص كالتالي:
// في PurchaseOrderController
public function store(Request $request)
{
$order = PurchaseOrder::create($request->all());
// التحقق من البيانات
if (! $this->validateOrder($order)) {
return response()->error('Invalid data');
}
// التحقق من المخزون
if (! $this->checkInventory($order)) {
return response()->error('Insufficient inventory');
}
// إرسال إشعار
$this->notifyReviewer($order);
// تسجيل العملية
$this->logActivity($order);
return response()->success($order);
}
الكود ده فيه مشاكل كتير: منطق متشابك، صعب التعديل، ما ينفعش إعادة الاستخدام، وما فيهوش تتبع مركزي.
بعد تطبيق النظام المخصص:
// في PurchaseOrderController
public function store(Request $request)
{
$order = PurchaseOrder::create($request->all());
event(new PurchaseOrderCreated($order));
return response()->success($order);
}
كل المنطق اتنقل للـ Steps، وصار كل جزء مسؤول عن مهمة واحدة. التعديل بقى أسهل: لو حابب أضيف خطوة جديدة، بس أضيفها في getSteps(). لو حابب أغير ترتيب الخطوات، بس أرتّب المصفوفة. لو حابب أوقف خطوة مؤقتًا، أشيلها من القائمة.
بالنسبة لي، الفرق كان كبير جدًا في جودة الكود وسهولة الصيانة.
نصائح نهائية لو فكرت تبني نظامك المخصص
بناء نظام Events وListeners مخصص في Laravel تجربة مفيدة جدًا، لكنها مش مناسبة لكل مشروع. إليك بعض النصائح اللي أتمنى حد قالها لي في البداية:
- ابدأ صغير: لا تحاول بناء نظام شامل من أول مرة. ابدأ بحل مشكلة واحدة محددة، ثم وسّعه تدريجيًا.
- احتفظ بالتوافق مع Laravel: استخدم EventServiceProvider، Service Container، والـ Queues كما هي. ما تبتكرش حلول بديلة لمجرد أنها "مخصصة".
- وثّق النظام جيدًا: لو شغال في فريق، لازم يكون في دوك يشرح إزاي يُستخدم النظام الجديد، وإزاي يُضاف خطوة جديدة.
- اختبره جيدًا: الـ Pipelines بتميل للتعقيد، فخلي لديك اختبارات تغطي السيناريوهات المختلفة، خاصةً حالات الفشل.
- لا تبالغ: لو المنطق بسيط، استخدم النظام الافتراضي. التخصيص مش هدف بذاته، ده وسيلة لحل مشكلة حقيقية.
الخاتمة: هل يستحق الأمر العناء؟
بعد كل التجربة دي، أقدر أقول بثقة: نعم، يستحق. ليس لأن النظام المخصص أفضل من النظام الافتراضي، لكن لأنه يعطيك مرونة لحل مشاكل معينة بطريقة أنظف وأكثر استدامة.
النظام اللي بنيته ما كانش بديلًا لـ Laravel Events، كان امتدادًا ذكيًا ليه. حافظت على كل مزايا Laravel، وزدت عليها طبقة من التنظيم والتحكم تناسب احتياجات المشروع اللي شغال عليه.
إذا كنت تشعر أنك تعيد نفس المنطق في عشرات الـ Listeners، أو أن ترتيب التنفيذ بقى مصدر قلق دائم، فجرب تبني نظامًا مخصصًا بسيط. ابدأ بخطوة واحدة، جربها، عدّلها، ووسّعها. حتى لو ما استخدمتهش في النهاية، هتتعلم أشياء كتير عن تصميم الأنظمة وفصل الاهتمامات.
في النهاية، Laravel إطار عمل مرن جدًا، ومصمم علشان يتكيف مع احتياجاتك، مش العكس. لا تخف من أن تبتكر فوقه، طالما أنك تحترم مبادئه الأساسية.
جرب، خطّط، نفّذ، وشارك تجربتك. لأن أفضل طريقة لتعلّم Laravel هي أن تبني فوقه، مش أن تستخدمه فقط.
