جمعه شب یک استقرار کوچک داشتیم؛ چند اصلاح ظاهری و یک تغییر در قالب فاکتور. آخر کار، چون اولین درخواست بعد از هر استقرار کمی کند بود، دستور php artisan config:cache را هم زدیم. همان دستوری که همه‌ی راهنماها برای پروداکشن توصیه می‌کنند و بی‌خطرترین کار دنیا به نظر می‌رسد.

سه دقیقه بعد اولین پیام رسید: درگاه پرداخت خطای «کلید نامعتبر» می‌داد. کمی بعد معلوم شد ایمیل تأیید سفارش هم نمی‌رود. یک dd(env('ZARINPAL_MERCHANT_ID')) جواب داد null. فایل .env را باز کردیم؛ کلید سرِ جایش بود، کامل و درست، همان‌طور که ماه‌ها کار کرده بود.

این همان لحظه‌ای است که آدم به همه چیز شک می‌کند جز به دستوری که خودش زده. ولی مقصر دقیقاً همان بود. وقتی پیکربندی کش می‌شود، لاراول دیگر فایل .env را نمی‌خواند و هر env() که بیرون از پوشه‌ی config/ صدا زده شود دستش خالی برمی‌گردد. این یادداشت همان مسیر است: علامت، علت، قانونی که جلوی تکرارش را می‌گیرد، و ترتیب درست کش‌ها بعد از استقرار.

علامت: کلیدها خالی شدند ولی فایل env دست‌نخورده است#

نشانه‌ها معمولاً این‌ها هستند و هیچ‌کدام اسم مقصر را نمی‌گوید:

  • درگاه پرداخت می‌گوید کلید یا شناسه‌ی پذیرنده نامعتبر است، در حالی که همان کلید دیروز کار می‌کرد.
  • ایمیل ارسال نمی‌شود یا با خطای اتصال شکست می‌خورد، چون میزبان و کاربر و رمز SMTP خالی‌اند.
  • سرویس پیامک، فضای ابری یا هر پکیجی که کلید می‌خواهد، پاسخ «دسترسی ندارید» می‌دهد.
  • در لاگ خطاهایی از جنس «آرگومان باید رشته باشد، مقدار null داده شد» می‌بینید.
  • هیچ کدی عوض نشده و آخرین کاری که کرده‌اید یک دستور مربوط به کش بوده است.

مشخصه‌ی این خرابی، پراکندگی علامت است: چند سرویس کاملاً بی‌ربط هم‌زمان می‌شکنند. هر جا که کلیدی از محیط می‌آمد، حالا خالی است. همین پراکندگی معمولاً آدم را به بیراهه می‌برد، چون دنبال یک تغییر مشترک در کد می‌گردید و چنین تغییری وجود ندارد.

اول ببینید اصلاً فایل کش ساخته شده یا نه:

Bash
ls -l bootstrap/cache/config.php

اگر این فایل هست، تشخیص قطعی سه خط طول می‌کشد:

Bash
php artisan tinker
PHP
>>> env('ZARINPAL_MERCHANT_ID');
= null

>>> config('services.zarinpal.merchant_id');
= "8d1a0d7e-1f2c-4a1b-9c3e-0a7b2d4e6f10"

اگر env() نال بدهد ولی config() همان مقدار درست را بدهد، پرونده بسته است: پیکربندی کش شده و کد شما دارد مستقیم از env() می‌خواند.

علت: کش پیکربندی، فایل env را از مدار خارج می‌کند#

در حالت عادی ترتیب بوت شدن لاراول این است. اول فایل .env خوانده و متغیرهایش وارد محیط اجرای PHP می‌شوند. بعد همه‌ی فایل‌های داخل config/ اجرا می‌شوند و هر جا env() دیدند مقدار را از همان محیط برمی‌دارند. نتیجه یک آرایه‌ی بزرگ پیکربندی است که کد شما با config() از آن می‌خواند.

