عند تطوير تطبيق Laravel صغير يحتوي على بضع مئات من السجلات، قد لا تلاحظ مشكلة في كتابة استعلام يقوم بجلب جميع البيانات من قاعدة البيانات باستخدام get(). لكن عندما ينتقل التطبيق إلى بيئة Production ويصبح جدول واحد يحتوي على عشرات أو مئات الآلاف من السجلات، قد يتحول استعلام بسيط ظاهريًا إلى سبب مباشر في بطء التطبيق وارتفاع استهلاك الذاكرة، بل وقد يؤدي إلى ظهور خطأ:

Allowed memory size exhausted

المشكلة هنا ليست Laravel بحد ذاته، وإنما طريقة جلب البيانات ومعالجتها.

في هذا الدليل سنبدأ بمثال واقعي، ثم ننتقل تدريجيًا من الحل البسيط باستخدام select() إلى تقنيات أكثر ملاءمة لتطبيقات Production مثل paginate() وchunkById() وlazy()، مع توضيح متى يجب تنفيذ عملية التجميع داخل قاعدة البيانات بدلًا من PHP.

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

المشكلة الواقعية: صفحة أرشيف الكتب

لنفترض أن لدينا منصة كتب تحتوي على جدول books، وكل كتاب مرتبط بمؤلف داخل جدول authors.

نريد إنشاء صفحة تعرض الكتب مرتبة ومجمعة حسب سنة النشر:

2026
├── Clean Architecture with Laravel
├── Advanced PHP
└── Building APIs with Laravel

2025
├── Database Design
└── Modern Backend Development

2024
└── PHP Performance Guide

قد يحتوي جدول books في البداية على 500 كتاب فقط، لكن بعد عدة سنوات قد يصل العدد إلى 100,000 أو حتى مليون كتاب. وهنا تبدأ القرارات الصغيرة في الاستعلام بالتأثير بشكل واضح على استهلاك الذاكرة والأداء.

الحل الأول: الطريقة التقليدية

قد نكتب Controller بالشكل التالي:

use App\Models\Book;
use Carbon\Carbon;

public function index()
{
    $years = Book::query()
        ->with('author')
        ->latest('published_at')
        ->get()
        ->groupBy(function ($book) {
            return Carbon::parse($book->published_at)->format('Y');
        });

    return view('books', compact('years'));
}

وفي Blade:

<div class="row">
    @foreach($years as $year => $books)

        <div class="col-4">
            <div class="card mt-2">

                <div class="card-header">
                    {{ $year }}
                </div>

                <ul class="list-group list-group-flush">

                    @foreach($books as $book)

                        <li class="list-group-item">
                            {{ \Illuminate\Support\Str::limit($book->title, 40) }}
                        </li>

                    @endforeach

                </ul>

            </div>
        </div>

    @endforeach
</div>

الكود يعمل، والنتيجة صحيحة، ولذلك قد يبدو أنه لا توجد مشكلة.

لكن علينا النظر إلى ما يحدث فعليًا داخل التطبيق.

تدفق البيانات في هذا الاستعلام

HTTP Request
     │
     ▼
Laravel Controller
     │
     ▼
SELECT books.*
     │
     ▼
جلب جميع الكتب
     │
     ├──────────────► SELECT authors.*
     │
     ▼
إنشاء Book Models
     │
     ▼
إنشاء Author Models
     │
     ▼
Collection في ذاكرة PHP
     │
     ▼
groupBy() داخل PHP
     │
     ▼
Collection جديدة/مجمعة
     │
     ▼
Blade
     │
     ▼
HTML Response

لاحظ أن قاعدة البيانات لا تقوم هنا بتجميع الكتب حسب السنة. Laravel يقوم أولًا بجلب النتائج، ثم يتم تنفيذ groupBy() الخاص بالـ Collection داخل PHP.

أين يحدث استهلاك الذاكرة؟

عند تنفيذ:

Book::get();

لا تحصل فقط على البيانات الخام القادمة من MySQL، بل يقوم Eloquent بتحويل النتائج إلى Models يمكن التعامل معها داخل التطبيق.

وعند استخدام:

->with('author')

سيتم أيضًا تحميل بيانات العلاقة وإنشاء Models للمؤلفين.

