نظام الأحداث والبث في Laravel
نظام الأحداث هو "شبكة الاتصالات" في Laravel — يبث المرسل رسالة، ويتعامل كل مستقبل بها بشكل مستقل؛ لا يحتاج المرسل إلى معرفة من يستمع.
1. ما ستتعلمه
- الأحداث والمستمعات: تسجيل EventServiceProvider والاكتشاف التلقائي
- جدولة الأحداث: event() مقابل Event::dispatch()
- آلية البث: Redis Pub/Sub + Laravel Echo + Pusher
- نوع القناة: قناة عامة/خاصة/حضور
- الاستقبال في الواجهة الأمامية: Laravel Echo + إشعارات WebSocket في الوقت الفعلي
2. قصة حقيقية لمدير منتج
(1) مشكلة: يجب تحديث الصفحة يدويًا لرؤية تغييرات حالة الطلب
تدير Alice الطلبات في لوحة تحكم ShopMetrics — لا يمكنها رؤيتها فورًا بعد أن يقدم العملاء طلباتها وتضطر إلى تحديث الصفحة يدويًا كل 5 دقائق. Bob أسوأ حالًا؛ فهو يدير ثلاثة متاجر في نفس الوقت، ولا يستطيع مواكبة التحديث، وفاته خمسة طلبات عاجلة. قال Charlie: "نحن في 2024 وما زلنا نحدث يدويًا؟ هل سمعتم عن إشعارات WebSocket في الوقت الفعلي؟"
(2) حل بث الأحداث
بث أحداث Laravel — يتم تفعيل حدث عند إنشاء طلب؛ يدفع الخادم الحدث إلى الواجهة الأمامية عبر WebSocket، وتحدث صفحة Alice تلقائيًا بتأخير صفري.
// تقديم طلب → إرسال حدث → بث WebSocket
event(new OrderPlaced($order));
// متصفح Alice يستقبل الإشعار في الوقت الفعلي
(3) العائد
مع إشعارات Alice في الوقت الفعلي، تظهر الطلبات الجديدة على لوحة المعلومات خلال 0.5 ثانية، لذلك لن تفوتك طلبًا بعد الآن.
3. الأحداث والمستمعات
(1) إنشاء الأحداث والمستمعات
php artisan make:event OrderPlaced
php artisan make:listener SendOrderNotification --event=OrderPlaced
php artisan make:listener UpdateShopRevenue --event=OrderPlaced
php artisan make:listener SendOrderWebhook --event=OrderPlaced
(2) فئة الحدث
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
new PrivateChannel('shop.' . $this->order->shop_id),
];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'shop_name' => $this->order->shop->name,
'customer_name' => $this->order->user->name,
];
}
public function broadcastAs(): string
{
return 'order.placed';
}
}
(3) فئات المستمع
// app/Listeners/SendOrderNotification.php
class SendOrderNotification
{
public function handle(OrderPlaced $event): void
{
$order = $event->order;
// إرسال إشعار بريد إلكتروني لصاحب المتجر
$order->shop->tenant->users()
->where('role', 'tenant_owner')
->each(fn ($user) => $user->notify(new OrderCreatedNotification($order)));
}
}
// app/Listeners/UpdateShopRevenue.php
class UpdateShopRevenue
{
public function handle(OrderPlaced $event): void
{
$event->order->shop->increment('revenue', $event->order->total);
}
}
(4) تسجيل مستمع حدث
// app/Providers/EventServiceProvider.php
protected $listen = [
OrderPlaced::class => [
SendOrderNotification::class,
UpdateShopRevenue::class,
SendOrderWebhook::class,
],
OrderStatusChanged::class => [
SendStatusChangeNotification::class,
],
];
(1) ▶ مثال:تفعيل حدث طلب ShopMetrics
// app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request): RedirectResponse
{
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
foreach ($request->items as $item) {
$order->items()->create($item);
}
$order->updateTotal();
return $order;
});
// إرسال حدث — يفعل جميع المستمعات + البث
event(new OrderPlaced($order));
return redirect()->route('orders.show', $order)
->with('success', 'Order placed!');
}
الناتج:
// التنفيذ ناجح
4. جدولة الأحداث
(1) طريقة الجدولة
// الطريقة 1: مساعد event() (موصى به)
event(new OrderPlaced($order));
// الطريقة 2: Event facade
Event::dispatch(new OrderPlaced($order));
// الطريقة 3: إرسال ثابت على فئة الحدث
OrderPlaced::dispatch($order);
(2) مستمعات متزامنة مقابل غير متزامنة
// مستمع متزامن — يعمل في دورة الطلب
class UpdateShopRevenue implements ShouldHandleEventsAfterCommit
{
public function handle(OrderPlaced $event): void
{
$event->order->shop->increment('revenue', $event->order->total);
}
}
// مستمع غير متزامن — يُدفع إلى الطابور
class SendOrderWebhook implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
public int $backoff = 30;
public function handle(OrderPlaced $event): void
{
Http::post($event->order->shop->webhook_url, [
'event' => 'order.placed',
'data' => new OrderResource($event->order),
]);
}
}
| النوع | الواجهة | طريقة التنفيذ | السيناريوهات المناسبة |
|---|---|---|---|
| متزامن | لا شيء | تنفيذ متسلسل داخل الطلب | تحديث قاعدة البيانات |
| غير متزامن | ShouldQueue | إدراج في الطابور للتنفيذ غير المتزامن | إرسال بريد إلكتروني/Webhook |
| بعد المعاملة | AfterCommit | التنفيذ بعد الالتزام بالمعاملة | يعتمد على البيانات المستمرة |
(1) ▶ مثال:أحداث ShopMetrics وتسجيل المستمعات
// app/Providers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
// أحداث الطلبات
OrderPlaced::class => [
UpdateShopRevenue::class, // متزامن — تحديث الإحصائيات
SendOrderNotification::class, // غير متزامن — إرسال بريد إلكتروني
SendOrderWebhook::class, // غير متزامن — استدعاء webhook
],
OrderStatusChanged::class => [
SendStatusNotification::class, // غير متزامن
UpdateAnalyticsCache::class, // متزامن — مسح التخزين المؤقت
],
SubscriptionCreated::class => [
SendWelcomeEmail::class, // غير متزامن
ProvisionTenantResources::class, // غير متزامن
],
];
}
الناتج:
// التنفيذ ناجح
5. آلية البث
(1) بنية البث
sequenceDiagram
participant S as خادم
participant E as حدث
participant B as مبث
participant WS as خادم WebSocket
participant C as عميل (Echo)
S->>E: event(new OrderPlaced($order))
E->>B: broadcastOn() → قنوات
B->>WS: نشر إلى قناة Redis
WS->>C: دفع عبر WebSocket
C->>C: Echo يستقبل ويحدث واجهة المستخدم
(2) تكوين البث
# .env
BROADCAST_CONNECTION=redis
QUEUE_CONNECTION=redis
# تثبيت التبعيات
composer require pusher/pusher-php-server
# أو لـ Redis:
# predis/predis مثبت بالفعل
// config/broadcasting.php
'default' => env('BROADCAST_CONNECTION', 'redis'),
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
],
(3) مقارنة مشغلات البث
| المشغلات | الخدمات | مستضاف ذاتيًا | الأداء | التكلفة |
|---|---|---|---|---|
| Pusher | Pusher Cloud | ❌ | عالي | مدفوع |
| Redis | Redis + Laravel Echo Server | ✅ | عالي | مجاني |
| Ably | Ably Cloud | ❌ | عالي | مدفوع |
| Log | سجل (للتطوير) | ✅ | — | مجاني |
(1) ▶ مثال:تعريف أحداث بث ShopMetrics
// app/Events/OrderStatusChanged.php
class OrderStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
public string $oldStatus,
public string $newStatus,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
'updated_at' => $this->order->updated_at->toISOString(),
];
}
public function broadcastAs(): string
{
return 'order.status_changed';
}
}
الناتج:
// التنفيذ ناجح
6. أنواع القنوات والتصريح
(1) ثلاثة أنواع من القنوات
| القناة | البادئة | الرؤية | الغرض |
|---|---|---|---|
| عامة | channel- |
الجميع | الإعلانات، إشعارات على مستوى الموقع |
| خاصة | private- |
مستخدم مصرح له | حصري للمستأجر/المستخدم |
| حضور | presence- |
تصريح + قائمة متصلين | التعاون، الدردشة |
(2) تصريح القناة
// routes/channels.php
use Illuminate\Support\Facades\Broadcast;
// قناة خاصة — فقط أعضاء المستأجر يمكنهم الاستماع
Broadcast::channel('tenant.{tenantId}', function ($user, $tenantId) {
return $user->tenant_id === (int) $tenantId;
});
// قناة خاصة — فقط صاحب/محلل المتجر
Broadcast::channel('shop.{shopId}', function ($user, $shopId) {
$shop = Shop::find($shopId);
return $shop && $user->tenant_id === $shop->tenant_id;
});
// قناة حضور — من متصل
Broadcast::channel('shop.dashboard.{shopId}', function ($user, $shopId) {
if ($user->tenant_id === Shop::find($shopId)?->tenant_id) {
return ['id' => $user->id, 'name' => $user->name, 'role' => $user->role];
}
});
(1) ▶ مثال:تصريح قنوات ShopMetrics
// routes/channels.php
Broadcast::channel('tenant.{tenantId}', function ($user, $tenantId) {
return $user->tenant_id === (int) $tenantId
&& $user->tenant->status === 'active';
});
Broadcast::channel('shop.{shopId}', function ($user, $shopId) {
$shop = Shop::find($shopId);
if (!$shop || $user->tenant_id !== $shop->tenant_id) {
return false;
}
return ['id' => $user->id, 'name' => $user->name];
});
Broadcast::channel('notifications.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
الناتج:
// التنفيذ ناجح
7. الاستقبال في الواجهة الأمامية
(1) تثبيت Laravel Echo
npm install laravel-echo pusher-js
# أو لـ Redis:
npm install laravel-echo-connector socket.io-client
(2) تكوين Echo
// resources/js/app.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_APP_KEY,
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
wsHost: import.meta.env.VITE_PUSHER_HOST,
wsPort: import.meta.env.VITE_PUSHER_PORT ?? 6001,
forceTLS: false,
enabledTransports: ['ws'],
});
(3) الاستماع للأحداث
// الاستماع على قناة خاصة — أحداث خاصة بالمستأجر
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (e) => {
showToast(`طلب جديد: ${e.order_number} — $${e.total}`);
updateOrdersList(e);
})
.listen('.order.status_changed', (e) => {
updateOrderStatus(e.order_id, e.new_status);
});
// الاستماع على قناة حضور — معرفة من متصل
window.Echo.join(`shop.dashboard.${shopId}`)
.here((users) => {
updateOnlineUsers(users);
})
.joining((user) => {
addOnlineUser(user);
})
.leaving((user) => {
removeOnlineUser(user);
});
(1) ▶ مثال:لوحة معلومات ShopMetrics في الوقت الفعلي
<!-- resources/views/dashboard/index.blade.php -->
<script>
const tenantId = {{ auth()->user()->tenant_id }};
// تهيئة Echo
window.Echo.private(`tenant.${tenantId}`)
.listen('.order.placed', (event) => {
// تحديث الإحصائيات
const stats = document.getElementById('stats');
const orderCount = stats.querySelector('.order-count');
orderCount.textContent = parseInt(orderCount.textContent) + 1;
// إضافة إلى الطلبات الأخيرة
const list = document.getElementById('recent-orders');
list.insertAdjacentHTML('afterbegin', `
<tr class="bg-green-50">
<td>${event.order_number}</td>
<td>${event.shop_name}</td>
<td>$${event.total.toFixed(2)}</td>
<td><span class="badge-blue">جديد</span></td>
</tr>
`);
// عرض الإشعار
showNotification(`طلب جديد من ${event.customer_name}: $${event.total}`);
})
.listen('.order.status_changed', (event) => {
const row = document.querySelector(`[data-order="${event.order_id}"]`);
if (row) {
row.querySelector('.status-badge').textContent = event.new_status;
row.querySelector('.status-badge').className = `status-badge badge-${event.new_status}`;
}
});
</script>
الناتج:
// التنفيذ ناجح
8. مثال شامل: نظام إشعارات ShopMetrics في الوقت الفعلي
// ============================================
// شامل: إشعارات ShopMetrics في الوقت الفعلي
// يغطي: الأحداث، المستمعات، البث، القنوات، Echo
// ============================================
// app/Events/OrderPlaced.php
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('tenant.' . $this->order->tenant_id),
new PrivateChannel('shop.' . $this->order->shop_id),
];
}
public function broadcastWith(): array
{
$this->order->load('shop', 'user');
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'total' => (float) $this->order->total,
'status' => $this->order->status,
'shop' => ['id' => $this->order->shop->id, 'name' => $this->order->shop->name],
'customer' => ['id' => $this->order->user->id, 'name' => $this->order->user->name],
'created_at' => $this->order->created_at->toISOString(),
];
}
public function broadcastAs(): string { return 'order.placed'; }
}
// app/Events/OrderStatusChanged.php
class OrderStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order,
public string $oldStatus,
public string $newStatus,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel('tenant.' . $this->order->tenant_id)];
}
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'order_number' => $this->order->order_number,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
];
}
public function broadcastAs(): string { return 'order.status_changed'; }
}
// التفعيل في فئة الخدمة
class OrderService
{
public function place(array $data): Order
{
$order = DB::transaction(function () use ($data) {
$order = Order::create($data);
// ... إنشاء العناصر، حساب الإجمالي
return $order;
});
event(new OrderPlaced($order));
return $order;
}
public function changeStatus(Order $order, string $newStatus): Order
{
$oldStatus = $order->status;
$order->update(['status' => $newStatus]);
event(new OrderStatusChanged($order, $oldStatus, $newStatus));
return $order;
}
}
❓ أسئلة شائعة
broadcastWith() لإرسال الحقول الضرورية فقط (ID + معلومات رئيسية)؛ بمجرد استلام الواجهة الأمامية، يمكنها استخدام AJAX لجلب البيانات الكاملة. تجنب تضمين عدد كبير من الموارد المرتبطة داخل بيانات البث.Event::fake() لمحاكاة إرسال الأحداث، وأكد أن الحدث تم إرساله: Event::assertDispatched(OrderPlaced::class). اكتب اختبارات وحدة منفصلة للمستمعات.📖 ملخص
- يفصل بين مرسلي ومستقبلي الأحداث؛ يمكن إضافة ميزات جديدة ببساطة بإضافة مستمع
- المستمع ShouldQueue ينفذ بشكل غير متزامن ولا يحظر الطلبات
- واجهة ShouldBroadcast تسمح ببث الأحداث عبر WebSocket
- القنوات الخاصة تتطلب تصريحًا؛ قنوات الحضور تعرض أيضًا قائمة المستخدمين المتصلين حاليًا
- مكتبة Laravel Echo للواجهة الأمامية تتعامل مركزيًا مع اتصالات WebSocket والاستماع للأحداث
- broadcastWith() يتحكم في كمية البيانات المبثة، وينقل الحقول الضرورية فقط
📝 تمارين
-
تمرين أساسي (⭐): أنشئ حدث
OrderPlacedومستمعSendOrderNotification، الذي يُفعل عند إنشاء طلب. استخدمEvent::fake()لكتابة اختبار للتحقق من إرسال الحدث. -
تمرين متقدم (⭐⭐): نفذ بث
ShouldBroadcastلحدثOrderPlaced، وقم بتكوين مشغل بث Redis، واستخدم Laravel Echo في الواجهة الأمامية للاستماع إلى قناةprivate-tenant.{id}لعرض إشعارات فورية للطلبات الجديدة. -
تحدي (⭐⭐⭐): نفذ لوحة معلومات تعاونية متعددة المستخدمين باستخدام قناة الحضور — عندما يشاهد عدة مستخدمين بيانات نفس المتجر في نفس الوقت، اعرض قائمة المستخدمين المتصلين؛ عندما يعدل مستخدم واحد البيانات، يرى المستخدمون الآخرون التغييرات في الوقت الفعلي (بث تغييرات حالة الطلب).