دستور php artisan config:cache همین آرایه‌ی نهایی را یک بار می‌سازد و به شکل یک فایل PHP در bootstrap/cache/config.php می‌نویسد.

از این لحظه به بعد، لاراول هنگام بوت اول سراغ همان فایل می‌رود. اگر موجود باشد، مرحله‌ی خواندن .env را کامل رد می‌کند. نه این‌که فایل را بخواند و نادیده بگیرد؛ اصلاً بازش نمی‌کند.

نتیجه ساده است. config() مقدار درست را می‌دهد، چون مقدارها همان موقعِ ساختنِ کش داخل فایل نوشته شده‌اند. ولی env() دیگر منبعی برای خواندن ندارد و مقدار پیش‌فرضش را برمی‌گرداند، که وقتی پیش‌فرضی ندهید یعنی null.

یک استثنا هم هست: اگر متغیر واقعاً در سطح سیستم‌عامل یا وب‌سرور تعریف شده باشد و نه در فایل .env، آن‌وقت env() همچنان آن را می‌بیند. روی هاست اشتراکی سی‌پنل معمولاً چنین چیزی تنظیم نشده و همه چیز از فایل می‌آید.

دو مسیر خواندن پیکربندی، بدون کش و با کش، و نقطه‌ای که فایل محیطی از مدار خارج می‌شود

قانون یک‌خطی: env() فقط داخل پوشه‌ی config#

کل این یادداشت را می‌شود در یک جمله خلاصه کرد:

env() فقط داخل config/.

بیرون از آن پوشه، هیچ‌جا. نه در کنترلر، نه در مدل، نه در کلاس سرویس، نه در قالب بلید، نه در فایل روت، نه در میدل‌ور، نه در جاب صف.

دلیلش زمان‌بندی است. فایل‌های config/ دقیقاً در یک لحظه‌ی مشخص اجرا می‌شوند: یا در ابتدای هر درخواست وقتی کشی در کار نیست، یا فقط یک بار موقع ساخته شدن کش. در هر دو حالت، در آن لحظه .env خوانده شده و env() معنا دارد. هر جای دیگری از برنامه، زمان اجرا نامعلوم است و در حالت کش‌شده قطعاً بعد از عبور از مرحله‌ی محیط اتفاق می‌افتد.

این قانون از خودمان نیست؛ توصیه‌ی رسمی خود لاراول است. فقط مشکلش این است که تا وقتی کش پیکربندی را روشن نکنید، نقض کردنش هیچ هزینه‌ای ندارد و به همین دلیل سال‌ها بی‌سروصدا در پروژه می‌ماند.

چرا این طراحی درست است و چرا خطایش خاموش می‌ماند#

سرعت و یک منبع حقیقت#

خواندن .env یعنی باز کردن یک فایل متنی، پارس کردن خط‌به‌خط، و ریختن مقدارها در محیط — در هر درخواست. بعد از آن هم ده‌ها فایل پیکربندی باید اجرا شوند. کش پیکربندی همه‌ی این‌ها را به یک require از یک آرایه‌ی آماده تبدیل می‌کند که اپ‌کد آن هم در حافظه می‌ماند. روی هاست اشتراکی که منابع پردازشی تنگ است، این تفاوت واقعاً حس می‌شود.

مزیت دوم مهم‌تر است: یک منبع حقیقت. وقتی همه‌ی کد از config() می‌خواند، دقیقاً یک جا هست که مقدار نهایی تعیین می‌شود. در تست می‌توانید با config(['services.sms.key' => 'fake']) مقدار را عوض کنید و مطمئن باشید همه‌ی کد همان را می‌بیند. با env() پراکنده در کد، چنین تضمینی ندارید.

خطا هیچ صدایی ندارد#

env() وقتی چیزی پیدا نمی‌کند استثنا پرتاب نمی‌کند؛ فقط null می‌دهد. آن null بی‌سروصدا وارد سازنده‌ی یک کلاینت HTTP می‌شود، در هدر Authorization می‌نشیند، و شما در نهایت پیام خطای سرویس طرف مقابل را می‌بینید نه خطای واقعی خودتان را. فاصله‌ی بین محل خطا و محل بروزش همان چیزی است که چند ساعت وقت می‌گیرد.