بالتالي فإن حجم البيانات الموجودة داخل ذاكرة PHP قد يكون أكبر بكثير من حجم الصفوف الخام التي تتخيل أنك قمت بجلبها من قاعدة البيانات.

المشكلة الأولى: SELECT *

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

SELECT * FROM books;

لنفترض أن الجدول يحتوي على:

id
title
description
content
cover
isbn
published_at
author_id
metadata
created_at
updated_at

لكن الصفحة تحتاج فعليًا إلى:

id
title
published_at
author_id

إذا كان description أو content أو metadata يحتوي على بيانات كبيرة، فإن تحميلها لكل سجل بدون استخدامها يعتبر هدرًا غير ضروري للذاكرة ونقل البيانات.

المشكلة الثانية: تحميل جميع أعمدة العلاقة

الأمر نفسه يحدث عند كتابة:

->with('author')

إذا كان جدول المؤلف يحتوي على عدة أعمدة، بينما تحتاج الصفحة إلى اسم المؤلف فقط، فلا يوجد سبب لتحميل بقية البيانات.

المشكلة الثالثة والأهم: get()

حتى بعد تحديد الأعمدة المطلوبة، تبقى هناك مشكلة إذا كان عدد السجلات كبيرًا:

->get()

لأنك تطلب من التطبيق تحميل كامل مجموعة النتائج التي يعيدها الاستعلام إلى الذاكرة.

إذا كانت لديك 100 نتيجة، فهذا طبيعي. أما إذا كانت لديك 500,000 نتيجة، فقد يصبح الأمر مكلفًا جدًا.

التحسين الأول: جلب الأعمدة المطلوبة فقط

بدلًا من:

$books = Book::query()
    ->with('author')
    ->get();

يمكن كتابة:

$books = Book::query()
    ->select([
        'id',
        'title',
        'published_at',
        'author_id',
    ])
    ->with('author:id,name')
    ->get();

بهذا أصبح الاستعلام أقرب إلى:

SELECT
    id,
    title,
    published_at,
    author_id
FROM books;

واستعلام المؤلفين سيطلب فقط الحقول التي حددناها للعلاقة.

عند تقييد أعمدة العلاقات في Eloquent، احرص على تضمين المفاتيح اللازمة لربط العلاقة، مثل المفتاح الأساسي والمفتاح الأجنبي عند الحاجة.

لماذا نحتاج author_id؟

من الأخطاء الشائعة كتابة:

Book::select('id', 'title', 'published_at')
    ->with('author:id,name')
    ->get();

مع نسيان:

author_id

Eloquent يحتاج المفتاح الأجنبي الموجود في الكتاب لمعرفة المؤلف المرتبط به.

لذلك يجب أن يكون ضمن الأعمدة المحددة:

->select([
    'id',
    'title',
    'published_at',
    'author_id',
])

نسخة محسنة من المثال الأصلي

use App\Models\Book;

public function index()
{
    $years = Book::query()
        ->select([
            'id',
            'title',
            'published_at',
            'author_id',
        ])
        ->with('author:id,name')
        ->latest('published_at')
        ->get()
        ->groupBy(fn ($book) => $book->published_at->format('Y'));

    return view('books', compact('years'));
}

إذا كان published_at معرفًا كـ cast في Model:

protected function casts(): array
{
    return [
        'published_at' => 'datetime',
    ];
}

فلا نحتاج إلى تنفيذ:

Carbon::parse($book->published_at)

في كل دورة. يمكن استخدام:

$book->published_at->format('Y')

مباشرة، وهو أيضًا أوضح من ناحية تصميم الكود.

هل select() يجعل التطبيق أسرع دائمًا؟

ليس بالضرورة.

استخدام select() يقلل كمية البيانات المطلوبة، وقد يقلل استهلاك الذاكرة وحجم البيانات المنقولة من قاعدة البيانات، خصوصًا عندما يحتوي الجدول على أعمدة كبيرة.

لكن إذا كان الجدول يحتوي فقط على:

id
title
published_at

فقد يكون الفرق محدودًا.

أما إذا كان يحتوي على:

LONGTEXT
JSON
TEXT
BLOB
large metadata

