save_post يعمل مرتين؟ أوقف الحلقة اللانهائية

save_post يعمل مرتين؟ أوقف الحلقة اللانهائية

بتحفظ ووردبريس للمحتوى المؤرخ مثل التدوينات والأخبار والشروحات. تُرتّب زمنيًا وتُنظَّم بالتصنيفات والوسوم وتظهر في الأرشيف وخلاصات RSS، بخلاف الصفحات الثابتة.">مقالة واحدة، والكود بتاعك بيشتغل تلات مرات. ولو كنت بتعدّل المقالة من جوه الكود نفسه، الشاشة بتفضل بتلف لحد ما ووردبريس يقع برسالة Allowed memory size exhausted.

المشكلة نادرًا ما تكون في منطق الكود. المشكلة أن معظم المطورين يتعاملون مع الخطّاف (Hook) المسمى save_post كأنه يعمل مرة واحدة عند الحفظ، وهو لا يفعل ذلك.

لماذا يعمل save_post أكثر من مرة

عملية حفظ واحدة في الإضافات والقوالب والمستخدمين والإعدادات، وما يظهر فيها يختلف حسب دور المستخدم.">لوحة التحكم (Dashboard) ليست عملية كتابة واحدة في السيرفر.">قاعدة البيانات (Database). ووردبريس قد ينفّذ حفظًا تلقائيًا، ثم ينشئ مراجعة للمقالة، ثم يكتب المقالة الحقيقية.

كل خطوة من هذه الخطوات تُطلق save_post. النتيجة أن الكود الذي كتبته لتحديث عدّاد أو لمزامنة خدمة خارجية يعمل ثلاث مرات على الأقل، ويسجّل ثلاث نتائج حيث كنت تتوقع واحدة.

هذا وحده ليس كارثة. الكارثة تبدأ في الخطوة التالية.

من أين تأتي الحلقة اللانهائية

الحل السريع الذي يلجأ إليه الكثيرون هو استدعاء wp_update_post() من داخل معالج save_post لتعديل شيء في المقالة نفسها — تصحيح العنوان، أو ضبط حقل.

لكن wp_update_post() تكتب في قاعدة البيانات، والكتابة تُطلق save_post مرة أخرى، فيعمل معالجك من جديد ويستدعي wp_update_post() من جديد. هذا هو الاستدعاء الذاتي اللانهائي (Recursion).

النهاية واحدة من ثلاث: الحفظ يتجمد، أو الإجراء يتكرر مرات لا تُحصى، أو ووردبريس ينهار بخطأ استنفاد الذاكرة.

وهذه ليست حالة نادرة. الخطّاف نفسه تتعلق عليه عادةً أكواد عدة إضافات في وقت واحد: إضافة السيو، إضافة الكاش، وكودك المخصص. حلقة واحدة في أي منها تعطّل الحفظ على الجميع. وعلى المواقع التي تحتوي آلاف المقالات، كل استدعاء زائد يعني أمر UPDATE إضافيًا وتشغيلًا كاملًا لسلسلة خطافات الحفظ من أولها.

الخطوة 1: فحوص الحماية أولًا

قبل أي منطق، استبعد الحفظ التلقائي والمراجعات وأنواع المحتوى التي لا تهمك. بدون هذه الفحوص سيعمل معالجك ضعف المرات التي يحتاجها على الأقل:

add_action( 'save_post', function( $post_id, $post, $update ) {  
    // Skip autosaves, revisions, and other post types right away.  
    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {  
        return;  
    }  
    if ( 'post' !== $post->post_type ) {  
        return;  
    }  
    if ( ! current_user_can( 'edit_post', $post_id ) ) {  
        return;  
    }

    // Your code here.  
}, 10, 3 );  

فحص current_user_can ليس زينة. save_post يعمل أيضًا عند الاستيراد ومن خلال الـ API في ووردبريس واجهة مدمجة تتيح قراءة المحتوى وإنشاءه وتعديله عبر روابط HTTP تحت /wp-json/، دون فتح لوحة التحكم. عليها تعتمد تطبيقات الموبايل والمواقع المنفصلة…">REST API، وبدون فحص بالدالة current_user_can قبل تنفيذ أي إجراء حساس مثل تعديل الحجوزات أو تسجيل الحضور.">الصلاحية قد ينفّذ كودك تعديلات نيابة عن مستخدم لا يملك حق التعديل.

