ارتباطات Eloquent في Laravel
الارتباطات هي "القوة الخارقة" لـ Eloquent — بسطر واحد من الكود، يمكنك ربط الجداول عبر المفاتيح الأجنبية والوداع لـ JOINs اليدوية.
1. ما ستتعلمه
- واحد لواحد: hasOne/belongsTo
- واحد لكثير: hasMany/belongsTo
- كثير لكثير: belongsToMany (بما في ذلك جدول محوري)
- HasManyThrough: ارتباطات بعيدة وارتباطات متعددة الأشكال
- التحميل المسبق والتحميل الكسول: مشكلة N+1 والتحسين باستخدام
with()/load()
2. قصة حقيقعة لفريق تحليلات بيانات
(1) المشكلة: 50 استعلامًا فقط لعرض 10 طلبات
يرى Charlie 10 طلبات على لوحة تحكم ShopMetrics — يشغل استعلامًا واحدًا لاسترداد الطلبات، ثم لكل طلب، يشغل استعلامًا للمستخدم وواحدًا للمتجر وواحدًا للمنتج، بإجمالي 1 + 10 x 4 = 41 استعلام SQL. ارتفع حمل قاعدة البيانات بشكل كبير، وانتقل وقت تحميل الصفحة من 200 مللي ثانية إلى 3 ثوانٍ. أضاف Bob المزيد من الوصلات (طلب ← منتج ← فئة ← وسم)، مما رفع عدد الاستعلامات إلى أكثر من 200، وشكت Alice أن النظام "بطيء كالحلزون".
(2) حلول التحميل المسبق
يسترد التحميل المسبق with() في Eloquent جميع البيانات المرتبطة في استعلام واحد، مما يقلل 41 عبارة SQL إلى 4.
// مشكلة N+1 — 41 استعلامًا
$orders = Order::take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // +1 استعلام لكل طلب
echo $order->shop->name; // +1 استعلام لكل طلب
}
// تحميل مسبق — 4 استعلامات إجمالًا
$orders = Order::with(['user', 'shop', 'items.product'])->take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // 0 استعلامات إضافية
echo $order->shop->name; // 0 استعلامات إضافية
}
(3) العائد
بعد أن نفذ Charlie التحميل المسبق، انخفض عدد استعلامات لوحة التحكم من 41 إلى 4، وانخفض وقت تحميل الصفحة من 3 ثوانٍ إلى 300 مللي ثانية.
3. ارتباط واحد لواحد
(1) hasOne / belongsTo
// المستخدم لديه ملف شخصي واحد
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
// الملف الشخصي ينتمي لمستخدم
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// الاستخدام
$profile = $user->profile;
$user = $profile->user;
| الاتجاه | الطريقة | موقع المفتاح الأجنبي | الوصف |
|---|---|---|---|
| المستخدم ← الملف الشخصي | hasOne | جدول profiles | يملك |
| الملف الشخصي ← المستخدم | belongsTo | جدول profiles | ينتمي إلى |
(1) ▶ مثال: مستخدمو ShopMetrics وخطط الاشتراك
// المستخدم لديه اشتراك نشط واحد
class User extends Model
{
public function activeSubscription(): HasOne
{
return $this->hasOne(Subscription::class)
->where('status', 'active')
->latestOfMany();
}
}
// الاشتراك ينتمي لمستخدم
class Subscription extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// الاستخدام
$plan = $user->activeSubscription->plan;
الناتج:
// تم التنفيذ بنجاح
4. ارتباط واحد لكثير
(1) hasMany / belongsTo
// المستأجر لديه عدة متاجر
class Tenant extends Model
{
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// المتجر ينتمي لمستأجر
class Shop extends Model
{
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
(2) مخطط UML لارتباطات العلاقات السبع الرئيسية
classDiagram
class Tenant {
+shops() HasMany
+orders() HasMany
+users() HasMany
+subscription() HasOne
}
class User {
+tenant() BelongsTo
+profile() HasOne
+orders() HasMany
}
class Shop {
+tenant() BelongsTo
+products() HasMany
+orders() HasMany
}
class Product {
+shop() BelongsTo
+categories() BelongsToMany
}
class Category {
+products() BelongsToMany
}
class Order {
+shop() BelongsTo
+user() BelongsTo
+items() HasMany
}
class OrderItem {
+order() BelongsTo
+product() BelongsTo
}
Tenant "1" --> "*" Shop : hasMany
Tenant "1" --> "*" User : hasMany
Tenant "1" --> "1" Subscription : hasOne
Shop "1" --> "*" Product : hasMany
Shop "1" --> "*" Order : hasMany
Product "*" --> "*" Category : belongsToMany
Order "1" --> "*" OrderItem : hasMany
(1) ▶ مثال: المتاجر والطلبات في مستأجر ShopMetrics
// جلب المستأجر مع جميع متاجره وطلباته الأخيرة
$tenant = Tenant::with(['shops' => function ($query) {
$query->withCount(['orders' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])->orderBy('revenue', 'desc');
}])->findOrFail($tenantId);
foreach ($tenant->shops as $shop) {
echo "{$shop->name}: {$shop->orders_count} طلبات حديثة";
}
الناتج:
// تم التنفيذ بنجاح
5. ارتباط كثير لكثير
(1) belongsToMany والجداول المحورية
// المنتج ينتمي لعدة فئات (عبر جدول category_product المحوري)
class Product extends Model
{
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)
->withPivot('is_primary')
->withTimestamps();
}
}
class Category extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('is_primary')
->withTimestamps();
}
}
(2) بنية الجدول المحوري
category_product
├── id
├── category_id (FK)
├── product_id (FK)
├── is_primary (BOOLEAN)
├── created_at
└── updated_at
| طريقة المحور | الغرض |
|---|---|
withPivot() |
قراءة عمود محوري إضافي |
withTimestamps() |
صيانة طابع المحور الزمني |
as('alias') |
اسم مستعار للمحور |
wherePivot() |
تصفية شروط المحور |
sync() |
مزامنة الارتباط (تعيين الفرق) |
attach() |
إضافة ارتباط |
detach() |
إزالة ارتباط |
(1) ▶ مثال: فئات منتجات ShopMetrics (كثير لكثير)
// إرفاق فئات بمنتج
$product->categories()->attach([1, 2, 3], ['is_primary' => false]);
$product->categories()->attach(4, ['is_primary' => true]);
// مزامنة — تعيين فئات محددة (يزيل الأخرى)
$product->categories()->sync([
1 => ['is_primary' => false],
4 => ['is_primary' => true],
]);
// مزامنة بدون فصل — إضافة فقط، بدون إزالة
$product->categories()->syncWithoutDetaching([5, 6]);
// استعلام مع شرط محوري
$primaryCategory = $product->categories()
->wherePivot('is_primary', true)
->first();
// فصل فئات محددة
$product->categories()->detach([1, 2]);
الناتج:
// تم التنفيذ بنجاح
6. الارتباط البعيد والارتباط متعدد الأشكال
(1) HasManyThrough
// المستأجر لديه عدة منتجات عبر المتجر
class Tenant extends Model
{
public function products(): HasManyThrough
{
return $this->hasManyThrough(
Product::class, // الهدف النهائي
Shop::class, // الوسيط
'tenant_id', // FK على shops
'shop_id', // FK على products
'id', // PK على tenants
'id', // PK على shops
);
}
}
// الاستخدام: وصول مباشر بدون تحميل المتاجر
$products = $tenant->products()->where('is_active', true)->get();
(2) الارتباطات متعددة الأشكال
// الصورة يمكن أن تنتمي لمتجر أو منتج (قابل للتشكيل)
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Shop extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
class Product extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// تهجير متعدد الأشكال
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->morphs('imageable'); // imageable_type + imageable_id
$table->string('path');
$table->timestamps();
});
| نوع الارتباط | الطريقة | حالة الاستخدام |
|---|---|---|
| واحد لواحد | hasOne/belongsTo | المستخدم ← الملف الشخصي |
| واحد لكثير | hasMany/belongsTo | المستأجر ← المتجر |
| كثير لكثير | belongsToMany | المنتج ↔ الفئة |
| واحد لكثير بعيد | hasManyThrough | المستأجر ← المنتج (عبر المتجر) |
| متعدد الأشكال واحد لواحد | morphOne/morphTo | صورة ← منتج/متجر |
| متعدد الأشكال واحد لكثير | morphMany/morphTo | تعليقات ← منتجات/مقالات |
| متعدد الأشكال كثير لكثير | morphToMany/morphByMany | وسم ← منتج/مقالة |
(1) ▶ مثال: نظام صور متعدد الأشكال لـ ShopMetrics
// إضافة صورة لمتجر
$shop->images()->create(['path' => 'shops/alice-store/banner.jpg']);
// إضافة صورة لمنتج
$product->images()->create(['path' => 'products/widget-a/thumb.jpg']);
// استعلام متعدد الأشكال — جلب مالك الصورة
$image = Image::find(1);
$image->imageable; // يعيد نسخة Shop أو Product
// تحميل مسبق متعدد الأشكال
$images = Image::with('imageable')->get();
foreach ($images as $image) {
echo $image->imageable->name; // يعمل لكل من Shop وProduct
}
الناتج:
// تم التنفيذ بنجاح
7. التحميل المسبق وتحسين N+1
(1) مشكلة N+1
بدون تحميل مسبق:
1. SELECT * FROM orders WHERE tenant_id = 1 LIMIT 10 -- 1 استعلام
2. SELECT * FROM users WHERE id = 1 -- +1 لكل طلب
3. SELECT * FROM users WHERE id = 2
4. SELECT * FROM shops WHERE id = 5
... (حتى 30+ استعلامًا لـ 10 طلبات)
(2) التحميل المسبق مع with()
// تحميل مسبق — 4 استعلامات إجمالًا
$orders = Order::with(['user', 'shop', 'items.product'])->paginate(15);
// تحميل مسبق متداخل
$orders = Order::with(['items.product.categories'])->get();
// تحميل مسبق شرطي
$orders = Order::with(['items' => function ($query) {
$query->where('quantity', '>', 1);
}])->get();
// تحميل مسبق كسول — تحميل بعد الاستعلام الأولي
$orders = Order::all();
if ($needItems) {
$orders->load('items.product');
}
| الطريقة | التوقيت | السيناريوهات المناسبة |
|---|---|---|
with() |
تحميل عند الاستعلام | معروف الحاجة للربط |
load() |
تحميل بعد الاستعلام | تحميل شرطي |
loadCount() |
تحميل العدد فقط | كمية فقط، لا بيانات مطلوبة |
loadMissing() |
تحميل عند الفقد | تجنب إعادة التحميل |
(1) ▶ مثال: تحسين N+1 في لوحة تحكم ShopMetrics
// سيء — مشكلة N+1 في لوحة التحكم
$shops = Shop::where('tenant_id', $tenantId)->get();
foreach ($shops as $shop) {
echo $shop->orders->count(); // +1 استعلام لكل متجر
echo $shop->products->count(); // +1 استعلام لكل متجر
}
// جيد — withCount + تحميل مسبق
$shops = Shop::where('tenant_id', $tenantId)
->withCount(['orders', 'products', 'orders as recent_orders_count' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])
->with(['latestOrder'])
->get();
foreach ($shops as $shop) {
echo $shop->orders_count; // 0 استعلامات إضافية
echo $shop->recent_orders_count; // 0 استعلامات إضافية
echo $shop->latestOrder->total; // 0 استعلامات إضافية
}
الناتج:
// تم التنفيذ بنجاح
8. مثال شامل: تجميع بيانات مستأجر ShopMetrics
// ============================================
// شامل: تجميع بيانات مستأجر ShopMetrics
// يغطي: جميع أنواع الارتباطات، التحميل المسبق، المحور، متعدد الأشكال
// ============================================
// app/Models/Tenant.php
class Tenant extends Model
{
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function products(): HasManyThrough
{
return $this->hasManyThrough(Product::class, Shop::class);
}
public function scopeWithStats(Builder $query): Builder
{
return $query->withCount([
'shops as active_shops_count' => fn ($q) => $q->where('status', 'active'),
'orders as monthly_orders_count' => fn ($q) => $q->whereBetween('created_at', [
now()->startOfMonth(), now()->endOfMonth(),
]),
])->withSum('orders as total_revenue', 'total');
}
}
// الاستخدام — استعلام واحد بكل شيء
$tenant = Tenant::withStats()
->with(['subscription.plan', 'shops' => fn ($q) => $q->orderBy('revenue', 'desc')->take(5)])
->findOrFail($tenantId);
$tenant->active_shops_count; // 15
$tenant->monthly_orders_count; // 234
$tenant->total_revenue; // 45678.90
$tenant->subscription->plan->name; // Pro
$tenant->shops->first()->name; // Alice Store
❓ أسئلة شائعة
hasOne أو hasMany؛ إذا كان المفتاح الأجنبي في هذا الجدول، استخدم belongsTo؛ إذا لم يكن أي جدول به مفتاح أجنبي، استخدم belongsToMany (يتطلب جدولًا محوريًا). يمكن أن ينتمي النموذج لعدة أنواع باستخدام الارتباطات متعددة الأشكال.with() وload()؟with() يحمل البيانات أثناء الاستعلام، مما يوفر أفضل أداء؛ load() يحمل البيانات عند الطلب بعد الاستعلام، مما يجعله مناسبًا للتحميل الشرطي (مثل الحاجة للبيانات المرتبطة فقط في ظل شروط معينة).imageable_type للتمييز بين الأنواع، لذا لا يمكن إنشاء قيود مفتاح أجنبي تقليدية. إضافة فهرس على (imageable_type, imageable_id) يمكن أن يحسن الأداء. لمجموعات البيانات الكبيرة، فكر في استخدام جداول منفصلة بدلاً من الربط متعدد الأشكال.DB::listen() لتسجيل عدد الاستعلامات أو استخدام laravel/telescope للمراقبة.$shop->products هي خاصية ديناميكية (تعيد Collection)، بينما $shop->products() هي منشئ استعلام ارتباطي (يسمح بالاستعلامات المتسلسلة). الأول ينفذ الاستعلام تلقائيًا، بينما الثاني يؤخره حتى استدعاء get() يدويًا.📖 ملخص
- للارتباطات واحد لواحد، استخدم
hasOneأوbelongsTo؛ المفتاح الأجنبي على جانبbelongsTo - الارتباطات واحد لكثير (hasMany/belongsTo) هي أكثر أنواع الارتباطات شيوعًا
- للارتباطات كثير لكثير، استخدم
belongsToMany؛ جدول محوري مطلوب - HasManyThrough: الوصول إلى ارتباط بعيد عبر جدول وسيط
- الارتباطات متعددة الأشكال تسمح لنموذج واحد بالانتماء لعدة أنواع (مثل صورة ← منتج/متجر)
- التحميل المسبق with() يحل مشكلة N+1؛ withCount() يعّد فقط عدد السجلات بدون تحميل البيانات
📝 تمارين
-
تمرين أساسي (⭐): حدد ارتباط Tenant←Shop←Product لـ ShopMetrics. استخدم Tinker لإنشاء بيانات اختبار والوصول إلى الارتباط:
$tenant->shops->first()->products. -
تمرين متقدم (⭐⭐): نفذ ارتباط كثير لكثير بين
ProductوCategory، أنشئ تهجيرًا محوريًا يتضمن عمودis_primary، استخدمsync()لمزامنة الفئات، واستعلام عن الفئة الرئيسية لمنتج معين. -
تحدٍ (⭐⭐⭐): نفذ نظام تعليقات متعدد الأشكال (حيث يمكن ربط نموذج
Commentبكل من المنتجات والمتاجر) وحمّل مسبقًا جميع التعليقات وارتباطاتcommentableفي لوحة التحكم، مع التأكد من الحاجة لـ 3 عبارات SQL فقط (1 لاسترداد التعليقات + 1 لاسترداد المنتجات + 1 لاسترداد المتاجر).