فقد يصبح الفرق كبيرًا.

لا تعتمد على أرقام ثابتة مثل "انخفض الاستهلاك من 31MB إلى 4MB" كقاعدة عامة. النتيجة تختلف حسب عدد السجلات، حجم الأعمدة، إصدار PHP وLaravel، العلاقات المحملة وإعدادات البيئة.

كيف نقيس استهلاك الذاكرة بشكل صحيح؟

يمكن استخدام أدوات مثل Laravel Debugbar أثناء التطوير، لكن يمكن أيضًا إجراء قياسات مباشرة باستخدام PHP.

$startMemory = memory_get_usage(true);
$startTime = microtime(true);

$books = Book::query()
    ->select([
        'id',
        'title',
        'published_at',
        'author_id',
    ])
    ->with('author:id,name')
    ->get();

$endMemory = memory_get_usage(true);
$endTime = microtime(true);

logger()->info('Books benchmark', [
    'memory_mb' => round(
        ($endMemory - $startMemory) / 1024 / 1024,
        2
    ),

    'peak_memory_mb' => round(
        memory_get_peak_usage(true) / 1024 / 1024,
        2
    ),

    'execution_ms' => round(
        ($endTime - $startTime) * 1000,
        2
    ),

    'records' => $books->count(),
]);

هذا يسمح لنا بمقارنة أكثر من تنفيذ بدل الاعتماد على الانطباع.

ما الذي يجب قياسه؟

Memory Usage
Peak Memory
Execution Time
Database Query Time
Number of Queries
Number of Records
Response Size

لأن انخفاض الذاكرة وحده لا يعني بالضرورة أن الحل أصبح أسرع في جميع الحالات.

لكن ماذا لو كان لدينا مليون كتاب؟

هنا نصل إلى النقطة الأهم.

حتى لو استخدمت:

select('id', 'title')

ثم نفذت:

->get()

على مليون سجل، فأنت ما زلت تحاول تحميل عدد ضخم من النتائج إلى ذاكرة التطبيق.

وهذا يعني أن select() تحسين مهم، لكنه ليس استراتيجية لمعالجة البيانات الضخمة بمفرده.

الحل في صفحات العرض: Pagination

إذا كان المستخدم لا يحتاج إلى مشاهدة جميع الكتب دفعة واحدة، فلا تجلبها جميعًا.

$books = Book::query()
    ->select([
        'id',
        'title',
        'published_at',
        'author_id',
    ])
    ->with('author:id,name')
    ->latest('published_at')
    ->paginate(50);

الآن أصبح التدفق:

Database
   │
   ▼
50 Books
   │
   ▼
Laravel
   │
   ▼
Blade
   │
   ▼
User

Next Page
   │
   ▼
50 Books أخرى

بدلًا من تحميل عشرات الآلاف من الكتب في Request واحد.

متى نستخدم Pagination؟

تعتبر مناسبة جدًا في:

  • لوحات التحكم.
  • قوائم المستخدمين.
  • المنتجات.
  • الطلبات.
  • المقالات.
  • سجلات العمليات.
  • نتائج البحث.

ماذا عن معالجة البيانات في الخلفية؟

لنفترض أن لدينا 2,000,000 كتاب ونريد تنفيذ عملية عليهم، مثل تحديث بيانات أو تصدير سجلات.

هذه الطريقة خطرة:

$books = Book::all();

foreach ($books as $book) {
    // processing
}

لأن جميع السجلات يتم تحميلها إلى الذاكرة.

استخدام chunkById()

في عمليات المعالجة الكبيرة يمكن معالجة البيانات على دفعات:

Book::query()
    ->select([
        'id',
        'title',
        'published_at',
    ])
    ->chunkById(1000, function ($books) {

        foreach ($books as $book) {

            // Process book...

        }

    });

بدلًا من:

2,000,000 records
        │
        ▼
      Memory

نصبح أقرب إلى:

Database

   │
   ├── 1000 records ──► Process
   │
   ├── 1000 records ──► Process
   │
   ├── 1000 records ──► Process
   │
   ├── 1000 records ──► Process
   │
   └── ...

وبالتالي لا نحتاج إلى الاحتفاظ بكامل dataset في الذاكرة في اللحظة نفسها.