همین قاعده برای APP_DEBUG هم صادق است: اگر بعد از کش شدن پیکربندی مقدارش را در .env عوض کنید، تا ساخته شدن دوباره‌ی کش هیچ اتفاقی نمی‌افتد. چون این متغیر مستقیماً روی چیزی که کاربر و مهاجم می‌بینند اثر می‌گذارد، اشتباه در آن گران تمام می‌شود؛ در یادداشت APP_DEBUG روی هاست اشتراکی دقیقاً همین را باز کرده‌ایم.

پیدا کردن موارد نقض env() با grep#

قبل از این‌که کش را دوباره بسازید، یک بار کل پروژه را جارو کنید. این دستور همه‌ی فراخوانی‌های env() را بیرون از config/ نشان می‌دهد:

Bash
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.php است و به *.php می‌خورد.

برای این‌که فقط عدد را ببینید:

Bash
grep -rn --include='*.php' -E '(^|[^_a-zA-Z0-9])env\(' app routes resources database bootstrap | wc -l

عدد درست این شمارش صفر است. هر عددی بزرگ‌تر از صفر، فهرست کارهای امروز شماست.

و برای مقایسه، ببینید در خود config/ چند مورد دارید. این‌ها مجازند و باید هم باشند:

Bash
grep -rn "env(" config | wc -l
پوشه‌ی پیکربندی که خواندن متغیر محیطی در آن مجاز است، در برابر پوشه‌های دیگر پروژه

الگوی درست: کلید در config و خواندن با config()#

فرض کنیم کلید درگاه پرداخت را در .env دارید:

INI
ZARINPAL_MERCHANT_ID=8d1a0d7e-1f2c-4a1b-9c3e-0a7b2d4e6f10
ZARINPAL_SANDBOX=false

یک ورودی برایش در config/services.php بسازید:

PHP
'zarinpal' => [
    'merchant_id' => env('ZARINPAL_MERCHANT_ID'),
    'sandbox' => env('ZARINPAL_SANDBOX', false),
    'callback' => env('ZARINPAL_CALLBACK', '/payment/verify'),
],

و در کد فقط از config() بخوانید:

PHP
$merchantId = config('services.zarinpal.merchant_id');

if (blank($merchantId)) {
    throw new RuntimeException('کلید درگاه پرداخت تنظیم نشده است.');
}

آن if را جدی بگیرید. تفاوتش با حالت قبل این است که وقتی مقدار خالی است، خطا را همان‌جا می‌گیرید، با پیامی که خودتان نوشته‌اید، نه سه لایه بعد از زبان یک سرویس بیرونی.

سه نکته‌ی ریز که وقت می‌خورند:

  1. مقدار پیش‌فرض را داخل config/ بگذارید، نه در کد. config('services.zarinpal.sandbox', false) هم کار می‌کند، ولی آن پیش‌فرض فقط وقتی به کار می‌آید که کلید اصلاً در آرایه نباشد؛ اگر کلید باشد و مقدارش null باشد، همان null را می‌گیرید.
  1. مقدارهای .env رشته‌اند. لاراول رشته‌های true، false، null و empty را خودش به مقدار متناظر تبدیل می‌کند، ولی عدد را نه. اگر انتظار عدد دارید، در فایل پیکربندی صریح تبدیل کنید: (int) env('PAYMENT_TIMEOUT', 30).
  1. در فایل‌های config/ کلوژر ننویسید. اگر مقداری قابل سریالایز شدن نباشد، config:cache با پیام «Your configuration files are not serializable» شکست می‌خورد و تا وقتی آن کلوژر را برندارید کش اصلاً ساخته نمی‌شود.

