ليلة الجمعة أجرينا نشرًا صغيرًا: بضعة تعديلات شكلية وتغيير في قالب الفاتورة. وفي النهاية، ولأن أول طلب بعد كل نشر كان بطيئًا قليلًا، نفّذنا أيضًا الأمر php artisan config:cache. هو الأمر نفسه الذي توصي به كل الأدلة للإنتاج، ويبدو أكثر أمر في الدنيا أمانًا.
بعد ثلاث دقائق وصلت أول رسالة: بوابة الدفع تعطي خطأ «مفتاح غير صالح». وبعد قليل تبيّن أن بريد تأكيد الطلب لا يُرسل أيضًا. نفّذنا dd(env('PAYMENT_MERCHANT_ID')) فجاءت النتيجة null. فتحنا ملف .env؛ كان المفتاح في مكانه كاملًا وصحيحًا كما عمل أشهرًا.
هذه هي اللحظة التي يشك فيها المرء بكل شيء إلا بالأمر الذي نفّذه بنفسه. لكن المتهم كان هو بالضبط. عندما تُخزَّن الإعدادات مؤقتًا لا يقرأ لارافيل ملف .env بعدها، وكل env() تُستدعى خارج مجلد config/ تعود فارغة اليدين. هذه المقالة هي المسار نفسه: العَرَض، والسبب، والقاعدة التي تمنع تكراره، والترتيب الصحيح للكاش بعد النشر.
العَرَض: المفاتيح صارت فارغة لكن ملف env لم يُمس#
تكون الأعراض عادةً هذه، ولا يشير أي منها إلى اسم المتهم:
- تقول بوابة الدفع إن المفتاح أو معرّف التاجر غير صالح، مع أن المفتاح نفسه عمل بالأمس.
- لا يُرسل البريد أو يفشل بخطأ اتصال، لأن مضيف SMTP والمستخدم وكلمة المرور فارغة.
- تعطي خدمة الرسائل النصية أو التخزين السحابي أو أي حزمة تحتاج مفتاحًا الرد «ليس لديك صلاحية».
- ترى في السجل أخطاء من نوع «يجب أن يكون المعامل سلسلة نصية، وأُعطيت القيمة null».
- لم يتغير أي كود، وآخر ما فعلته كان أمرًا متعلقًا بالكاش.
سمة هذا العطل تشتّت العلامات: تنكسر عدة خدمات لا علاقة بينها في وقت واحد. فكل موضع كان يأتي بمفتاح من البيئة صار الآن فارغًا. وهذا التشتت هو ما يضلّ الناس عادةً، لأنك تبحث عن تغيير مشترك في الكود وهو غير موجود.
أولًا انظر هل بُني ملف الكاش أصلًا:
ls -l bootstrap/cache/config.phpإذا كان هذا الملف موجودًا فالتشخيص القاطع يستغرق ثلاثة أسطر:
php artisan tinker>>> env('PAYMENT_MERCHANT_ID');
= null
>>> config('services.payment.merchant_id');
= "8d1a0d7e-1f2c-4a1b-9c3e-0a7b2d4e6f10"إذا أعطت env() القيمة null بينما أعطت config() القيمة الصحيحة نفسها فالقضية مغلقة: الإعدادات مخزنة مؤقتًا وكودك يقرأ مباشرة من env().
السبب: كاش الإعدادات يُخرج ملف env من الدورة#
في الحالة العادية يكون ترتيب إقلاع لارافيل هكذا. أولًا يُقرأ ملف .env وتدخل متغيراته في بيئة تشغيل PHP. ثم تُنفَّذ كل الملفات داخل config/ وكلما رأت env() أخذت القيمة من تلك البيئة نفسها. والنتيجة مصفوفة إعدادات كبيرة يقرأ منها كودك بـ config().
الأمر php artisan config:cache يبني هذه المصفوفة النهائية مرة واحدة ويكتبها على هيئة ملف PHP في bootstrap/cache/config.php.
ومنذ تلك اللحظة يتجه لارافيل عند الإقلاع أولًا إلى هذا الملف. فإذا كان موجودًا تخطّى مرحلة قراءة .env بالكامل. لا أنه يقرأ الملف ويتجاهله؛ بل لا يفتحه أصلًا.
والنتيجة بسيطة. config() تعطي القيمة الصحيحة لأن القيم كُتبت داخل الملف لحظة بناء الكاش. أما env() فلا تجد مصدرًا تقرأ منه فتعيد قيمتها الافتراضية، وهي null إذا لم تعطها قيمة افتراضية.
وهناك استثناء: إذا كان المتغير معرَّفًا فعلًا على مستوى نظام التشغيل أو خادم الويب لا في ملف .env فستظل env() تراه. وفي الاستضافة المشتركة مع cPanel لا يكون ذلك مضبوطًا عادةً، وكل شيء يأتي من الملف.
القاعدة في سطر واحد: env() داخل مجلد config فقط#
يمكن تلخيص هذه المقالة كلها في جملة واحدة:
env()داخلconfig/فقط.
خارج هذا المجلد، لا في أي مكان. لا في controller، ولا في model، ولا في فئة خدمة، ولا في قالب blade، ولا في ملف المسارات، ولا في middleware، ولا في job الطابور.
والسبب هو التوقيت. تُنفَّذ ملفات config/ في لحظة محددة بالضبط: إما في بداية كل طلب حين لا يوجد كاش، وإما مرة واحدة عند بناء الكاش. وفي الحالتين يكون .env قد قُرئ في تلك اللحظة ويكون لـ env() معنى. أما أي موضع آخر في التطبيق فوقت تنفيذه غير معلوم، وفي الحالة المخزنة مؤقتًا يقع بالتأكيد بعد تجاوز مرحلة البيئة.
هذه القاعدة ليست من عندنا؛ إنها توصية لارافيل الرسمية نفسها. لكن مشكلتها أنك ما لم تفعّل كاش الإعدادات فمخالفتها لا تكلف شيئًا، ولهذا تبقى في المشروع سنوات بصمت.
لماذا هذا التصميم صحيح ولماذا يبقى خطؤه صامتًا#
السرعة ومصدر واحد للحقيقة#
قراءة .env تعني فتح ملف نصي وتحليله سطرًا سطرًا ثم وضع القيم في البيئة، وذلك في كل طلب. وبعدها يجب تنفيذ عشرات ملفات الإعدادات. كاش الإعدادات يحوّل كل ذلك إلى require واحد لمصفوفة جاهزة يبقى كودها التنفيذي (opcode) في الذاكرة أيضًا. وفي الاستضافة المشتركة حيث موارد المعالجة ضيقة يُحَس هذا الفرق فعلًا.
الميزة الثانية أهم: مصدر واحد للحقيقة. حين يقرأ كل الكود من config() فهناك موضع واحد بالضبط تتحدد فيه القيمة النهائية. وفي الاختبارات يمكنك تغيير القيمة بـ config(['services.sms.key' => 'fake']) وأنت واثق أن كل الكود يرى القيمة نفسها. أما مع env() المتناثرة في الكود فلا تملك هذا الضمان.
الخطأ بلا صوت#
حين لا تجد env() شيئًا لا تُطلق استثناءً؛ بل تعطي null فقط. وتدخل تلك الـ null بصمت إلى مُنشئ عميل HTTP، وتستقر في ترويسة Authorization، فترى في النهاية رسالة خطأ الخدمة الأخرى لا خطأك الحقيقي. والمسافة بين موضع الخطأ وموضع ظهوره هي ما يستغرق ساعات.
والقاعدة نفسها تنطبق على APP_DEBUG: إذا غيّرت قيمته في .env بعد تخزين الإعدادات مؤقتًا فلن يحدث شيء حتى يُعاد بناء الكاش. ولأن هذا المتغير يؤثر مباشرة فيما يراه المستخدم والمهاجم، فالخطأ فيه مكلف؛ وقد شرحنا ذلك بالضبط في مقالة APP_DEBUG في الاستضافة المشتركة.
العثور على مخالفات env() بأمر grep#
قبل أن تعيد بناء الكاش امسح المشروع كله مرة واحدة. هذا الأمر يُظهر كل استدعاءات env() خارج config/:
grep -rn --include='*.php' -E '(^|[^_a-zA-Z0-9])env\(' app routes resources database bootstrapنقطتان عن الأمر نفسه. النمط [^_a-zA-Z0-9] يمنع التطابق مع أمور مثل getenv( أو my_env(. و--include='*.php' يشمل ملفات blade أيضًا، لأن امتدادها .blade.php ويطابق *.php.
لترى العدد فقط:
grep -rn --include='*.php' -E '(^|[^_a-zA-Z0-9])env\(' app routes resources database bootstrap | wc -lالعدد الصحيح لهذا الحساب هو صفر. وأي رقم أكبر من الصفر هو قائمة مهامك اليوم.
وللمقارنة، انظر كم موضعًا لديك في config/ نفسه. هذه مسموحة ويجب أن تكون موجودة:
grep -rn "env(" config | wc -lالنمط الصحيح: المفتاح في config والقراءة بـ config()#
لنفترض أن مفتاح بوابة الدفع موجود في .env:
PAYMENT_MERCHANT_ID=8d1a0d7e-1f2c-4a1b-9c3e-0a7b2d4e6f10
PAYMENT_SANDBOX=falseأنشئ له مدخلًا في config/services.php:
'payment' => [
'merchant_id' => env('PAYMENT_MERCHANT_ID'),
'sandbox' => env('PAYMENT_SANDBOX', false),
'callback' => env('PAYMENT_CALLBACK', '/payment/verify'),
],واقرأ في الكود من config() فقط:
$merchantId = config('services.payment.merchant_id');
if (blank($merchantId)) {
throw new RuntimeException('مفتاح بوابة الدفع غير مضبوط.');
}خذ هذا الـ if بجدية. فرقه عن الحالة السابقة أنك حين تكون القيمة فارغة تلتقط الخطأ في موضعه برسالة كتبتها بنفسك، لا بعد ثلاث طبقات بلغة خدمة خارجية.
ثلاث نقاط صغيرة تستهلك الوقت:
- ضع القيمة الافتراضية داخل
config/لا في الكود.config('services.payment.sandbox', false)تعمل أيضًا، لكن ذلك الافتراضي لا ينفع إلا إذا كان المفتاح غير موجود في المصفوفة أصلًا؛ فإذا كان المفتاح موجودًا وقيمتهnullفستحصل علىnullنفسها.
- قيم
.envسلاسل نصية. يحوّل لارافيل السلاسلtrueوfalseوnullوemptyبنفسه إلى القيمة المقابلة، لكنه لا يحوّل الأعداد. فإن كنت تتوقع عددًا فحوّله صراحةً في ملف الإعدادات:(int) env('PAYMENT_TIMEOUT', 30).
- لا تكتب closure في ملفات
config/. إذا كانت قيمة ما غير قابلة للتسلسل فسيفشلconfig:cacheبرسالة «Your configuration files are not serializable» ولن يُبنى الكاش أصلًا حتى تزيل ذلك الـ closure.
وأضف عادة جيدة: كل مفتاح جديد تضيفه إلى .env ضعه في اللحظة نفسها في .env.example أيضًا، بلا قيمة حقيقية. فهذا المكان الوحيد الذي يعرف منه الشخص التالي ما المتغيرات التي يحتاجها هذا المشروع.
ثلاثة فخاخ قريبة تُوقع هنا#
الفخ الأول: القيمة الافتراضية تضلّلك#
يظن كثيرون أن المشكلة في null فقط، فيضعون قيمة افتراضية ويرتاحون:
$driver = env('PAYMENT_DRIVER', 'sandbox');في الحالة المخزنة مؤقتًا لا يعطي هذا السطر null أبدًا؛ بل يعطي sandbox دائمًا. أي أن القيمة الحقيقية التي كتبتها في .env لم تعد تُقرأ، ويستمر الكود بصمت بالقيمة الافتراضية.
وهذا أسوأ من null. فـ null تُنتج خطأً على الأقل وتنبهك. أما الافتراضي الخاطئ فقد يعمل أسابيع بصمت، حتى يوم تكتشف فيه أن كل المدفوعات كانت في الوضع التجريبي.
الفخ الثاني: env() داخل فئات الخدمة#
أخطر أشكاله داخل المُنشئ (constructor)، لأن الخدمة تُبنى عادةً مرة واحدة عند الإقلاع فيظهر الخطأ بعيدًا جدًّا عن موضعه الحقيقي:
class SmsClient
{
public function __construct()
{
$this->apiKey = env('SMS_API_KEY');
}
}الشكل الصحيح أن تستقبل القيمة من الخارج وتأخذ افتراضيها من الإعدادات:
class SmsClient
{
public function __construct(private ?string $apiKey = null)
{
$this->apiKey = $apiKey ?? config('services.sms.key');
}
}والأفضل من ذلك ربط الخدمة في service provider حتى لا تعرف الفئة بوجود الإعدادات أصلًا:
$this->app->singleton(SmsClient::class, function () {
return new SmsClient(config('services.sms.key'));
});الفخ الثالث: الحزم الخارجية#
بعض الحزم تحمل الخطأ نفسه داخل كودها. وعلامته واضحة جدًّا: الحزمة تعمل بدون كاش الإعدادات وتنكسر معه، بينما لم تفعل أنت شيئًا بها.
للعثور عليه ابحث في مجلد تلك الحزمة وحدها:
grep -rn --include='*.php' -E '(^|[^_a-zA-Z0-9])env\(' vendor/VENDOR/PACKAGE/srcإذا وجدت شيئًا فانشر أولًا ملف إعدادات الحزمة واكتب القيمة صراحةً في ذلك الملف:
php artisan vendor:publish --provider="Vendor\Package\ServiceProvider"وإذا كانت الحزمة تقرأ القيمة مباشرة من البيئة ولا تنظر إلى الإعدادات أصلًا فلا يمكن إصلاحها من الخارج؛ فإما أن تبلّغ عن المسألة في مستودعها، أو تعرّف المتغير مؤقتًا على مستوى الخادم نفسه، وقد ينجح ذلك أو لا بحسب إعداد الاستضافة. والطريق الأسلم استبدال تلك الحزمة.
الكاشات الأخرى والترتيب الصحيح بعد النشر#
كاش الإعدادات ليس الكاش الوحيد في لارافيل، ومسح أحدها لا يمسح البقية. الترتيب الذي ننفذه بعد كل نشر هو: امسح الجميع أولًا ثم ابنِ من جديد.
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan view:clear
php artisan config:cache
php artisan route:cache
php artisan view:cacheبعض الملاحظات حول هذا الترتيب نفسه:
cache:clearهو كاش التطبيق لا كاش الإعدادات. فإذا كان مشغّل الكاش لديك على قاعدة البيانات أو Redis فهذا الأمر يحتاج إلى اتصال ليعمل؛ وإذا كانت الإعدادات تالفة فقد يفشل هو نفسه. ولهذا ننفذconfig:clearأولًا.- يجب تنفيذ
config:cacheقبل بناء بقية الكاشات، لأن بناءها نفسه يحتاج إلى الإعدادات. route:cacheيفشل بخطأ «Unable to prepare route for serialization. Uses Closure» إذا كان لديك ولو مسار واحد يستخدم closure بدل controller. والحل الصحيح نقل ذلك الـ closure إلى controller، لا التخلي عن كاش المسارات.- في الإصدارات الأحدث يبني
php artisan optimizeعدة كاشات من هذه معًا ويمسحها كلهاphp artisan optimize:clear. وأيها يشمل بالضبط يختلف بحسب الإصدار؛ فإن أردت أن تعرف بدقة ما الذي يحدث فاكتب الأوامر صراحةً.
الاستعادة السريعة حين يتوقف الموقع#
أعد الموقع أولًا ثم ابحث عن المتهم. يكفي أمر واحد:
php artisan config:clearيحذف هذا الأمر الملف bootstrap/cache/config.php فيعود لارافيل فورًا إلى قراءة .env مباشرة. يصبح أبطأ لكنه سليم.
وأحيانًا لا يُقلع artisan نفسه، لأن كاش الإعدادات يشير إلى فئة لم تعد موجودة أو أن قيمة فيه تالفة. وفي تلك الحالة لا يُنفَّذ أي أمر ويجب أن تحذف الملف يدويًا:
rm -f bootstrap/cache/config.phpهذا السطر وحده يعيد الموقع. وإذا لم يكن لديك SSH فيمكنك حذف الملف نفسه من مدير الملفات في cPanel؛ والأثر واحد تمامًا.
وإذا كنت تشك في كاش المسارات أيضًا:
rm -f bootstrap/cache/routes-*.phpيختلف الاسم الدقيق لهذا الملف بحسب الإصدار، ولهذا استخدمنا نمطًا. ويمكن حذف الملفين packages.php وservices.php في المجلد نفسه أيضًا؛ فيعيد لارافيل بناءهما بنفسه، وإن لم يفعل فإن php artisan package:discover يُتم المهمة.
وإذا بدت القيم بعد كل ذلك صحيحة لكنها تُقرأ خطأً، فاشتبه في ملف .env نفسه؛ فنهاية سطر بنمط ويندوز تكفي لتُقرأ القيمة مع محرف غير مرئي، وهو ما حلّلناه في مقالة نهاية أسطر ويندوز في ملف .env.
المتغيرات التي تتغير أثناء التشغيل ليس مكانها env أصلًا#
خطأ أشيع من env() نفسها هو أن نضع في .env أشياء ليس مكانها هناك: تكلفة الشحن، والحد الأدنى لقيمة الطلب، ونص رسالة الترحيب، وتشغيل ميزة أو إيقافها. أي أشياء ينبغي أن يستطيع مدير الموقع تغييرها من لوحة التحكم.
كاش الإعدادات يفضح هذا الخطأ. فالقيمة تتجمد لحظة بناء الكاش ولا تتغير حتى النشر التالي. وحتى لو لم يكن لديك كاش، فتعديل .env على الخادم يعني أن شخصًا ما يجب أن يملك صلاحية على نظام الملفات، ولا يُسجَّل أي تاريخ، ولا يجري أي تحقق من القيمة.
الحد بسيط:
| أين نضعه | ماذا | لماذا |
|---|---|---|
| ملف البيئة | مفاتيح API، بيانات قاعدة البيانات، عنوان الموقع، مشغّل الكاش | يختلف بين البيئات ولا يتغير أثناء التشغيل |
| قاعدة البيانات | تكلفة الشحن، النصوص، مفاتيح تشغيل الميزات | قد يغيّره شخص في أي لحظة |
النمط البسيط الذي نستخدمه هو جدول للإعدادات مع طبقة كاش:
function setting(string $key, mixed $default = null): mixed
{
$all = Cache::rememberForever('settings', function () {
return Setting::query()->pluck('value', 'key')->all();
});
return $all[$key] ?? $default;
}وفي النموذج Setting أبطل الكاش بعد كل حفظ أو حذف حتى يظهر التغيير فورًا:
protected static function booted(): void
{
static::saved(fn () => Cache::forget('settings'));
static::deleted(fn () => Cache::forget('settings'));
}ملاحظة ذات صلة: config(['x' => 'y']) تغيّر القيمة لذلك الطلب فقط ولا تكتب شيئًا على القرص. وهي ممتازة للاختبارات، وغير صالحة إطلاقًا لحفظ الإعدادات.
وإذا كان لديك worker للطوابير فنفّذ بعد كل نشر php artisan queue:restart أيضًا. فالعملية الجارية تحتفظ بالإعدادات القديمة في الذاكرة وتستخدمها حتى تنتهي هي بنفسها؛ وقد تناولنا هذه المسألة بتوسع في مقالة منفصلة عن تشغيل الطوابير بمهام cron.
سكربت النشر حتى لا تُنسى هذه الخطوات#
كل ما عليك أن تتذكره يدويًا سيُنسى يومًا. ضع هذه الخطوات في سكربت ثم نفّذه وحده بعد ذلك:
#!/bin/bash
set -euo pipefail
APP=/home/USER/app
PHP=/usr/local/bin/ea-php84
COMPOSER=/opt/cpanel/composer/bin/composer
cd "$APP"
"$PHP" artisan down
git pull --ff-only
"$PHP" "$COMPOSER" install --no-dev --optimize-autoloader
"$PHP" artisan migrate --force
"$PHP" artisan config:clear
"$PHP" artisan cache:clear
"$PHP" artisan route:clear
"$PHP" artisan view:clear
"$PHP" artisan config:cache
"$PHP" artisan route:cache
"$PHP" artisan view:cache
"$PHP" artisan queue:restart
"$PHP" artisan upيختلف مسار PHP وComposer من استضافة إلى أخرى؛ اعثر عليهما بـ which php وwhich composer أو من لوحة التحكم نفسها. والأهم أنه لا يوجد أي alias في الـ shell غير التفاعلي وقد يكون php إصدارًا آخر، فاكتب المسار الكامل دائمًا؛ وهذا فخ شرحناه بالتفصيل في مقالة PHP 8.4 في مهام cron على cPanel.
يعني set -euo pipefail أن السكربت يتوقف عند أول خطأ. وعيبه أنه إذا انكسر في المنتصف بقي الموقع في وضع الصيانة. وهذا أفضل من موقع نصف معطوب، لكن بشرط أن تعلم بذلك؛ فأرسل مخرجات السكربت إلى مكان تقرؤه فعلًا.
وإذا فشل route:cache بسبب مسار closure فلا تحذف ذلك السطر، بل أصلح المسار. فحذف السطر يجعل كل من يقرأ هذا السكربت يظن أن كاش المسارات استُبعد عمدًا. وإن أردت مراجعة بقية فخاخ هذا النوع من الاستضافة دفعة واحدة، فإن قائمة فحص الاستضافة المشتركة للارافيل وضعتها معًا.
الدرس الأساسي#
config:cache ليس فيه خطأ. هو فقط يأخذ بجدية عقدًا لم يكن كثيرون يعلمون أنهم وقّعوه: بعد تخزين الإعدادات مؤقتًا لا وجود لملف .env بالنسبة إلى تطبيقك.
ضع القاعدة اليوم ولا تفكر فيها بعد ذلك: env() داخل config/ فقط، وconfig() في كل مكان آخر. أمر grep يستغرق عشر ثوانٍ الآن يمنع ليلة معطوبة.
وعادة أعمّ نفعتنا مرارًا: كلما تعطل شيء بعد أمر «تحسين»، ارجع وانظر أي مرحلة بالضبط أزالها هذا الأمر من المسار. التحسين يعني تقريبًا دائمًا حذف مرحلة؛ والسؤال الصحيح هو أي مرحلة، ومن كان يعتمد عليها.