متى نستخدم lazy() أو lazyById()؟

إذا أردنا التعامل مع النتائج بأسلوب قريب من Collection مع الحفاظ على استهلاك أكثر انضباطًا للذاكرة، يمكن استخدام Lazy Collections.

Book::query()
    ->select([
        'id',
        'title',
        'published_at',
    ])
    ->lazyById(1000)
    ->each(function (Book $book) {

        // Process one book...

    });

هذا الأسلوب مفيد في عمليات مثل:

  • تصدير البيانات.
  • معالجة السجلات القديمة.
  • Jobs طويلة.
  • عمليات المزامنة.
  • إعادة بناء بيانات مشتقة.

هل يجب أن نقوم بالتجميع داخل PHP أصلًا؟

في المثال:

->get()
->groupBy(...)

قاعدة البيانات تعيد الكتب، ثم PHP يقوم بالتجميع.

لكن إذا كان المطلوب إحصائية فقط، مثل عدد الكتب المنشورة في كل سنة، فلا يوجد سبب لتحميل جميع Models إلى PHP.

مثال غير فعال للإحصائيات

$years = Book::all()
    ->groupBy(fn ($book) => $book->published_at->format('Y'))
    ->map
    ->count();

إذا كان لدينا مليون كتاب، فهذا يعني تحميل مليون Model فقط لحساب عدد الكتب في كل سنة.

دع قاعدة البيانات تنفذ التجميع

في MySQL مثلًا:

use Illuminate\Support\Facades\DB;

$years = Book::query()
    ->selectRaw('YEAR(published_at) as year, COUNT(*) as total')
    ->whereNotNull('published_at')
    ->groupByRaw('YEAR(published_at)')
    ->orderByDesc('year')
    ->get();

فتصبح النتيجة:

[
    { year: 2026, total: 18432 },
    { year: 2025, total: 15721 },
    { year: 2024, total: 12987 }
]

بدل نقل عشرات الآلاف من الصفوف إلى PHP.

مقارنة تدفق البيانات

التجميع في PHP:

Database
   │
   ▼
100,000 rows
   │
   ▼
Network / DB Driver
   │
   ▼
100,000 Eloquent Models
   │
   ▼
PHP Memory
   │
   ▼
groupBy()
   │
   ▼
3 أو 10 مجموعات

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

Database
   │
   ▼
GROUP BY
   │
   ▼
10 rows
   │
   ▼
Laravel
   │
   ▼
Response
إذا كانت قاعدة البيانات تستطيع تقليل dataset قبل إرساله إلى التطبيق، فغالبًا من الأفضل أن تقوم بذلك هناك، خصوصًا في عمليات العد والتجميع والتصفية.

مشكلة N+1 التي يجب الانتباه إليها

لنفترض أننا كتبنا:

$books = Book::query()
    ->select('id', 'title', 'author_id')
    ->get();

foreach ($books as $book) {
    echo $book->author->name;
}

إذا لم تكن العلاقة محملة مسبقًا، فقد ينتج عن ذلك استعلام إضافي أثناء الوصول إلى علاقة كل كتاب.

الحل:

$books = Book::query()
    ->select([
        'id',
        'title',
        'author_id',
    ])
    ->with('author:id,name')
    ->get();

بهذا نجمع بين تحسينين مختلفين:

Selective Columns
        +
Eager Loading
        │
        ▼
بيانات أقل + استعلامات أكثر انضباطًا

خطأ Production شائع: تحسين الذاكرة على حساب عدد الاستعلامات

قد يقرر مطور إزالة:

->with('author')

لأنها تستهلك ذاكرة إضافية.

لكن إذا كانت الصفحة تحتاج إلى بيانات المؤلف، فقد يؤدي ذلك إلى مشكلة N+1.

لذلك لا يمكن تقييم الأداء باستخدام مؤشر واحد فقط.

يجب مراقبة:

Memory
Queries
Query Time
CPU
Response Time
Transferred Data

ماذا نستخدم في كل سيناريو؟

سيناريو 1: صفحة تحتوي على 30 كتابًا

select()
+
with()
+
get()

حل طبيعي ولا داعي لتعقيد الكود.