یک عادت خوب هم اضافه کنید: هر کلید جدیدی که به .env اضافه می‌کنید، همان لحظه در .env.example هم بگذارید، بدون مقدار واقعی. این تنها جایی است که آدمِ بعدی می‌فهمد این پروژه چه متغیرهایی لازم دارد.

سه تله‌ی نزدیک که همین‌جا گیر می‌اندازند#

تله‌ی اول: مقدار پیش‌فرض شما را گمراه می‌کند#

خیلی‌ها فکر می‌کنند مشکل فقط null است، پس پیش‌فرض می‌گذارند و خیالشان راحت می‌شود:

PHP
$driver = env('PAYMENT_DRIVER', 'sandbox');

در حالت کش‌شده این خط هرگز نال نمی‌دهد؛ همیشه sandbox می‌دهد. یعنی مقدار واقعی که در .env نوشته‌اید دیگر خوانده نمی‌شود و کد بی‌سروصدا با پیش‌فرض ادامه می‌دهد.

این از نال بدتر است. نال دست‌کم یک خطا تولید می‌کند و شما را خبر می‌کند. پیش‌فرضِ اشتباه می‌تواند هفته‌ها بی‌صدا کار کند — تا روزی که بفهمید همه‌ی پرداخت‌ها روی حالت آزمایشی بوده‌اند.

تله‌ی دوم: env() داخل کلاس‌های سرویس#

خطرناک‌ترین شکلش داخل سازنده است، چون سرویس معمولاً یک بار هنگام بوت ساخته می‌شود و خطا خیلی دور از محل واقعی ظاهر می‌شود:

PHP
class SmsClient
{
    public function __construct()
    {
        $this->apiKey = env('SMS_API_KEY');
    }
}

شکل درستش این است که مقدار را از بیرون بگیرید و پیش‌فرضش را از پیکربندی بردارید:

PHP
class SmsClient
{
    public function __construct(private ?string $apiKey = null)
    {
        $this->apiKey = $apiKey ?? config('services.sms.key');
    }
}

بهتر از آن، بستن سرویس در یک سرویس‌پروایدر است تا کلاس اصلاً از وجود پیکربندی خبر نداشته باشد:

PHP
$this->app->singleton(SmsClient::class, function () {
    return new SmsClient(config('services.sms.key'));
});

تله‌ی سوم: پکیج‌های شخص ثالث#

بعضی پکیج‌ها همین اشتباه را داخل کد خودشان دارند. علامتش خیلی مشخص است: پکیج بدون کش پیکربندی کار می‌کند و با کش می‌شکند، در حالی که شما هیچ کاری با آن نکرده‌اید.

برای پیدا کردنش، فقط پوشه‌ی همان پکیج را بگردید:

Bash
grep -rn --include='*.php' -E '(^|[^_a-zA-Z0-9])env\(' vendor/VENDOR/PACKAGE/src

اگر چیزی پیدا شد، اول فایل پیکربندی پکیج را منتشر کنید و مقدار را صریح در همان فایل بنویسید:

Bash
php artisan vendor:publish --provider="Vendor\Package\ServiceProvider"

اگر پکیج مقدار را مستقیم از محیط می‌خواند و اصلاً به پیکربندی نگاه نمی‌کند، از بیرون نمی‌شود درستش کرد؛ یا باید مسئله را به مخزنش گزارش کنید یا موقتاً متغیر را در سطح خود سرور تعریف کنید که بسته به پیکربندی هاست ممکن است جواب بدهد و ممکن است ندهد. راه سالم‌تر، جایگزین کردن آن پکیج است.

کش‌های دیگر و ترتیب درست بعد از استقرار#

کش پیکربندی تنها کش لاراول نیست و پاک کردن یکی، بقیه را پاک نمی‌کند. ترتیبی که ما بعد از هر استقرار می‌زنیم این است: اول همه را پاک کن، بعد از نو بساز.

