تطوير الويب 27 يونيو 2026 · 9 دقائق قراءة

حاوية خدمات Laravel من المبادئ الأولى: الفرق بين bind وscoped وأخواتهما

لا تحفظ أربع دوالّ منفصلة. افهم محورًا واحدًا تتفرّع منه كلها: سياسة العمر. شرح من المبادئ الأولى للفرق بين bind وsingleton وscoped وinstance، ولماذا يهمّ في Octane.

حاوية خدمات Laravel من المبادئ الأولى: الفرق بين bind وscoped وأخواتهما

تخيّل هذا السيناريو: تطبيقك يعمل بكفاءة عالية على Laravel Octane، ثم يبدأ المستخدمون بالشكوى من أنهم يرون بيانات مستخدمين آخرين. لا اختراق، ولا خطأ في الاستعلامات. المشكلة في سطر واحد كتبته قبل أشهر دون أن تدرك أثره: سجّلت خدمةً تحمل حالة المستخدم بـ singleton بدل scoped. هذا الخطأ الصغير، الذي لا يظهر إطلاقًا في بيئة PHP التقليدية، يتحوّل إلى كارثة تسريب بيانات في البيئات طويلة العمر. ولفهم سببه، علينا أن نفهم حاوية الخدمات (Service Container) من مبدئها الأول.

ما الحاوية أصلًا؟ وصفة وسياسة العمر

قبل أن نغرق في أسماء الدوال، لنرسّخ المبدأ. توجد الحاوية لأن صنفًا في تطبيقك يحتاج كائنًا آخر، ولا نريده أن يبنيه بيده عبر new فيرتبط به بإحكام. فنسلّم مهمّة «البناء» لطرف مركزي هو الحاوية. وفي جوهرها، الحاوية شيئان فقط مهما تعدّدت دوالّها: «وصفة بناء» (كيف يُصنع الكائن؟) و«سياسة العمر» (كم مرّة تُنفَّذ الوصفة، ومتى تُحفظ النتيجة؟).

هذه المعادلة هي مفتاح كل شيء. فالدوال الأربع التي تبدو منفصلة — bind وsingleton وscoped وinstance — تفعل الشيء نفسه تمامًا: تسجّل وصفة. الفرق الوحيد بينها محور واحد فقط: سياسة العمر. هل تُعاد الوصفة في كل طلب؟ أم مرّة واحدة للأبد؟ أم مرّة لكل طلب؟

صورة وصفية لشرح مفهوم laravel container

المحور الوحيد: العمر (Lifetime)

سؤال «العمر» بسيط: عندما أطلب الكائن مرّتين، هل أحصل على النسخة نفسها أم نسخة جديدة؟ ومتى تُنسى النسخة المحفوظة؟ كل الدوال نقاط على هذا المحور وحده:

bind — بلا ذاكرة (transient): تُعاد الوصفة كاملةً عند كل استدعاء، فتحصل على كائن جديد في كل مرّة. لا تخزين إطلاقًا.

singleton — ذاكرة دائمة (shared): تُنفَّذ الوصفة مرّة واحدة فقط، وتعيش النسخة بعمر الحاوية كلّه، فتحصل على الكائن نفسه دائمًا.

scoped — ذاكرة تُصفَّر عند حدود الطلب: مثل singleton تمامًا، لكن النسخة المحفوظة تُمحى تلقائيًّا عند بداية كل «دورة حياة» جديدة (طلب أو مهمّة).

instance — أحضرتَ كائنك بيدك: مثل singleton (نسخة مشتركة)، لكنك بنيتَ الكائن مسبقًا بنفسك وسلّمته للحاوية لتخزّنه فقط.

تشبيه المقهى يثبّت المنطق