سيناريو 2: صفحة تحتوي على آلاف الكتب

select()
+
with()
+
paginate()

لا يوجد سبب عملي غالبًا لعرض آلاف العناصر في HTML دفعة واحدة.

سيناريو 3: معالجة مليون سجل

select()
+
chunkById()

أو:

select()
+
lazyById()

سيناريو 4: حساب إحصائيات

استخدم SQL قدر الإمكان:

COUNT()
SUM()
AVG()
MIN()
MAX()
GROUP BY

بدل تحميل كامل البيانات ثم حساب النتيجة في PHP.

سيناريو 5: API

إذا كانت API تحتاج:

{
    "id": 18,
    "title": "Laravel Performance"
}

فلا يوجد سبب منطقي لجلب:

content
description
internal_notes
metadata
created_at
updated_at
...

من قاعدة البيانات إذا لم تكن مطلوبة أصلًا.

استخدام API Resources لا يغني عن select()

هذه نقطة مهمة.

إذا كتبت:

return [
    'id' => $this->id,
    'title' => $this->title,
];

داخل Resource، فهذا يحدد البيانات التي سيتم إرسالها للمستخدم، لكنه لا يعني بالضرورة أن قاعدة البيانات لم تجلب بقية الأعمدة.

هناك فرق بين:

Database Selection
        │
        ▼
select()

Response Transformation
        │
        ▼
API Resource

الأول يتحكم فيما تطلبه من قاعدة البيانات، والثاني يتحكم فيما ترسله إلى العميل.

ماذا عن Indexes؟

تقليل الأعمدة لا يحل جميع مشاكل الأداء.

في مثالنا نستخدم:

ORDER BY published_at DESC

ومع نمو الجدول، يجب أيضًا دراسة الفهارس المناسبة للاستعلامات المتكررة.

مثلًا قد يكون وجود index على:

published_at

مفيدًا لبعض أنماط الاستعلام.

لكن قرار إضافة Index يجب أن يعتمد على الاستعلامات الحقيقية وخطة التنفيذ وليس على قاعدة عامة.

يمكن تحليل الاستعلام باستخدام:

EXPLAIN SELECT ...

أو الأدوات التي توفرها قاعدة البيانات المستخدمة.

مثال Production أكثر واقعية

لنفترض أن لدينا متجرًا يحتوي على 500,000 طلب. لوحة الإدارة تريد عرض آخر الطلبات مع اسم العميل.

تنفيذ مثل:

$orders = Order::with('customer')
    ->latest()
    ->get();

تصميم سيئ لصفحة ويب عادية، لأنك لا تحتاج إلى نصف مليون طلب في Request واحد.

تنفيذ أكثر منطقية:

$orders = Order::query()
    ->select([
        'id',
        'customer_id',
        'status',
        'total',
        'created_at',
    ])
    ->with([
        'customer:id,name,email'
    ])
    ->latest('id')
    ->paginate(50);

تدفق البيانات:

500,000 Orders in Database
          │
          ▼
     SQL Filtering
          │
          ▼
   Required Columns
          │
          ▼
     50 Records
          │
          ▼
      Eloquent
          │
          ▼
        Blade
          │
          ▼
       Browser

هذه هي الفكرة التي يجب أن نبني عليها استعلامات Production:

ليس المهم كم عدد السجلات الموجودة في قاعدة البيانات، بل كم سجلًا وكم بايتًا يحتاج هذا الـ Request تحديدًا.

أخطاء شائعة عند تحسين استعلامات Laravel

1. استخدام get() في كل مكان

لا تستخدم get() تلقائيًا قبل التفكير في حجم النتائج.

2. استخدام SELECT * بدون حاجة

خصوصًا في الجداول التي تحتوي على أعمدة كبيرة.

3. نسيان المفاتيح عند استخدام select()

حذف author_id مثلًا قد يمنع Eloquent من ربط العلاقة بصورة صحيحة.

4. حل N+1 بتحميل علاقات ضخمة بلا داعٍ

Eager Loading مهم، لكن حمّل العلاقات والأعمدة التي يحتاجها السيناريو فقط.

5. تنفيذ Aggregation في PHP