Bash
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 کش برنامه است، نه کش پیکربندی. اگر درایور کش شما روی دیتابیس یا ردیس باشد، این دستور برای اجرا به اتصال نیاز دارد؛ و اگر پیکربندی خراب باشد ممکن است خودش هم شکست بخورد. برای همین config:clear را اول می‌زنیم.
  • config:cache باید قبل از ساختن بقیه‌ی کش‌ها اجرا شود، چون ساخت آن‌ها خودش به پیکربندی نیاز دارد.
  • route:cache اگر حتی یک روت داشته باشید که به‌جای کنترلر یک کلوژر است، با خطای «Unable to prepare route for serialization. Uses Closure» شکست می‌خورد. راه‌حل درست، بردن آن کلوژر به یک کنترلر است، نه بی‌خیال شدن از کش روت.
  • در نسخه‌های جدیدتر، php artisan optimize چند تا از این‌ها را با هم می‌سازد و php artisan optimize:clear همه را پاک می‌کند. این‌که دقیقاً کدام‌ها را شامل می‌شود بسته به نسخه فرق می‌کند؛ اگر می‌خواهید دقیقاً بدانید چه اتفاقی می‌افتد، دستورها را صریح بنویسید.

بازیابی سریع وقتی سایت خوابیده است#

اول سایت را برگردانید، بعد دنبال مقصر بگردید. یک دستور کافی است:

Bash
php artisan config:clear

این دستور فایل bootstrap/cache/config.php را پاک می‌کند و لاراول بلافاصله به خواندن مستقیم .env برمی‌گردد. کندتر می‌شود، ولی سالم است.

گاهی خودِ artisan بوت نمی‌شود، چون کش پیکربندی به کلاسی اشاره می‌کند که دیگر وجود ندارد یا مقداری در آن خراب است. در آن حالت هیچ دستوری اجرا نمی‌شود و باید فایل را دستی حذف کنید:

Bash
rm -f bootstrap/cache/config.php

همین یک خط سایت را برمی‌گرداند. اگر SSH ندارید، از فایل‌منیجر سی‌پنل هم می‌شود همین فایل را پاک کرد؛ اثرش دقیقاً یکی است.

اگر به کش روت هم مشکوکید:

Bash
rm -f bootstrap/cache/routes-*.php

نام دقیق این فایل بسته به نسخه فرق می‌کند، برای همین از الگو استفاده کرده‌ایم. فایل‌های packages.php و services.php در همان پوشه را هم می‌شود پاک کرد؛ لاراول خودش دوباره می‌سازدشان و اگر نساخت، php artisan package:discover کار را تمام می‌کند.

و اگر بعد از همه‌ی این‌ها مقدارها ظاهراً درست‌اند ولی باز هم اشتباه خوانده می‌شوند، به خود فایل .env شک کنید؛ یک پایان‌خط ویندوزی کافی است تا مقدار با یک کاراکتر نامرئی خوانده شود — همان چیزی که در یادداشت پایان‌خط ویندوزی در فایل .env شکافته‌ایم.

متغیرهایی که در زمان اجرا عوض می‌شوند اصلاً جای env نیستند#

یک اشتباه رایج‌تر از خود env() این است که چیزهایی را در .env می‌گذاریم که آن‌جا جایشان نیست: هزینه‌ی ارسال، حداقل مبلغ سفارش، متن پیام خوش‌آمد، روشن و خاموش بودن یک قابلیت. یعنی چیزهایی که مدیر سایت باید بتواند از پنل عوض کند.

کش پیکربندی این اشتباه را افشا می‌کند. مقدار در لحظه‌ی ساخت کش منجمد می‌شود و تا استقرار بعدی عوض نمی‌شود. حتی اگر کش هم نداشته باشید، ویرایش .env روی سرور یعنی کسی باید به فایل سیستم دسترسی داشته باشد، هیچ تاریخچه‌ای ثبت نمی‌شود، و هیچ اعتبارسنجی‌ای روی مقدار انجام نمی‌گیرد.

مرز ساده است:

