تحسين أداء Laravel: الكاش والفهارس واستعلامات Eloquent
دليل عملي لتحسين أداء Laravel عبر قياس الاستعلامات، وضبط الكاش والفهارس، وتجنب N+1، مع أمثلة Eloquent وأوامر نشر قابلة للتطبيق.

لماذا يبدأ تحسين أداء Laravel بالقياس؟
تحسين أداء Laravel ليس مجموعة أوامر تُنفّذ مرة واحدة، بل دورة تبدأ بقياس المشكلة وتنتهي بالتحقق من أثر الحل. قد تكون الصفحة بطيئة بسبب استعلام غير مفهرس، أو تحميل علاقات كثيرة، أو اتصال بخدمة خارجية، وليس بسبب إطار العمل نفسه. لذلك، لا تبدأ بإضافة الكاش إلى كل شيء. اختر مسارًا مهمًا، مثل قائمة المنتجات أو لوحة المبيعات، وحدد زمن استجابته وعدد استعلاماته وحجم البيانات التي يعيدها.
استخدم بيانات قريبة من حجم الإنتاج، لأن جدولًا يحتوي على خمسين سجلًا لن يكشف المشكلات التي تظهر عند وصوله إلى مليون سجل. كرر الطلب عدة مرات، وافصل بين الطلب الأول والطلبات اللاحقة، وسجل الوسيط والمئين الخامس والتسعين بدل الاكتفاء بمتوسط واحد. راقب كذلك استهلاك الذاكرة ونسبة الأخطاء؛ فالتحسين الحقيقي لا يسرّع التطبيق على حساب صحته أو استقراره.
راقب الاستعلامات في بيئة التطوير
يمكن تسجيل الاستعلامات البطيئة داخل دالة boot في AppServiceProvider. المثال التالي يسجل الاستعلامات التي تتجاوز مئة مللي ثانية محليًا. هذه العتبة نقطة بداية وليست معيارًا ثابتًا. تجنب تسجيل القيم المرتبطة بالاستعلام دون تنقيح، فقد تتضمن بيانات شخصية أو رموزًا حساسة. كذلك، لا تجعل التسجيل التفصيلي عبئًا دائمًا على الإنتاج.
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function boot(): void
{
if (app()->environment('local')) {
DB::listen(function (QueryExecuted $query) {
if ($query->time > 100) {
Log::debug('Slow query', [
'sql' => $query->sql,
'time_ms' => $query->time,
]);
}
});
}
}عالج مشكلة N+1 قبل التفكير في الكاش
تحدث مشكلة N+1 عندما تجلب قائمة سجلات ثم تنفذ استعلامًا إضافيًا لكل سجل للوصول إلى علاقة. مثلًا، عرض ثلاثين مقالًا مع اسم الكاتب قد ينتج واحدًا وثلاثين استعلامًا بدل استعلامين. يبدو الكود بسيطًا، لكن تكلفة الاتصال بقاعدة البيانات تتضاعف مع نمو الصفحة. الحل المعتاد هو التحميل المسبق للعلاقات باستخدام with، وليس تخزين النتيجة البطيئة مباشرة في الكاش.
use App\Models\Post;
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at'])
->with('user:id,name')
->where('status', 'published')
->orderByDesc('published_at')
->orderByDesc('id')
->paginate(20);
foreach ($posts as $post) {
echo $post->user?->name;
}عند تحديد أعمدة العلاقات، احتفظ بالمفاتيح اللازمة لربط النتائج. نحتاج هنا إلى user_id في المقال وإلى id في المستخدم. لا تحمّل جميع العلاقات احتياطيًا؛ فهذا يستهلك الذاكرة وينقل بيانات غير مستخدمة. راجع ما يحتاجه القالب أو مورد الواجهة فعلًا، ثم حمّل العلاقات المناسبة لذلك المسار فقط.
امنع التحميل الكسول غير المقصود
فعّل منع التحميل الكسول خارج الإنتاج حتى تظهر الأخطاء أثناء التطوير والاختبارات. لا يغني ذلك عن مراجعة الاستعلامات، لكنه يمنع تكرار المشكلة عند إضافة حقول جديدة إلى الواجهات. وإذا كنت تحتاج عدد التعليقات فقط، فاستخدم withCount بدل تحميل نماذج التعليقات كاملة ثم عدّها داخل PHP.
use Illuminate\Database\Eloquent\Model;
// Inside AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
$posts = Post::query()
->withCount('comments')
->paginate(20);قلّل البيانات التي تعالجها استعلامات Eloquent
اختيار الأعمدة المطلوبة يقلل نقل البيانات وكلفة تحويل الصفوف إلى نماذج. استخدم exists عندما تريد معرفة وجود سجل، وcount عندما تريد العدد، وpluck عندما تحتاج قائمة قيم فقط. تجنب get ثم إجراء التصفية أو التجميع داخل PHP إذا كانت قاعدة البيانات تستطيع تنفيذ العملية بكفاءة. لكن تذكر أن اختصار الكود لا يضمن تلقائيًا خطة تنفيذ أفضل.
use App\Models\Order;
$hasPendingOrders = Order::query()
->where('user_id', $userId)
->where('status', 'pending')
->exists();
$total = Order::query()
->where('user_id', $userId)
->where('status', 'paid')
->sum('total');اختر التصفح والمعالجة على دفعات بوعي
تستخدم paginate استعلامًا لحساب إجمالي النتائج، بينما تتجنب simplePaginate هذا الحساب عندما تكفي روابط السابق والتالي. وللقوائم الكبيرة، قد يكون cursorPaginate أنسب من الإزاحات العميقة، بشرط ترتيب ثابت وفريد ومدعوم بفهرس مناسب. أما مهام المعالجة، فنفذها بدفعات باستخدام chunkById أو lazyById بدل تحميل الجدول كاملًا. لا تعدّل عمود الترتيب أثناء المرور على الدفعات.
$orders = Order::query()
->where('user_id', $userId)
->orderByDesc('id')
->cursorPaginate(50);
Order::query()
->select(['id', 'status'])
->where('status', 'pending')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
// Perform an idempotent operation here.
}
});صمّم الفهارس وفق شروط البحث والترتيب
الفهرس الجيد يعكس شكل الاستعلام المتكرر. إذا كانت صفحة الطلبات ترشح حسب المستخدم والحالة، ثم ترتب بحسب المعرف، فقد يفيد فهرس مركب على user_id وstatus وid. لا تضف فهرسًا لكل عمود عشوائيًا؛ فالفهارس تستهلك مساحة وتزيد تكلفة الإدراج والتحديث والحذف. افحص الفهارس الموجودة أولًا لتجنب التكرار، وقيّم تأثير أي إضافة على عمليات الكتابة.
php artisan make:migration add_user_status_id_index_to_orders_table --table=ordersuse Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index(
['user_id', 'status', 'id'],
'orders_user_status_id_idx'
);
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_user_status_id_idx');
});
}
};ترتيب أعمدة الفهرس مهم. الفهرس السابق لا يضمن أفضل أداء لاستعلام يعتمد على status وحده، كما أنه لا يطابق بالضرورة قائمة مستخدم مرتبة بالمعرف دون شرط الحالة. انطلق من استعلام فعلي، لا من أسماء الأعمدة. قبل تنفيذ الترحيل على جدول ضخم، تحقق من سلوك محرك قاعدة البيانات وإمكان حجز الجدول، ثم اختر نافذة نشر مناسبة.
تحقق من خطة التنفيذ باستخدام EXPLAIN
في MySQL، يساعد EXPLAIN على معرفة الفهرس المختار والعدد التقديري للصفوف وخطوات الفرز. افحص الخطة قبل الإضافة وبعدها، ثم قارن الزمن تحت ظروف متشابهة. ظهور فهرس في الخطة ليس دليلًا كافيًا على نجاح التحسين؛ فقد يظل الاستعلام يقرأ صفوفًا كثيرة. كذلك، تختلف تفاصيل الخطط وأدوات تحليلها بين محركات قواعد البيانات.
EXPLAIN
SELECT id, total
FROM orders
WHERE user_id = 42
AND status = 'paid'
ORDER BY id DESC
LIMIT 50;استخدم الكاش للبيانات المناسبة لا لإخفاء العيوب
يناسب الكاش النتائج المكلفة التي تتكرر قراءتها وتتحمل قدرًا معلومًا من التأخر، مثل قائمة التصنيفات أو مؤشرات عامة. حدد أولًا مدة صلاحية البيانات، ومن يحق له رؤيتها، ومتى يجب إبطالها. لا تخزن نتيجة شخصية تحت مفتاح مشترك. يجب أن يتضمن المفتاح كل ما يغير النتيجة، مثل المستخدم والمتجر واللغة والمرشحات ذات الصلة.
use Illuminate\Support\Facades\Cache;
$key = 'dashboard:v1:user:'.$userId.':paid-total';
$total = Cache::remember($key, now()->addMinutes(5), function () use ($userId) {
return Order::query()
->where('user_id', $userId)
->where('status', 'paid')
->sum('total');
});الإصدار v1 داخل المفتاح يسهّل الانتقال إلى بنية جديدة دون تفسير قيم قديمة بطريقة خاطئة. ومدة الخمس دقائق مثال يحتاج ضبطًا وفق متطلبات المنتج. لا تعتمد على هذا الإجمالي المخزن لاتخاذ قرار مالي حساس؛ ارجع إلى البيانات الأصلية وقواعد الاتساق المناسبة عند التنفيذ. الكاش طبقة تسريع، وليس بديلًا عن مصدر الحقيقة.
اضبط مخزن الكاش وخطة الإبطال
في مشروع حديث يستخدم CACHE_STORE داخل config/cache.php، يمكن اختيار Redis عبر الإعداد التالي. إذا لم يتوفر امتداد PhpRedis، يمكن تثبيت عميل Predis باستخدام Composer. اختر عميلًا واحدًا واضبط الاتصال بخدمة Redis العاملة. أما المشاريع الأقدم، فقد تستخدم CACHE_DRIVER؛ راجع ملف الإعداد الفعلي بدل افتراض اسم متغير ثابت لكل الإصدارات.
composer require predis/predis
# .env
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379بعد نجاح تعديل الطلب واعتماد المعاملة، أبطل المفتاح المرتبط بالمستخدم. إذا كانت الكتابات تحدث من مسارات متعددة، فاجمع منطق الإبطال في خدمة أو مستمع أحداث يعمل بعد الاعتماد. انتبه إلى أن طلبًا متزامنًا قد يعيد ملء المفتاح بقيمة قديمة؛ التطبيقات ذات الاتساق الصارم تحتاج استراتيجية أقوى، مثل مفاتيح بإصدارات متغيرة أو تجاوز الكاش للقراءات الحساسة.
DB::transaction(function () use ($order) {
$order->update(['status' => 'paid']);
$userId = $order->user_id;
DB::afterCommit(function () use ($userId) {
Cache::forget('dashboard:v1:user:'.$userId.':paid-total');
});
});تجنب تدافع الطلبات عند انتهاء الكاش
عند انتهاء مفتاح شائع، قد تحاول طلبات كثيرة إعادة حسابه في اللحظة نفسها. Cache::remember لا يوفر قفلًا تلقائيًا يمنع هذه الحالة. استخدم قفلًا موزعًا مع مخزن يدعم الأقفال، وأعد فحص القيمة بعد الحصول عليه. اضبط مدة القفل فوق زمن الحساب المتوقع، وعالج مهلة الانتظار باستجابة محسوبة أو قيمة قديمة مقبولة، لا باستعلامات احتياطية غير محدودة.
$value = Cache::get($key);
if ($value === null) {
$value = Cache::lock($key.':lock', 15)->block(3, function () use ($key, $userId) {
return Cache::remember($key, 300, function () use ($userId) {
return Order::query()
->where('user_id', $userId)
->where('status', 'paid')
->sum('total');
});
});
}يفترض المثال أن null ليست قيمة صالحة للنتيجة، وأن التطبيق يعالج LockTimeoutException في طبقته المناسبة. وفي بيئة متعددة الخوادم، يجب أن تتشارك جميع النسخ مخزن القفل نفسه. يمكن أيضًا توزيع أوقات انتهاء المفاتيح بإضافة تفاوت صغير إلى مدة الصلاحية، لتجنب موجة إعادة حساب جماعية.
جهّز Laravel للإنتاج دون مفاجآت
بعد تحسين مسار البيانات، جهّز ملفات التطبيق للإنتاج. نفذ الأوامر داخل عملية نشر مجربة، وبعد توفير متغيرات البيئة الصحيحة. يعمل Composer على استبعاد اعتماديات التطوير وتحسين التحميل التلقائي، بينما تجمع أوامر Artisan الإعدادات والمسارات والقوالب. هذه الخطوات تقلل العمل المتكرر، لكنها لا تصلح استعلامًا سيئًا أو اتصالًا خارجيًا بطيئًا.
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restartاضبط APP_DEBUG=false في الإنتاج، واستخدم env داخل ملفات الإعداد فقط، ثم اقرأ القيم عبر config في التطبيق. إعادة تشغيل عمال الطوابير مهمة لتحميل الكود الجديد، ويجب أن يديرهم مشرف عمليات يعيد تشغيلهم بعد خروجهم. وإذا كان المشروع يستخدم عمليات طويلة العمر أخرى، فاتبع آلية إعادة تحميلها الخاصة ضمن خطة النشر.
قائمة تحقق لقياس النتيجة والمحافظة عليها
اختبر الصفحة نفسها بالبيانات والصلاحيات والمرشحات ذاتها قبل التعديل وبعده. قارن الكاش البارد والدافئ، واختبر تزامن الطلبات بدل الاكتفاء بطلب منفرد. أضف اختبارات للتأكد من عزل بيانات المستخدمين وإبطال النتائج بعد التحديث. ثم راقب التطبيق بعد النشر، لأن توزيع البيانات وحركة الاستخدام الفعلية قد يختلفان عن بيئة الاختبار.
- سجل زمن الاستجابة وعدد الاستعلامات واستهلاك الذاكرة قبل التحسين.
- عالج N+1 والتحميل الزائد قبل إضافة طبقات تخزين مؤقت.
- اربط كل فهرس باستعلام واضح وتحقق من خطة تنفيذه.
- حدد صلاحية الكاش ومفاتيحه وسياسة الإبطال وسلوك الفشل.
- راقب المئين الخامس والتسعين والأخطاء وتكلفة الكتابة بعد النشر.
الترتيب العملي هو القياس، ثم تقليل العمل، ثم تسريع الوصول بالفهرسة، وأخيرًا إعادة استخدام النتائج بالكاش. باتباع هذا التسلسل، يصبح تحسين أداء Laravel عملية قابلة للتفسير والاختبار، لا مجموعة تغييرات يصعب معرفة سبب نجاحها أو فشلها.
Why Laravel Performance Starts with Measurement
Choose an important endpoint and establish a baseline: response latency, query count, memory usage, and errors. Test with production-like data volumes. Separate cold and warm requests, and compare median and p95 latency rather than relying on one average.
Monitor Queries in Development
Register a query listener in AppServiceProvider. Avoid logging sensitive bindings or enabling unrestricted query logging in production.
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function boot(): void
{
if (app()->environment('local')) {
DB::listen(function (QueryExecuted $query) {
if ($query->time > 100) {
Log::debug('Slow query', [
'sql' => $query->sql,
'time_ms' => $query->time,
]);
}
});
}
}Fix N+1 Queries Before Adding Cache
Accessing an unloaded relationship inside a loop can execute one additional query per record. Eager-load only the relationships the endpoint needs, keeping the keys required to match related models.
use App\Models\Post;
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at'])
->with('user:id,name')
->where('status', 'published')
->orderByDesc('published_at')
->orderByDesc('id')
->paginate(20);Prevent Accidental Lazy Loading
Disable lazy loading outside production to expose mistakes early. Use withCount when you need a relationship count rather than its full collection.
use Illuminate\Database\Eloquent\Model;
// Inside AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
$posts = Post::withCount('comments')->paginate(20);Reduce the Data Eloquent Processes
Select necessary columns and push suitable filtering and aggregation into the database. Use exists for existence checks, count for counts, and pluck for individual columns.
use App\Models\Order;
$hasPendingOrders = Order::query()
->where('user_id', $userId)
->where('status', 'pending')
->exists();Choose Pagination and Batching Deliberately
simplePaginate avoids a total-count query. cursorPaginate can avoid expensive deep offsets when supported by a stable, unique ordering and suitable index. Process large datasets with chunkById or lazyById without changing their ordering key.
$orders = Order::query()
->where('user_id', $userId)
->orderByDesc('id')
->cursorPaginate(50);
Order::query()
->where('status', 'pending')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
// Perform an idempotent operation here.
}
});Design Indexes Around Filters and Ordering
A query filtering by user and status, then ordering by ID, may benefit from a composite index on those columns. Indexes consume storage and add write overhead. Check existing indexes and plan large-table migrations around your database engine's locking behavior.
php artisan make:migration add_user_status_id_index_to_orders_table --table=orders// In the migration's up() method:
Schema::table('orders', function (Blueprint $table) {
$table->index(
['user_id', 'status', 'id'],
'orders_user_status_id_idx'
);
});
// In down():
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_user_status_id_idx');
});Import Illuminate\Support\Facades\Schema and Illuminate\Database\Schema\Blueprint. Column order matters: this index is not automatically ideal for status-only filtering or user-only filtering ordered by ID.
Inspect the Execution Plan with EXPLAIN
For MySQL, inspect the chosen index, estimated rows, and sorting work. Compare actual latency too; index usage alone does not prove that a query is efficient.
EXPLAIN
SELECT id, total
FROM orders
WHERE user_id = 42
AND status = 'paid'
ORDER BY id DESC
LIMIT 50;Cache Appropriate Data, Not Unresolved Problems
Cache repeated, expensive reads that tolerate defined staleness. Include every result-changing dimension in the key, such as user, tenant, locale, and filters. Never share personalized results through a global key.
use Illuminate\Support\Facades\Cache;
$key = 'dashboard:v1:user:'.$userId.':paid-total';
$total = Cache::remember($key, now()->addMinutes(5), function () use ($userId) {
return Order::query()
->where('user_id', $userId)
->where('status', 'paid')
->sum('total');
});Versioned keys simplify format changes. Treat cached totals as display data, not an authoritative basis for sensitive financial decisions.
Configure Storage and Invalidation
For projects whose cache configuration uses CACHE_STORE, select Redis as follows. Predis is an option when PhpRedis is unavailable. Older projects may use CACHE_DRIVER; inspect config/cache.php.
composer require predis/predis
# .env
CACHE_STORE=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379Invalidate affected keys after the database transaction commits. Centralize invalidation across write paths. Concurrent requests can still repopulate stale data, so strict consistency may require versioned invalidation or bypassing cache.
DB::transaction(function () use ($order) {
$order->update(['status' => 'paid']);
$userId = $order->user_id;
DB::afterCommit(function () use ($userId) {
Cache::forget('dashboard:v1:user:'.$userId.':paid-total');
});
});Prevent Cache Stampedes
Cache::remember does not automatically lock cache regeneration. Use a supported distributed lock, recheck the cache inside it, and explicitly handle lock timeouts. All application servers must share the lock store.
$value = Cache::get($key);
if ($value === null) {
$value = Cache::lock($key.':lock', 15)->block(3, function () use ($key, $userId) {
return Cache::remember($key, 300, function () use ($userId) {
return Order::query()
->where('user_id', $userId)
->where('status', 'paid')
->sum('total');
});
});
}This assumes null is not a valid result. Handle LockTimeoutException in the application layer, size the lock lifetime for the computation, and consider expiration jitter for large groups of keys.
Prepare Laravel for Production
Run deployment commands after supplying the correct environment configuration. These optimize application bootstrapping; they do not repair slow SQL.
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restartSet APP_DEBUG=false. Use env only in configuration files and config elsewhere. A process supervisor should restart queue workers after they exit. Reload other long-running application processes using their appropriate deployment mechanism.
Validate and Maintain the Improvements
Compare identical endpoints, permissions, filters, and datasets. Test concurrency as well as cold and warm cache behavior. Verify user isolation and invalidation correctness before monitoring the production rollout.
- Record baseline latency, query count, and memory usage.
- Fix N+1 queries and excessive loading first.
- Connect each index to a verified query plan.
- Define cache freshness, keys, invalidation, and failure behavior.
- Monitor p95 latency, errors, and write costs after deployment.
The practical sequence is measurement, less work, better indexed access, and then cached reuse. This makes Laravel performance improvements explainable and testable.
عبدالرحمن ربيع
Software Engineer & AI Builder
مطور برمجيات متكامل ومصمم جرافيك مع أكثر من 4 سنوات خبرة في بناء تطبيقات الويب الحديثة باستخدام PHP و JavaScript و HTML و CSS. خلفية قوية في تصميم UI/UX واستخدام متقدم لأدوات الذكاء الاصطناعي لتعزيز كفاءة التطوير والأتمتة واتخاذ القرارات. حاصل على ماجستير تنفي...
التعليقات (0)
كن أول من يعلّق على هذا المقال.