لا تحمل مليون سجل لتنفيذ count() أو sum() إذا كان SQL يستطيع إعطاءك النتيجة مباشرة.

6. افتراض أن select() يحل مشكلة البيانات الضخمة

هو يقلل حجم كل سجل، لكنه لا يمنعك من تحميل مليون سجل إذا استخدمت get().

7. الاعتماد على أرقام Benchmark من جهاز آخر

رقم مثل:

31 MB → 4 MB

قد يكون صحيحًا في اختبار معين، لكنه ليس نتيجة يمكن ضمانها في كل تطبيق.

قم بالقياس داخل بيئتك وببيانات قريبة من Production.

قائمة مراجعة قبل إطلاق الاستعلام إلى Production

[ ] هل أحتاج فعلًا إلى جميع السجلات؟

[ ] هل أحتاج جميع الأعمدة؟

[ ] هل العلاقات المطلوبة محددة؟

[ ] هل حددت أعمدة العلاقات؟

[ ] هل أحتاج Pagination؟

[ ] هل العملية الكبيرة تحتاج chunkById أو lazyById؟

[ ] هل يمكن تنفيذ aggregation داخل SQL؟

[ ] هل يوجد N+1؟

[ ] هل الاستعلام يحتاج Index مناسبًا؟

[ ] هل قمت بقياس Peak Memory؟

[ ] هل قمت بقياس Query Time؟

[ ] هل اختبرت الاستعلام بكمية بيانات قريبة من Production؟

الخلاصة

تحسين استهلاك الذاكرة في Laravel لا يعني إضافة select() إلى الاستعلام وانتهاء المشكلة. الأداء الجيد يبدأ من فهم دورة حياة البيانات منذ لحظة طلبها من قاعدة البيانات وحتى إرسال النتيجة إلى المستخدم.

ابدأ دائمًا بالسؤال:

ما أقل كمية من البيانات التي يحتاجها هذا الـ Request لتنفيذ مهمته؟

إذا كنت تحتاج أربعة أعمدة، فلا تجلب عشرين. إذا كنت تحتاج 50 سجلًا، فلا تجلب 50 ألفًا. إذا كنت تحتاج عدد السجلات، فلا تحمل السجلات نفسها. وإذا كنت تعالج ملايين الصفوف، فلا تضعها جميعًا في الذاكرة في الوقت نفسه.

يمكن تلخيص الاستراتيجية كالتالي:

Small Dataset
    │
    └── select() + with() + get()

Large UI Dataset
    │
    └── select() + with() + paginate()

Large Processing Job
    │
    ├── chunkById()
    │
    └── lazyById()

Statistics / Reports
    │
    └── SQL Aggregation

Related Data
    │
    └── Eager Loading + Required Columns

Performance Problem
    │
    └── Measure → Analyze → Optimize → Measure Again

بهذه الطريقة يصبح تحسين الذاكرة جزءًا من تصميم التطبيق نفسه، وليس محاولة إصلاح المشكلة بعد وصول السيرفر إلى حد memory_limit.

مراجع تقنية

للمزيد من التفاصيل، يُنصح بالرجوع إلى التوثيق الرسمي لـ Laravel حول Eloquent وQuery Builder وطرق التعامل مع مجموعات البيانات الكبيرة، بالإضافة إلى توثيق PHP الرسمي الخاص بقياس استهلاك الذاكرة.


بيانات SEO

SEO Title

تقليل استهلاك الذاكرة وتحسين أداء Laravel عند جلب البيانات من قاعدة البيانات

SEO Description

دليل عملي متقدم لتقليل استهلاك الذاكرة في Laravel وتحسين استعلامات Eloquent باستخدام select وEager Loading وPagination وchunkById وlazyById وSQL Aggregation مع أمثلة Production عملية.

SEO Keywords

Laravel Performance, Laravel Memory Optimization, Laravel Eloquent, تحسين أداء Laravel, تقليل استهلاك الذاكرة Laravel, Laravel select, Laravel chunkById, Laravel lazyById, Laravel Pagination, Eloquent Performance, Laravel Database Optimization, Laravel Query Optimization, تحسين استعلامات Laravel, PHP Memory Optimization, Laravel Production

Slug

laravel-memory-database-query-optimization