الخطوة 2: اكسر الحلقة يدويًا عند تحديث المقالة

إذا كنت مضطرًا فعلًا لتحديث المقالة من داخل save_post، فالحل هو إزالة المعالج قبل الاستدعاء وإعادته بعده مباشرة. لاحظ أن هذا يتطلب دالة مسمّاة لا دالة مجهولة (Anonymous Function)، لأن remove_action تحتاج اسمًا لتتعرف عليه:

// Bad — infinite recursion: save_post calls wp_update_post(),  
// which fires save_post again, and so on in a loop.  
add_action( 'save_post', function( $post_id ) {  
    wp_update_post( array(  
        'ID'         => $post_id,  
        'post_title' => forwp_generate_title( $post_id ),  
    ) );  
} );

// Good — remove the handler before updating, add it back immediately after.  
add_action( 'save_post', 'forwp_normalize_title', 10, 3 );

function forwp_normalize_title( $post_id, $post, $update ) {  
    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {  
        return;  
    }

    remove_action( 'save_post', 'forwp_normalize_title', 10 );

    wp_update_post( array(  
        'ID'         => $post_id,  
        'post_title' => forwp_generate_title( $post_id ),  
    ) );

    add_action( 'save_post', 'forwp_normalize_title', 10, 3 );  
}  

ولاحظ الأهم: إذا كان كل ما تريد تغييره هو حقل مخصص (Custom Field) وليس حقلًا من حقول جدول المقالات نفسه، فلا تستخدم wp_update_post() من الأصل. استخدم update_post_meta() — فهي لا تُطلق save_post، وبذلك تختفي المشكلة قبل أن تبدأ.

الخطوة 3: الخطّاف المناسب للمهمة المناسبة

الخطافات الأربعة في هذه المجموعة تبدو متبادلة، لكن كل واحد منها مخصص للحظة معينة:

الخطّافمتى يعملالأنسب له
transition_post_statusقبل الحفظ، في لحظة تغيّر الحالة بالضبطالتفاعل مع انتقال محدد: مسودة ← نشر، أو نشر ← سلة المحذوفات
———
wp_insert_postبعد الكتابة في قاعدة البيانات مباشرة، قبل تفريغ الكاشإجراءات سريعة فور الإدراج: التسجيل، الإشعارات
save_postبعد wp_insert_post، وهو الأكثر ثباتًا والخيار الافتراضي لمعظم الإضافاتمنطق معالجة الحقول المخصصة عند الحفظ
before_delete_postقبل الحذف مباشرة، والمقالة لا تزال موجودةتنظيف البيانات المرتبطة — الفرصة الأخيرة قبل اختفاء السجل
// React specifically to publishing, not to every draft save.  
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {  
    if ( 'publish' === $new_status && 'publish' !== $old_status ) {  
        forwp_notify_subscribers( $post->ID );  
    }  
}, 10, 3 );

// Clean up related data before deletion — while the post still exists in the database.  
add_action( 'before_delete_post', function( $post_id, $post ) {  
    forwp_cleanup_related_records( $post_id );  
}, 10, 2 );  

الخطأ الشائع هو اللجوء إلى save_post في موضع يحتاج transition_post_status. النتيجة أن منطق «عند النشر» يعمل مع كل حفظة مسودة بدلًا من لحظة تغيّر الحالة الحقيقية — وهذا يعني إشعارات تصل إلى مشتركيك قبل أن تنشر المقالة أصلًا.

في Elementor و JetEngine

لو بنيت نوع محتوى مخصص (Custom Post Type) بـ JetEngine وأضفت عليه صندوق حقول (Meta Box)، فالحقول تُحفظ عبر نفس سلسلة الخطافات.

السيناريو المتكرر: تحسب قيمة حقل انطلاقًا من حقول أخرى — سعر إجمالي، أو مدة، أو تقييم — وتستدعي wp_update_post() لتخزينها. هنا بالتحديد استخدم update_post_meta() بدلًا منها، لأن الحقول المخصصة لا تحتاج تحديث سجل المقالة.

وإذا كان الحقل يظهر داخل الـ Listing Grid (شبكة القوائم التي تعرض نتائج الاستعلام بقالب تكرار)، تذكّر أن تفريغ الكاش هو ما يجعل القيمة الجديدة ظاهرة، لا تكرار الحفظ.

