العلاقة One To Many في Laravel: المرجع الشامل من المبتدئ إلى المتقدم

تُعد العلاقات بين الجداول من أهم المفاهيم التي يجب إتقانها عند العمل باستخدام Laravel وEloquent ORM، ومن أكثر هذه العلاقات استخدامًا علاقة One To Many أو واحد إلى متعدد.

في هذا الدليل لن نكتفي بإنشاء العلاقة باستخدام hasMany() وbelongsTo() فقط، وإنما سنبدأ من تصميم قاعدة البيانات وفهم الـForeign Key، ثم ننتقل إلى إدخال واسترجاع وتحديث وفلترة البيانات، وبعد ذلك إلى موضوعات أكثر تقدمًا مثل Eager Loading، ومشكلة N+1، وwhereHas()، وwithCount()، والاستعلامات التي ينفذها Laravel تقريبًا خلف الكواليس.

تم تقسيم الدليل إلى ثلاثة مستويات:

  • Beginner: فهم العلاقة وإنشاؤها والتعامل الأساسي معها.
  • Intermediate: الاستعلامات والفلترة وEager Loading.
  • Advanced: الأداء، N+1، SQL، العلاقات المقيدة، وتصميم التطبيقات الاحترافية.
الهدف من هذا المقال ليس حفظ أوامر Laravel، وإنما فهم الطريقة التي يفكر بها Eloquent عند التعامل مع العلاقات.

جدول المحتويات


المستوى الأول: Beginner

ما هي علاقة One To Many؟

علاقة One To Many تعني أن سجلًا واحدًا في جدول معين يستطيع الارتباط بعدة سجلات في جدول آخر، بينما كل سجل من الجدول الثاني ينتمي إلى سجل واحد فقط من الجدول الأول.

سنستخدم طوال المقال مثال متجر إلكتروني يحتوي على:

  • جدول للعلامات التجارية brands.
  • جدول للمنتجات products.

الـBrand الواحد يمكن أن يحتوي على عدد كبير من المنتجات.

Apple
│
├── iPhone 15
├── iPhone 15 Pro
├── MacBook Pro
└── iPad Pro

بالتالي:

One Brand → Many Products

أمثلة أخرى من التطبيقات الحقيقية

User     → Posts
Post     → Comments
Category → Products
Country  → Cities
Order    → Order Items
Course   → Lessons
Brand    → Products
إذا استطعت أن تقول: "السجل X يمتلك عدة سجلات من Y، وكل Y ينتمي إلى X واحد"، فأنت غالبًا أمام علاقة One To Many.

تصميم علاقة One To Many داخل قاعدة البيانات

من الأخطاء الشائعة الاعتقاد أن Laravel هو الذي يصنع مفهوم One To Many.

في الحقيقة العلاقة تبدأ من تصميم قاعدة البيانات نفسها.

لدينا:

brands

و:

products

يجب أن يحتوي جدول المنتجات على عمود يحدد الـBrand الذي ينتمي إليه المنتج:

brand_id

الشكل الكامل للعلاقة

┌─────────────────────┐
│       brands        │
├─────────────────────┤
│ id                  │
│ name                │
│ created_at          │
│ updated_at          │
└──────────┬──────────┘
           │
           │ 1
           │
           │
           │ many
           ▼
┌─────────────────────┐
│      products       │
├─────────────────────┤
│ id                  │
│ brand_id            │
│ name                │
│ price               │
│ qty                 │
│ created_at          │
│ updated_at          │
└─────────────────────┘

لاحظ أن:

products.brand_id → brands.id

وهذا هو أساس العلاقة بالكامل.

مثال على البيانات

brands

+----+---------+
| id | name    |
+----+---------+
| 1  | Samsung |
| 2  | Apple   |
+----+---------+

ثم:

products

+----+----------+----------------+
| id | brand_id | name           |
+----+----------+----------------+
| 1  | 2        | iPhone 15      |
| 2  | 2        | MacBook Pro    |
| 3  | 1        | Galaxy S25     |
+----+----------+----------------+

يمكننا الآن معرفة:

iPhone 15   → brand_id = 2 → Apple
MacBook Pro → brand_id = 2 → Apple
Galaxy S25  → brand_id = 1 → Samsung
في علاقة One To Many يكون الـForeign Key عادة في جدول جهة الـMany، أي جدول الأبناء.