تخيّل الحاوية مقهى، والكائن المطلوب فنجان قهوة، وسياسة العمر هي «كيف يقدّم المقهى القهوة». مع bind، يحضّر الباريستا فنجانًا طازجًا لكل زبون: لا مشاركة ولا تخزين، وهو مناسب للكائنات الرخيصة بلا حالة. ومع singleton، قِدرٌ ضخم يُحضَّر مرّة، والجميع يشرب منه طوال اليوم ما دام المقهى مفتوحًا. ومع scoped، إبريق طازج لكل طاولة تتشاركه الطاولة الواحدة فقط، والطاولة التالية تأخذ إبريقًا جديدًا تمامًا. ومع instance، دخلتَ ومعك ترمسك الخاص وقلت «استخدم هذا»، فالمقهى لا يصنع شيئًا بل يقدّم لك ما أحضرتَه بعينه.

السؤال الأعمق: لماذا وُجد scoped أصلًا؟

هنا جوهر الفهم. لو أدركت «لماذا» وُجد scoped فلن تخلط بينه وبين singleton أبدًا. السبب مبدأ واحد اسمه الحالة المشتركة (Shared State). ففي طلب PHP التقليدي (PHP-FPM)، تُبنى الحاوية من الصفر مع كل طلب وتموت بنهايته. لذلك يكون singleton آمنًا: يعيش طلبًا واحدًا ثم يُمحى. هنا يتصرّف scoped وsingleton بشكل متطابق عمليًّا، ولا فرق ملموس بينهما.

لكن المشكلة تنفجر في البيئات طويلة العمر مثل Laravel Octane أو عمّال الطوابير (Queue Workers). ففي Octane تبقى الحاوية حيّة عبر آلاف الطلبات. عندها يصبح singleton الذي يحمل حالة فخًّا: كائنٌ حُمِّل ببيانات الطلب «أ» (كبيانات مستخدم) يبقى حيًّا فيُسرّبها إلى الطلب «ب». النتيجة تسرّب بيانات بين المستخدمين وتضخّم في الذاكرة (Memory Leak). هذا تحديدًا هو السيناريو الذي بدأنا به المقال.

كيف يحلّ scoped المشكلة داخليًّا؟

سحر scoped أبسط ممّا تظنّ. فهو في جوهره يسجّل الكائن كـ singleton، لكنه يضيف اسمه إلى قائمة خاصّة بالنسخ «المنطاقية». وعند بداية كل دورة حياة جديدة، يستدعي Laravel دالّة forgetScopedInstances التي تمرّ على هذه القائمة وتمحو نسخها المحفوظة، فيُعاد بناؤها نظيفةً عند أوّل طلب لها في الطلب الجديد. باختصار: scoped هو ببساطة «singleton يعيد تصفير نفسه تلقائيًّا عند بداية كل طلب». وُجد لأنك أردت المشاركة داخل الطلب الواحد، لكنك لا تريدها أن تعبر إلى الطلب التالي.

الوصفات بالكود: مثال لكل سياسة

تُكتب هذه الوصفات عادةً داخل دالّة register في مزوّد خدمة (Service Provider):

// bind — كائن جديد كل مرّة (transient)
$this->app->bind(PaymentGateway::class, function ($app) {
    return new StripeGateway($app['config']['services.stripe']);
});

// singleton — يُبنى مرّة، نفس النسخة للأبد (shared)
$this->app->singleton(Clock::class, fn() => new SystemClock());

// scoped — نسخة لكل طلب، تُصفَّر تلقائيًّا (آمن في Octane)
$this->app->scoped(RequestContext::class, fn() => new RequestContext());

// instance — أحضرتَ الكائن جاهزًا، الحاوية تخزّنه فقط
$client = new ApiClient($token);
$this->app->instance(ApiClient::class, $client);

شجرة قرار: أيّها أختار؟