کجا بگذاریمچه چیزیچرا
فایل محیطیکلید API، مشخصات دیتابیس، آدرس سایت، درایور کشبین محیط‌ها فرق دارد و در زمان اجرا عوض نمی‌شود
دیتابیسهزینه‌ی ارسال، متن‌ها، سوییچ قابلیت‌هاآدمی ممکن است هر لحظه عوضش کند

الگوی ساده‌ای که خودمان استفاده می‌کنیم، یک جدول تنظیمات به‌علاوه‌ی یک لایه‌ی کش است:

PHP
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، بعد از هر ذخیره یا حذف، کش را باطل کنید تا تغییر بلافاصله دیده شود:

PHP
protected static function booted(): void
{
    static::saved(fn () => Cache::forget('settings'));
    static::deleted(fn () => Cache::forget('settings'));
}

یک نکته‌ی مرتبط: config(['x' => 'y']) مقدار را فقط برای همان درخواست عوض می‌کند و چیزی روی دیسک نمی‌نویسد. برای تست عالی است، برای ذخیره‌ی تنظیمات اصلاً.

و اگر ورکر صف دارید، بعد از هر استقرار php artisan queue:restart را هم بزنید. پروسه‌ی در حال اجرا پیکربندی قدیمی را در حافظه نگه داشته و تا وقتی خودش تمام نشود همان را استفاده می‌کند؛ در یادداشت جداگانه‌ای درباره‌ی اجرای صف با کرون‌جاب همین موضوع را بازتر کرده‌ایم.

اسکریپت استقرار تا این مرحله‌ها فراموش نشوند#

هر چیزی که باید دستی به یاد بیاورید، یک روز فراموش می‌شود. این مرحله‌ها را در یک اسکریپت بگذارید و از آن به بعد فقط همان را اجرا کنید:

Bash
#!/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 و کامپوزر روی هر هاست فرق می‌کند؛ با which php و which composer یا از خود پنل پیدایشان کنید. مهم‌تر این‌که در شل غیرتعاملی هیچ aliasی وجود ندارد و php ممکن است نسخه‌ی دیگری باشد، پس همیشه مسیر کامل بنویسید — تله‌ای که در یادداشت PHP 8.4 در کرون‌جاب سی‌پنل مفصل شرح داده‌ایم.

set -euo pipefail یعنی اسکریپت با اولین خطا متوقف می‌شود. عارضه‌اش این است که اگر وسط کار بشکند، سایت در حالت تعمیر می‌ماند. این از سایتِ نیمه‌خراب بهتر است، ولی به شرطی که خبردار شوید؛ خروجی اسکریپت را جایی بفرستید که واقعاً می‌خوانید.

اگر route:cache به‌خاطر روت کلوژری شکست می‌خورد، آن خط را حذف نکنید — روت را درست کنید. حذف کردن خط یعنی هر بار که کسی این اسکریپت را می‌خواند، فکر می‌کند کش روت عمداً کنار گذاشته شده است. و اگر می‌خواهید بقیه‌ی تله‌های این نوع هاست را هم یک‌جا مرور کنید، چک‌لیست هاست اشتراکی برای لاراول همین‌ها را کنار هم گذاشته است.

درس اصلی#

config:cache باگ ندارد. فقط قراردادی را جدی می‌گیرد که خیلی‌ها نمی‌دانستند امضایش کرده‌اند: بعد از کش شدن پیکربندی، فایل .env برای برنامه‌ی شما وجود خارجی ندارد.

قانون را همین امروز بگذارید و دیگر به آن فکر نکنید: env() فقط داخل config/، و همه‌جای دیگر config(). یک grep ده‌ثانیه‌ای الان، جلوی یک شب خراب را می‌گیرد.

و یک عادت کلی‌تر که بارها به دردمان خورده: هر وقت چیزی بعد از یک دستور «بهینه‌سازی» خراب شد، برگردید ببینید آن دستور دقیقاً کدام مرحله را از مسیر برداشته است. بهینه‌سازی تقریباً همیشه یعنی حذف یک مرحله؛ سؤال درست این است که کدام مرحله، و چه کسی به آن مرحله وابسته بوده.