إنشاء Models وMigrations

إنشاء Brand

php artisan make:model Brand -m

إنشاء Product

php artisan make:model Product -m

الخيار:

-m

يجعل Laravel ينشئ ملف Migration مع الـModel.


إنشاء جدول brands

Schema::create('brands', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

إنشاء جدول products والـForeign Key

Schema::create('products', function (Blueprint $table) {

    $table->id();

    $table->foreignId('brand_id')
        ->constrained()
        ->cascadeOnDelete();

    $table->string('name');

    $table->string('slug')
        ->unique();

    $table->decimal('price', 10, 2);

    $table->unsignedInteger('qty')
        ->default(0);

    $table->timestamps();
});

ماذا يفعل foreignId؟

$table->foreignId('brand_id');

ينشئ عمودًا مناسبًا لتخزين معرف الـBrand.

ماذا يفعل constrained؟

->constrained()

يعتمد Laravel على Naming Conventions ويستنتج أن:

brand_id

يشير إلى:

brands.id

أي أن:

products.brand_id
        │
        └──────→ brands.id

ماذا يفعل cascadeOnDelete؟

->cascadeOnDelete()

يعني أنه إذا تم حذف Brand، تقوم قاعدة البيانات بحذف المنتجات التابعة له أيضًا.

Apple
│
├── iPhone
├── MacBook
└── iPad

عند حذف:

Apple

يتم حذف المنتجات المرتبطة به.

لا تجعل Cascade Delete خيارًا افتراضيًا في كل مشروع. أحيانًا يكون حذف الأبناء خطرًا، خصوصًا في الأنظمة المالية وأنظمة الطلبات والسجلات التاريخية.

تعريف علاقة hasMany

داخل:

app/Models/Brand.php

نكتب:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Brand extends Model
{
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
}

السطر:

return $this->hasMany(Product::class);

يعني:

هذا الـBrand يمتلك عدة Products.

ماذا يفترض Laravel خلف الكواليس؟

بسبب اسم Model:

Brand

يفترض Laravel أن الـForeign Key هو:

brand_id

وأنه موجود في جدول:

products

تعريف العلاقة العكسية belongsTo

حتى الآن نستطيع الانتقال:

Brand → Products

لكن نحتاج أيضًا إلى الانتقال بالعكس:

Product → Brand

داخل:

app/Models/Product.php

نكتب:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Product extends Model
{
    public function brand(): BelongsTo
    {
        return $this->belongsTo(Brand::class);
    }
}

المخطط الكامل

Brand
  │
  │ hasMany
  ▼
Products


Product
  │
  │ belongsTo
  ▼
Brand
العلاقة العكسية لـhasMany هي belongsTo، وليست belongsToMany. belongsToMany تُستخدم لعلاقات Many To Many.

استخدام Foreign Key مخصص

الأفضل الالتزام بتسمية Laravel:

brand_id

لكن لنفترض أن قاعدة البيانات تستخدم:

company_brand_id

يمكن تحديده يدويًا:

public function products(): HasMany
{
    return $this->hasMany(
        Product::class,
        'company_brand_id'
    );
}

وفي Product:

public function brand(): BelongsTo
{
    return $this->belongsTo(
        Brand::class,
        'company_brand_id'
    );
}

تحديد Foreign Key وLocal Key

return $this->hasMany(
    Product::class,
    'company_brand_id',
    'id'
);

الترتيب:

hasMany(
    Related Model,
    Foreign Key,
    Local Key
)

استرجاع البيانات من العلاقة

جلب Brand

$brand = Brand::find(1);

جلب المنتجات التابعة له

$products = $brand->products;

القيمة:

$brand->products

ستكون Collection تحتوي جميع المنتجات التي يكون:

brand_id = 1

عرض المنتجات

foreach ($brand->products as $product) {
    echo $product->name;
}

جلب Brand من Product

$product = Product::find(1);

$brand = $product->brand;

يمكن كتابة:

echo $product->brand->name;

إدخال البيانات باستخدام العلاقة

الطريقة الأولى: save

$brand = Brand::findOrFail(1);

$product = new Product();

$product->name = 'iPhone 15 Pro';
$product->slug = 'iphone-15-pro';
$product->price = 999.99;
$product->qty = 10;

$brand->products()->save($product);

Laravel يقوم بتحديد:

brand_id

تلقائيًا من العلاقة.


الطريقة الثانية: create

$brand = Brand::findOrFail(1);

$product = $brand->products()->create([
    'name'  => 'iPhone 15 Pro',
    'slug'  => 'iphone-15-pro',
    'price' => 999.99,
    'qty'   => 10,
]);

إذا كنت تستخدم Mass Assignment يجب إعداد:

protected $fillable = [
    'name',
    'slug',
    'price',
    'qty',
];

إدخال عدة Products باستخدام createMany

$brand->products()->createMany([

    [
        'name'  => 'iPhone 15',
        'slug'  => 'iphone-15',
        'price' => 799,
        'qty'   => 10,
    ],

    [
        'name'  => 'iPhone 15 Pro',
        'slug'  => 'iphone-15-pro',
        'price' => 999,
        'qty'   => 5,
    ],

]);

استخدام saveMany

$brand->products()->saveMany([

    new Product([
        'name'  => 'iPhone 15',
        'slug'  => 'iphone-15',
        'price' => 799,
        'qty'   => 10,
    ]),

    new Product([
        'name'  => 'iPhone 15 Pro',
        'slug'  => 'iphone-15-pro',
        'price' => 999,
        'qty'   => 5,
    ]),

]);

المستوى الثاني: Intermediate

الفرق بين products و products()

هذه من أهم النقاط في علاقات Eloquent.

الحالة الأولى

$brand->products

تعني:

أعطني البيانات الناتجة من العلاقة.

الحالة الثانية

$brand->products()

تعني:

أعطني Relationship Query Builder حتى أستطيع إضافة شروط على الاستعلام.

مثال

$products = $brand->products()
    ->where('price', '>', 500)
    ->get();

يمكن أيضًا:

$products = $brand->products()
    ->where('qty', '>', 0)
    ->orderByDesc('price')
    ->get();

قاعدة سهلة للحفظ

$brand->products

= Result


$brand->products()

= Query

Lazy Loading

لننظر إلى:

$brand = Brand::find(1);

$products = $brand->products;

الاستعلام الأول يجلب Brand.

بعد ذلك، عندما يصل Laravel إلى:

$brand->products

يقوم بتنفيذ استعلام إضافي للحصول على المنتجات.

يسمى ذلك:

Lazy Loading

أي تحميل العلاقة عندما نحتاج إليها.


Eager Loading

إذا كنا نعرف مسبقًا أننا سنحتاج Products، يمكن تحميلها من البداية:

$brands = Brand::with('products')->get();

يسمى ذلك:

Eager Loading

وهو مهم جدًا لتحسين الأداء عندما نتعامل مع عدة Models.


تحميل علاقة بعد جلب Model

$brand = Brand::findOrFail(1);

$brand->load('products');

الفرق بين with وload

with()

تستخدم أثناء بناء Query.

مثال:

Brand::with('products')->get();

بينما:

load()

تستخدم عندما يكون Model موجودًا بالفعل.

$brand = Brand::find(1);

$brand->load('products');

تحميل أكثر من علاقة

Brand::with([
    'products',
    'country',
    'owner'
])->get();

Nested Relationships

إذا كان Product يحتوي Reviews:

Brand
  │
  ▼
Products
  │
  ▼
Reviews

يمكن:

Brand::with('products.reviews')->get();

فلترة المنتجات المرتبطة

لنفرض أننا نريد Brands كاملة، لكن نريد تحميل المنتجات التي يزيد سعرها عن 500 فقط.

$brands = Brand::with([
    'products' => function ($query) {

        $query->where('price', '>', 500)
              ->orderBy('name');

    }
])->get();
هذا الاستعلام لا يقوم بفلترة Brands نفسها. هو يعيد Brands ثم يحدد المنتجات التي سيتم تحميلها لكل Brand.

فلترة Parent باستخدام whereHas

إذا أردنا فقط Brands التي لديها منتج سعره أكبر من 500:

$brands = Brand::whereHas(
    'products',
    function ($query) {

        $query->where('price', '>', 500);

    }
)->get();

الفرق المهم

with()

→ يحدد البيانات التي سيتم تحميلها من Relationship.


whereHas()

→ يحدد أي Parent Models يجب إرجاعها.

الجمع بين whereHas وwith

نريد:

  • Brands التي لديها Products سعرها أكبر من 500.
  • تحميل Products التي سعرها أكبر من 500 فقط.
$brands = Brand::whereHas(
    'products',
    function ($query) {

        $query->where('price', '>', 500);

    }
)
->with([
    'products' => function ($query) {

        $query->where('price', '>', 500);

    }
])
->get();

doesntHave

للحصول على Brands التي لا تحتوي أي Products:

$brands = Brand::doesntHave('products')
    ->get();

whereDoesntHave

للحصول على Brands التي لا تحتوي منتجات متوفرة:

$brands = Brand::whereDoesntHave(
    'products',
    function ($query) {

        $query->where('qty', '>', 0);

    }
)->get();

حساب عدد المنتجات باستخدام withCount

إذا كنا نريد فقط معرفة عدد المنتجات، ليس من المنطقي تحميلها جميعًا.

يمكن:

$brands = Brand::withCount('products')
    ->get();

Laravel سيضيف خاصية:

products_count

مثال:

foreach ($brands as $brand) {

    echo $brand->name;

    echo $brand->products_count;

}

استخدام has

للحصول على Brands لديها Products:

$brands = Brand::has('products')
    ->get();

Brands التي لديها خمسة Products أو أكثر:

$brands = Brand::has(
    'products',
    '>=',
    5
)->get();

ربط Product موجود بBrand

إذا كان Product موجودًا بالفعل:

$product = Product::findOrFail(10);

$brand = Brand::findOrFail(2);

$product->brand()->associate($brand);

$product->save();

أصبح:

product.brand_id = 2

تغيير Brand الخاص بالمنتج

يمكن استخدام نفس الطريقة:

$product = Product::findOrFail(10);

$newBrand = Brand::findOrFail(5);

$product->brand()->associate($newBrand);

$product->save();

حذف Product

$product = Product::findOrFail(1);

$product->delete();

حذف جميع منتجات Brand

$brand = Brand::findOrFail(1);

$brand->products()->delete();

لاحظ أن هذا لا يحذف Brand نفسه.


حذف Brand باستخدام Cascade Delete

إذا كان Migration يحتوي:

->cascadeOnDelete()

فيمكن:

$brand = Brand::findOrFail(1);

$brand->delete();

وتقوم قاعدة البيانات بحذف Products التابعة له.


المستوى الثالث: Advanced

ما هو SQL الذي ينفذه Eloquent خلف الكواليس؟

فهم SQL التقريبي خلف Eloquent يجعل التعامل مع العلاقات أسهل بكثير.

الـSQL التالي توضيحي وتقريبي، وقد يختلف قليلًا حسب Database Driver وإصدار Laravel والاستعلام المستخدم.

Brand::find(1)

Eloquent:

$brand = Brand::find(1);

SQL تقريبًا:

SELECT *
FROM brands
WHERE id = 1
LIMIT 1;

$brand->products

$products = $brand->products;

SQL تقريبًا:

SELECT *
FROM products
WHERE brand_id = 1;

belongsTo

$product = Product::find(10);

$brand = $product->brand;

إذا كان:

product.brand_id = 2

فسيكون الاستعلام تقريبًا:

SELECT *
FROM brands
WHERE id = 2
LIMIT 1;

فلترة Relationship

$brand->products()
    ->where('price', '>', 500)
    ->get();

SQL تقريبًا:

SELECT *
FROM products
WHERE brand_id = 1
AND price > 500;

مع شرط الكمية

$brand->products()
    ->where('qty', '>', 0)
    ->orderByDesc('price')
    ->get();

SQL تقريبًا:

SELECT *
FROM products
WHERE brand_id = 1
AND qty > 0
ORDER BY price DESC;

SQL التقريبي لـEager Loading

لدينا:

$brands = Brand::with('products')
    ->get();

قد ينفذ Laravel استعلامًا لجلب Brands:

SELECT *
FROM brands;

ثم استعلامًا آخر لجلب جميع Products المطلوبة:

SELECT *
FROM products
WHERE brand_id IN (1, 2, 3, 4, 5);

ثم يقوم Eloquent بتوزيع Products داخل الـBrand المناسب في الذاكرة.

وهنا تكمن فائدة Eager Loading: بدل تنفيذ Query منفصل لكل Brand، يتم جلب المنتجات المطلوبة في عدد أقل من الاستعلامات.

مشكلة N+1

من أشهر مشاكل الأداء عند العمل مع ORM.

لننظر إلى:

$brands = Brand::all();

foreach ($brands as $brand) {

    echo $brand->name;

    foreach ($brand->products as $product) {

        echo $product->name;

    }
}

إذا كان لدينا 100 Brand، قد يحدث:

1 Query لجلب Brands

+

100 Queries لجلب Products

=

101 Queries

وهذا يسمى:

N + 1 Problem

الحل

$brands = Brand::with('products')
    ->get();

في السيناريو المعتاد تصبح العملية أقرب إلى:

Query 1:
SELECT * FROM brands;


Query 2:
SELECT *
FROM products
WHERE brand_id IN (...);

أي تقريبًا:

2 Queries

بدل:

101 Queries

كيف تكتشف N+1؟

أثناء التطوير، راقب عدد الاستعلامات التي ينفذها التطبيق.

إذا رأيت نفس Query يتكرر عشرات أو مئات المرات مع اختلاف ID فقط، فهذه علامة قوية على وجود N+1.

SELECT * FROM products WHERE brand_id = 1;
SELECT * FROM products WHERE brand_id = 2;
SELECT * FROM products WHERE brand_id = 3;
SELECT * FROM products WHERE brand_id = 4;
SELECT * FROM products WHERE brand_id = 5;

غالبًا يمكن تحسينها باستخدام:

with()

اختيار الأعمدة المطلوبة

ليس من الضروري دائمًا تحميل كل أعمدة الجداول.

بدل:

Brand::with('products')->get();

يمكن:

$brands = Brand::select(
        'id',
        'name'
    )
    ->with(
        'products:id,brand_id,name,price'
    )
    ->get();
عند تحديد Columns الخاصة بالـRelationship، لا تنس مفتاح الربط مثل brand_id، وإلا قد لا يستطيع Eloquent بناء العلاقة كما تتوقع.

مثال خاطئ

Brand::with(
    'products:id,name,price'
)->get();

المشكلة أننا حذفنا:

brand_id

الصحيح

Brand::with(
    'products:id,brand_id,name,price'
)->get();

إنشاء Relationship بشروط ثابتة

أحيانًا نستخدم Products كاملة:

public function products(): HasMany
{
    return $this->hasMany(Product::class);
}

لكن نحتاج أيضًا علاقة خاصة بالمنتجات المتوفرة.

public function availableProducts(): HasMany
{
    return $this->hasMany(Product::class)
        ->where('qty', '>', 0);
}

الآن:

$brand->availableProducts

يعيد فقط المنتجات التي:

qty > 0

إنشاء علاقة للمنتجات الغالية

public function expensiveProducts(): HasMany
{
    return $this->hasMany(Product::class)
        ->where('price', '>', 1000);
}

ثم:

$brand->expensiveProducts;

ترتيب افتراضي داخل Relationship

public function products(): HasMany
{
    return $this->hasMany(Product::class)
        ->orderBy('name');
}

لكن انتبه:

إذا أضفت orderBy داخل تعريف Relationship، سيصبح هذا الترتيب جزءًا افتراضيًا من العلاقة في كل مكان تستخدمها فيه.

إذا كنت تحتاجه في مكان واحد فقط، قد يكون الأفضل:

$brand->products()
    ->orderBy('name')
    ->get();

فلترة متقدمة

نريد Brands لديها Products:

  • متوفرة.
  • سعرها بين 500 و1500.

ثم نريد تحميل هذه المنتجات وترتيبها من الأغلى إلى الأرخص.

$brands = Brand::query()

    ->whereHas(
        'products',
        function ($query) {

            $query->where('qty', '>', 0)
                  ->whereBetween(
                      'price',
                      [500, 1500]
                  );

        }
    )

    ->with([
        'products' => function ($query) {

            $query->where('qty', '>', 0)
                  ->whereBetween(
                      'price',
                      [500, 1500]
                  )
                  ->orderByDesc('price');

        }
    ])

    ->get();

ماذا يحدث هنا؟

الجزء:

whereHas()

يقرر أي Brands يتم إرجاعها.

والجزء:

with()

يحدد أي Products يتم تحميلها داخل Brands.


Pagination مع العلاقات

في المشاريع الحقيقية قد يكون لديك آلاف Brands وعشرات الآلاف من Products.

لا تستخدم:

Brand::with('products')->get();

على مجموعة بيانات ضخمة دون سبب.

يمكن:

$brands = Brand::with(
        'products:id,brand_id,name,price'
    )
    ->paginate(20);

وبذلك يتم جلب 20 Brand لكل صفحة بدل تحميل الجدول كاملًا.


قواعد مهمة لتحسين الأداء

1. تجنب N+1

استخدم:

with()

عندما تعرف أنك ستحتاج إلى العلاقة.

2. لا تحمل بيانات لن تستخدمها

استخدم:

select()

عند الحاجة.

3. استخدم withCount بدل تحميل Relation للحساب فقط

بدل:

$brand->products->count();

إذا كان هدفك مجرد العدد لعدد كبير من Brands، استخدم:

Brand::withCount('products')
    ->get();

4. استخدم Pagination

خصوصًا في APIs وصفحات الإدارة.

5. دع قاعدة البيانات تنفذ Filtering

يفضل:

Product::where('price', '>', 500)
    ->get();

على:

Product::all()
    ->where('price', '>', 500);

في الأولى يتم تنفيذ الفلترة في قاعدة البيانات.

أما الثانية فتحمل البيانات أولًا إلى PHP ثم تقوم Collection بالفلترة.


أشهر الأخطاء في One To Many

الخطأ الأول: استخدام belongsToMany

الخطأ:

return $this->belongsToMany(Brand::class);

إذا كان Product ينتمي إلى Brand واحد.

الصحيح:

return $this->belongsTo(Brand::class);

الخطأ الثاني: وضع Foreign Key في الجدول الخطأ

في مثالنا يجب أن يكون:

products.brand_id

وليس:

brands.product_id

لأن Brand يمتلك عدة Products وليس Product واحدًا فقط.


الخطأ الثالث: Naming Conventions غير واضحة

يفضل:

brand_id

بدل أسماء مثل:

brands_id
brandID
brandId
id_brand

يمكن لـLaravel التعامل معها، لكن ستحتاج إلى إعدادات إضافية.


الخطأ الرابع: نسيان Eager Loading

هذا:

$brands = Brand::all();

foreach ($brands as $brand) {
    echo $brand->products;
}

قد يؤدي إلى N+1.

الأفضل:

$brands = Brand::with('products')
    ->get();

الخطأ الخامس: نسيان Foreign Key عند Select

الخطأ:

Brand::with(
    'products:id,name'
)->get();

الأفضل:

Brand::with(
    'products:id,brand_id,name'
)->get();

الخطأ السادس: استخدام find دون فحص null

$brand = Brand::find(500);

$brand->products;

إذا لم يوجد Brand:

$brand = null

لذلك في الحالات التي يجب أن يكون فيها السجل موجودًا:

$brand = Brand::findOrFail(500);

مثال Controller متكامل

عرض Brands

public function index()
{
    $brands = Brand::query()

        ->select(
            'id',
            'name'
        )

        ->with([
            'products:id,brand_id,name,slug,price,qty'
        ])

        ->withCount('products')

        ->paginate(20);

    return response()->json([
        'data' => $brands
    ]);
}

عرض Brand واحد

public function show(Brand $brand)
{
    $brand->load([
        'products:id,brand_id,name,slug,price,qty'
    ]);

    return response()->json([
        'data' => $brand
    ]);
}

إنشاء Product

public function store(
    Request $request,
    Brand $brand
) {

    $data = $request->validate([

        'name' => [
            'required',
            'string',
            'max:255'
        ],

        'slug' => [
            'required',
            'string',
            'max:255',
            'unique:products,slug'
        ],

        'price' => [
            'required',
            'numeric',
            'min:0'
        ],

        'qty' => [
            'required',
            'integer',
            'min:0'
        ],

    ]);

    $product = $brand
        ->products()
        ->create($data);

    return response()->json([

        'message' => 'Product created successfully',

        'data' => $product,

    ], 201);
}

لماذا لم نستقبل brand_id؟

لأن Brand معروف من:

Brand $brand

ثم قمنا بالإنشاء من خلال:

$brand->products()->create($data);

وهذا يجعل علاقة المنتج بالـBrand واضحة ويمنع تمرير brand_id من المستخدم بلا حاجة.


مخطط دورة العلاقة بالكامل

                DATABASE
                    │
                    │
          ┌─────────▼─────────┐
          │      brands       │
          │                   │
          │ id = 1            │
          │ name = Apple      │
          └─────────┬─────────┘
                    │
                    │ hasMany
                    │
          ┌─────────▼─────────┐
          │     products      │
          │                   │
          │ brand_id = 1      │
          ├───────────────────┤
          │ iPhone            │
          │ MacBook           │
          │ iPad              │
          └───────────────────┘


Laravel:

$brand->products

        ↓

SELECT *
FROM products
WHERE brand_id = 1;


Product:

$product->brand

        ↓

SELECT *
FROM brands
WHERE id = product.brand_id
LIMIT 1;

تمارين عملية

إذا أردت التأكد أنك فهمت علاقة One To Many، حاول حل التمارين التالية دون الرجوع إلى الحل مباشرة.

التمرين الأول

لديك:

Category
Product

كل Category تحتوي عدة Products.

المطلوب:

  • إنشاء Migration.
  • إنشاء Foreign Key.
  • تعريف hasMany.
  • تعريف belongsTo.

الحل المختصر

// Category

public function products(): HasMany
{
    return $this->hasMany(Product::class);
}


// Product

public function category(): BelongsTo
{
    return $this->belongsTo(Category::class);
}

التمرين الثاني

اكتب Query يعيد Brand رقم 5 والمنتجات التابعة له فقط إذا كانت:

  • متوفرة.
  • سعرها أكبر من 100.

الحل

$brand = Brand::findOrFail(5);

$products = $brand->products()

    ->where('qty', '>', 0)

    ->where('price', '>', 100)

    ->get();

التمرين الثالث

أعد جميع Brands التي لديها منتج واحد على الأقل سعره أكبر من 1000.

الحل

$brands = Brand::whereHas(
    'products',
    function ($query) {

        $query->where(
            'price',
            '>',
            1000
        );

    }
)->get();

التمرين الرابع

أعد Brands مع عدد Products لكل Brand بدون تحميل Products.

الحل

$brands = Brand::withCount('products')
    ->get();

التمرين الخامس

لديك 500 Brand، والكود التالي بطيء:

$brands = Brand::all();

foreach ($brands as $brand) {

    foreach ($brand->products as $product) {

        echo $product->name;

    }

}

ما المشكلة؟

الإجابة

احتمال وجود:

N+1 Query Problem

والحل:

$brands = Brand::with('products')
    ->get();

أسئلة مقابلات Laravel حول One To Many

السؤال الأول

ما الفرق بين:

hasMany()

و:

belongsTo()

الإجابة

hasMany() يتم تعريفها في جهة الأب الذي يمتلك عدة سجلات، بينما belongsTo() يتم تعريفها في جهة الابن الذي يحتوي عادة على الـForeign Key.


السؤال الثاني

أين يوضع Foreign Key في علاقة One To Many؟

الإجابة

عادة في جدول جهة الـMany.

مثال:

Brand → Products

products.brand_id

السؤال الثالث

ما الفرق بين:

$brand->products

و:

$brand->products()

الإجابة

الأولى تعيد البيانات الناتجة من Relationship، بينما الثانية تعيد Relationship Query Builder ويمكن إضافة شروط مثل:

$brand->products()
    ->where('price', '>', 500)
    ->get();

السؤال الرابع

ما هي مشكلة N+1؟

الإجابة

هي تنفيذ Query إضافي لكل Model داخل Loop عند تحميل Relationship باستخدام Lazy Loading.

ومن أشهر طرق حلها:

with()

السؤال الخامس

ما الفرق بين:

with()

و:

load()

الإجابة

with() تستخدم أثناء إنشاء Query، بينما load() تستخدم عندما يكون Model قد تم جلبه بالفعل.


السؤال السادس

ما الفرق بين:

with()

و:

whereHas()

الإجابة

with() تستخدم لتحميل Relationship، بينما whereHas() تستخدم لفلترة Parent Models بناءً على شروط موجودة في Relationship.


السؤال السابع

كيف تحصل على عدد Products لكل Brand دون تحميل Products نفسها؟

الإجابة

Brand::withCount('products')
    ->get();

السؤال الثامن

كيف تحصل على Brands التي ليس لديها أي Product؟

الإجابة

Brand::doesntHave('products')
    ->get();

السؤال التاسع

ماذا يفعل:

$table->foreignId('brand_id')
    ->constrained()
    ->cascadeOnDelete();

الإجابة

ينشئ Foreign Key يشير وفق Laravel conventions إلى brands.id، ويحدد أن حذف Brand يؤدي إلى حذف السجلات المرتبطة به في Products.


السؤال العاشر

ما الفرق بين:

Product::get()
    ->where('price', '>', 500);

و:

Product::where('price', '>', 500)
    ->get();

الإجابة

في المثال الأول يتم جلب البيانات أولًا من قاعدة البيانات ثم تتم الفلترة على Collection داخل PHP.

أما المثال الثاني فيتم إرسال شرط WHERE إلى قاعدة البيانات نفسها، وهو غالبًا الخيار الأفضل عند التعامل مع كميات كبيرة من البيانات.


اختبار نهائي

إذا أصبحت قادرًا على تفسير الكود التالي بالكامل، فأنت أصبحت تفهم One To Many بصورة جيدة:

$brands = Brand::query()

    ->select(
        'id',
        'name'
    )

    ->whereHas(
        'products',
        function ($query) {

            $query->where(
                'qty',
                '>',
                0
            );

        }
    )

    ->with([
        'products' => function ($query) {

            $query
                ->select(
                    'id',
                    'brand_id',
                    'name',
                    'price',
                    'qty'
                )
                ->where(
                    'qty',
                    '>',
                    0
                )
                ->orderByDesc(
                    'price'
                );

        }
    ])

    ->withCount('products')

    ->paginate(20);

ماذا يفعل هذا الاستعلام؟

  • يجلب Brands.
  • يحدد أعمدة Brand المطلوبة.
  • يعيد فقط Brands التي لديها Products متوفرة.
  • يحمل Products المتوفرة فقط.
  • يرتب Products من الأعلى سعرًا إلى الأقل.
  • يضيف عدد Products لكل Brand.
  • يقسم النتائج إلى 20 Brand في الصفحة.

الخلاصة

علاقة One To Many في Laravel ليست مجرد:

hasMany()

belongsTo()

بل تبدأ أولًا من تصميم قاعدة البيانات بصورة صحيحة:

brands.id
    │
    │
    └────→ products.brand_id

ثم تعريف الاتجاهين:

Brand
    ↓
hasMany
    ↓
Products


Product
    ↓
belongsTo
    ↓
Brand

بعد ذلك يمكن استخدام Eloquent للتعامل مع العلاقة:

$brand->products

$brand->products()

$product->brand

ثم الانتقال إلى الأدوات الأكثر تقدمًا:

with()

load()

whereHas()

whereDoesntHave()

has()

doesntHave()

withCount()

save()

saveMany()

create()

createMany()

associate()

وأخيرًا يجب الانتباه إلى الأداء وفهم الفرق بين:

Lazy Loading

vs

Eager Loading

ومراقبة مشكلة:

N + 1
أفضل طريقة لإتقان علاقات Laravel ليست حفظ الدوال، وإنما فهم ثلاث طبقات: تصميم العلاقة في قاعدة البيانات، طريقة تعريفها في Eloquent، والاستعلامات التي سينفذها التطبيق عند استخدام العلاقة.

عندما تفهم هذه الطبقات الثلاث، يصبح الانتقال إلى العلاقات الأخرى مثل One To One وMany To Many وHas Many Through وPolymorphic Relationships أسهل بكثير.