بدل الحفظ، اتبع المنطق وأوقِف عند أوّل «نعم». هل الكائن رخيص البناء وبلا حالة مشتركة (منطق بحت، تحويل بيانات، أمر عابر)؟ استخدم bind. هل يجب أن يُشارَك في كل مكان وآمنٌ بقاؤه (إعدادات، خدمة بلا حالة خاصّة بالطلب)؟ استخدم singleton. هل يُشارَك لكن يجب تصفيره مع كل طلب (يحمل حالة خاصّة بالمستخدم وتشغّل Octane أو Queue)؟ استخدم scoped. هل بنيتَ الكائن مسبقًا بنفسك (عميل API مهيّأ بتوكن جاهز)؟ استخدم instance.

الجانب الآخر: كيف تُخرج الكائن؟

بعد التسجيل، تسحب الكائن من الحاوية. وهنا مبدأ مريح: كل طرق الإخراج تفعل الشيء نفسه تحت أقنعة مختلفة. الاستدعاءات app(Service::class) وapp()->make(Service::class) وresolve(Service::class) كلها متطابقة في الجوهر: «ابنِ لي Service حسب وصفته وسياسة عمره». والأهمّ أنك نادرًا ما تستدعي make بنفسك، لأن الحقن التلقائي عبر تلميح النوع (Type-hint) في باني الصنف هو استدعاء make يقوم به الإطار نيابةً عنك:

// لم تستدعِ make يدويًّا — Laravel فعلها عنك عبر type-hint
public function __construct(private Service $service) {}

بقية الدوال: كلها مشتقّات لا أصول

كل ما تبقّى في الحاوية ليس مفاهيم جديدة، بل تنويعات صغيرة على ما سبق. فالدوال bindIf وsingletonIf وscopedIf هي نفس الأساس مع شرط «سجّل فقط إن لم يكن مسجّلًا»، تُستخدم في الحزم لتمكين التطبيق من الاستبدال. ودالّة extend خطّاف ما بعد البناء: تلتقط الكائن بعد بنائه فتغلّفه أو تعدّله (نمط Decorator). والربط السياقي عبر when()->needs()->give() يحلّ الوصفة نفسها إلى تنفيذ مختلف حسب «مَن يطلبها». والوسم عبر tag وtagged يجمع روابط متعدّدة لتطلبها دفعةً واحدة. أمّا forgetScopedInstances وflush فهما الرافعة اليدوية خلف تصفير scoped التلقائي.

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

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(fn() => Storage::disk('s3'));

والوسم: اجمع روابط متعدّدة واطلبها كمجموعة:

$this->app->tag([CpuReport::class, MemReport::class], 'reports');
$this->app->bind(Dashboard::class, fn($app) =>
    new Dashboard($app->tagged('reports')));

كل الحاوية تنهار إلى خمس أفكار

إذا حفظت هذه الخمسة، حفظت الحاوية كلها. أوّلًا: سجّل وصفة، وعائلة bind كلها تسجّل «كيف يُبنى الكائن». ثانيًا: اختر عمرًا، وهو المحور الوحيد الذي يفرّقها (transient أو shared أو scoped). ثالثًا: أخرج الكائن عبر make أو ما يكافئه، أو دع تلميح النوع يفعلها. رابعًا: عدّل الوصفات عبر extend أو الربط السياقي أو الوسم. خامسًا: أدِر الذاكرة عبر flush وforget، وهو ما يفعله scoped تلقائيًّا.

لاحظ الجمال هنا: لم نحفظ «الفرق بين bind وscoped» كقاعدة مستقلّة، بل استنتجناه. كلاهما «سجّل وصفة»، والفرق نقطة واحدة على محور العمر: bind لا يحفظ النتيجة، وscoped يحفظها لكن يصفّرها عند حدود الطلب. وهكذا يصير ما كان حفظًا متفرّقًا لأربع دوالّ منطقًا واحدًا متّصلًا — تستنتجه متى احتجته، بدل أن تحفظه.

هل وجدت هذا المقال مفيدًا؟

شارك هذا المقال

1 مشاركة

الوسوم: #Laravel#PHP#حاوية الخدمات#Service Container#Octane#حقن التبعيّات

مقالات أخرى