كيف تتأكد أنها حلقة فعلًا

لا تخمّن. شغّل تسجيل الأخطاء في wp-config.php:

define( 'WP_DEBUG', true );  
define( 'WP_DEBUG_LOG', true );  
define( 'WP_DEBUG_DISPLAY', false );  

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

إضافة Query Monitor تعطيك نفس الصورة من لوحة التحكم: قائمة الخطافات التي عملت وعدد الاستعلامات في الطلب الواحد.

ملاحظة تهمّك لو كنت على استضافة مشتركة: معظم خطط الاستضافة المشتركة (Shared Hosting) في المنطقة تضبط حد الذاكرة عند 128 ميجابايت وmax_execution_time عند 30 ثانية. هذا يعني أن الحلقة لن تظهر لك كخطأ واضح في ووردبريس، بل كصفحة خطأ 504 أو 500 من السيرفر قبل أن يُكتب أي شيء في سجل الأخطاء. لو رأيت 504 عند الحفظ تحديدًا، ابحث عن حلقة قبل أن تلوم الاستضافة.

الخلاصة

تلات فحوص في أول المعالج، وupdate_post_meta بدل wp_update_post لما تكون بتعدّل حقل، وخطّاف مناسب لكل لحظة — دي كل الحكاية.

ابدأ بمراجعة أي كود عندك متعلق على save_post دلوقتي، وشوف فيه فحوص الحماية ولا لأ. لو مفيهاش، فأنت بتشغّل نفس المنطق تلات مرات في كل حفظة من غير ما تعرف.

مرجع المقال (بالإنجليزية): A Hook That Fires Twice — and How Not to Trigger an Infinite Loop When Saving a Post
المقال ده مش ترجمة حرفية: متكيّف للقارئ العربي ومضاف عليه سياق ووردبريس وElementor وJetEngine.

الأسئلة الشائعة

لماذا يعمل save_post أكثر من مرة عند حفظ مقالة واحدة؟

لأن ووردبريس ينشئ مراجعة (Revision) للمقالة وقد يشغّل حفظًا تلقائيًا (Autosave) قبل الكتابة النهائية، وكل عملية من هذه العمليات تُطلق save_post بدورها. بدون فحوص wp_is_post_autosave() وwp_is_post_revision() سيعمل كودك على كل واحدة منها وليس على الحفظ الحقيقي فقط.

كيف أعرف أن المشكلة حلقة لانهائية وليست كودًا بطيئًا؟

العلامة الفارقة هي خطأ قاتل يظهر عند الحفظ تحديدًا: Allowed memory size exhausted أو Maximum execution time exceeded. وهو يظهر عندما يستدعي كودك داخل خطّاف الحفظ دالة تُطلق نفس الخطّاف مرة أخرى، مثل wp_update_post() أو wp_insert_post().

هل يمكن استخدام أكثر من خطّاف من هذه المجموعة في نفس المشروع؟

نعم، وهذا هو الاستخدام الطبيعي لأن كل خطّاف يغطي لحظة مختلفة. التركيب الشائع: transition_post_status للتفاعل مع النشر، وsave_post لمعالجة الحقول المخصصة، وbefore_delete_post لتنظيف البيانات المرتبطة. التعارض لا يأتي من الجمع بينها بل من غياب فحوص الحماية في كل واحدة.

ما الفرق بين wp_insert_post وsave_post إذا كان كلاهما يعمل «عند الحفظ»؟

wp_insert_post يعمل أبكر، مباشرة بعد الكتابة في قاعدة البيانات وقبل تفريغ الكاش. أما save_post فيعمل بعده قليلًا وهو الخيار الافتراضي الأكثر ثباتًا لمعظم الإضافات، ولهذا السبب هو الأكثر استخدامًا بين الأربعة.

هل يعمل before_delete_post عند نقل المقالة إلى سلة المحذوفات؟

لا. هو يعمل عند الحذف النهائي فقط. للتفاعل مع النقل إلى سلة المحذوفات تحتاج خطّافًا آخر هو wp_trash_post، أما before_delete_post فهو اللحظة الأخيرة قبل اختفاء السجل من قاعدة البيانات فعليًا.

اترك تعليقاً