أخبرنا أحد العملاء أن كود الخصم الخاص بالعرض الترويجي ما زال يعمل معه. كان العرض قد انتهى عند منتصف الليل، وكانت الساعة وقتها الواحدة صباحًا. فتحنا سجل القسيمة: عمود expires_at يحمل بالضبط القيمة المتوقعة، ولوحة الإدارة تعرض بجانب القسيمة كلمة «منتهية». لكن الاستعلام الذي يُنفَّذ عند تطبيق الكود كان يعتبر القسيمة صالحة ويطبّق الخصم.

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

هذا الرقم مألوف: إنه فرق منطقتنا الزمنية عن UTC. أي أن أيًّا من الطرفين لم يكن معطلًا؛ كل منهما يعمل بساعة مختلفة ولم يخبر أحد الآخر. هذا المقال عن اختلاف المنطقة الزمنية بين PHP وMySQL: كيف يظهر، وكيف تشخّصه في عشر ثوانٍ، وما المعمارية التي تمنع عودته.

حدث واحد على خطين زمنيين بفارق ثلاث ساعات

العَرَض: كود خصم منتهٍ ما زال مقبولًا#

أعراض هذا الخطأ متفرقة إلى درجة أنك تسجّلها عادةً كأخطاء منفصلة. هذه هي الأعراض التي رأيناها:

  • كود خصم «منتهٍ» في لوحة الإدارة ما زال يُطبَّق في صفحة الدفع، أو العكس: كود ما زال صالحًا لثلاث ساعات يُرفض.
  • محدِّد المعدل (Rate Limiter) الذي يفترض أن يحظر لمدة عشر دقائق يُفتح قبل الوقت بكثير أو بعده بكثير.
  • سجل قيمة created_at فيه أحدث من «الآن»، أي طلب يبدو وكأنه جاء من المستقبل.
  • عدّاد تنازلي في الصفحة لا يتطابق مع الساعة الحقيقية ويخطئ دائمًا بالمقدار الثابت نفسه.
  • تقرير «مبيعات اليوم» يحسب الساعات الأولى من الصباح ضمن اليوم السابق.

الخيط المشترك بين كل هذه الأعراض رقم واحد: ثلاث ساعات. لا ثانية زيادة ولا ثانية نقصان. كلما رأيت فرقًا زمنيًا ثابتًا يساوي بالضبط فرق منطقتك الزمنية عن UTC، فلا تبحث عن خطأ في المنطق. لديك ساعتان تعملان معًا.

وأسوأ ما في الأمر أن السجل يُحفظ بشكل صحيح. فالبيانات غير تالفة، المقارنة فقط هي التالفة. لذلك لن ترى شيئًا إن نظرت إلى السجل؛ عليك أن تنظر إلى ما تمت المقارنة معه.

السبب: ساعتان مستقلتان في PHP وMySQL#

في طلب ويب بسيط يشارك ساعتان مستقلتان على الأقل، وتُضبط كل منهما على حدة:

  1. ساعة PHP وتأتي من التوجيه date.timezone في ملف php.ini. كل ما تنشئه في كودك عبر date() أو time() أو DateTime أو Carbon يقرأ من هذه الساعة.
  1. ساعة MySQL وهي المتغير time_zone وله مستويان: @@global.time_zone للخادم كله و@@session.time_zone لهذا الاتصال فقط. قيمته الافتراضية غالبًا SYSTEM، أي «ما تقوله ساعة نظام تشغيل خادم قاعدة البيانات». كل ما تكتبه في SQL — NOW() وCURDATE() وCURRENT_TIMESTAMP — يقرأ من هذه الساعة.

لا توجد أي علاقة بين الاثنتين. ضبط إحداهما لا يحرّك الأخرى. وفي الاستضافة المشتركة تكون عادةً قد ضبطت date.timezone بنفسك (أو فعلتها شركة الاستضافة) على توقيت بلدك مثل Asia/Riyadh لتظهر التواريخ صحيحة، بينما يبقى time_zone في MySQL دون تغيير على SYSTEM ونظام تشغيل الخادم على UTC. النتيجة: PHP يكتب بتوقيت بلدك وMySQL يقارن بتوقيت UTC.

الآن أصبح مسار الخطأ واضحًا. عندما يحفظ لارافيل سجلًا فإنه ينشئ نص التاريخ بنفسه ويرسله إلى قاعدة البيانات — أي أن هذا النص بُني بساعة PHP. لكن عندما تكتب استعلامًا فيه expires_at > NOW() فإن المقارنة تتم داخل MySQL نفسه، وتُملأ NOW() بساعة MySQL. أحد طرفي المقارنة جاء بتوقيت بلدك والطرف الآخر بتوقيت UTC. وهنا بالضبط يظهر فرق الساعات الثلاث.

وسبب الحيرة أن المسارين كليهما «يعملان». لا خطأ ولا استثناء ولا سجل أخطاء. الموقع سليم، وفقط القرارات المرتبطة بالوقت خاطئة قليلًا. إن كان فريقك لا يعمل ليلًا فقد لا ترى هذا الخطأ شهورًا؛ لأن الإزاحة بثلاث ساعات في معظم ساعات النهار تُبقيك داخل «اليوم» نفسه. الخطأ يظهر فقط قرب الحدود: منتصف الليل، ولحظة الانتهاء، ونهاية النافذة الزمنية.

التشخيص: اقرأ القيمة الفعلية للمنطقة الزمنية في الاثنتين#

قبل أي تعديل، اقرأ القيمة الفعلية للساعتين. لا تخمّن. هذا الاستعلام يخبرك بكل شيء دفعة واحدة:

SQL
SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();

قراءته بسيطة:

  • إذا كانت NOW() وUTC_TIMESTAMP() متساويتين فجلستك تعمل بتوقيت UTC.
  • إذا كانت NOW() متقدمة بثلاث ساعات فالجلسة تعمل بتوقيت منطقتك المحلية.
  • إذا كانت @@session.time_zone تساوي SYSTEM فالساعة الفعلية هي ساعة نظام تشغيل خادم قاعدة البيانات — وقد تختلف عن نظام تشغيل خادم الويب حتى لو كانا على جهاز واحد.

وجانب PHP بالبساطة نفسها:

PHP
echo date_default_timezone_get();   // مثلًا Asia/Riyadh أو UTC
echo (new DateTime())->format('c'); // القيمة الكاملة مع الإزاحة
echo ini_get('date.timezone');      // القيمة الخام في php.ini

وفي لارافيل، ضع الاثنتين جنبًا إلى جنب في اللحظة نفسها عبر php artisan tinker:

PHP
[
    'php' => now()->toIso8601String(),
    'app_tz' => config('app.timezone'),
    'db' => DB::selectOne('SELECT NOW() AS n, @@session.time_zone AS tz'),
];

إذا لم يطابق خرج هذا المقطع ما تتوقعه فقد وجدت الخطأ. نقطة مهمة: قد يقرأ PHP في سطر الأوامر وPHP في الويب من ملفَّي php.ini مختلفين. هذا يحدث كثيرًا في cPanel. لذا أجرِ الفحص نفسه من المتصفح أيضًا وليس من الطرفية فقط. وإذا اختلف خرج الكرون عن خرج الطرفية فغالبًا أنت أمام فخ الاسم المستعار (alias) في الصدفة غير التفاعلية للكرون: الملف التنفيذي الذي يعمل في الكرون ليس الذي تظنه — سنعود إليه لاحقًا.

فخ الاستضافة المشتركة: إزاحة رقمية فقط دون اسم المنطقة#

أول ما يفعله المرء أن يكتب في جلسة قاعدة البيانات SET time_zone = 'Asia/Riyadh' ويرتاح. في كثير من الاستضافات المشتركة ينتهي هذا الأمر بخطأ:

SQL
SET time_zone = 'Asia/Riyadh';
-- ERROR 1298 (HY000): Unknown or incorrect time zone: 'Asia/Riyadh'

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

SQL
SET time_zone = '+03:00';

هذا يعمل، لكن له أثرًا جانبيًا يجب أن تعرفه: الإزاحة الرقمية مجرد رقم وليست قاعدة. اسم المنطقة مثل Asia/Riyadh يحمل معه التاريخ الكامل لقواعد تلك المنطقة؛ أما الإزاحة الرقمية فلا تعرف شيئًا. أي أنه لو أعادت منطقتك العمل بالتوقيت الصيفي يومًا ما، فإن اسم المنطقة يتكيّف تلقائيًا والإزاحة الرقمية لا — عليك التعديل يدويًا، وإلى أن تفعل يكون كل شيء خاطئًا بساعة.

وهنا نصل إلى الجواب الرئيسي. إذا كانت الإزاحة الوحيدة التي تضبطها في قاعدة البيانات هي +00:00 فلا وجود لهذه المشكلة أصلًا؛ لأن إزاحة UTC لا تتغير أبدًا. الحل الصحيح ليس أن نضبط الساعتين على توقيت بلدنا، بل أن نضبطهما معًا على UTC.

العلاج: التخزين بتوقيت UTC والعرض بالتوقيت المحلي#

القاعدة سطر واحد، وبقية المقال مجرد تفاصيل تنفيذ هذا السطر:

خزّن كل شيء وقارنه بتوقيت UTC، وحوّل إلى التوقيت المحلي في اللحظة الأخيرة فقط، في طبقة العرض.

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

والسبب الثاني أكثر عملية: مع UTC تكون المقارنات غير ملتبسة. الفرز يعمل بشكل صحيح، والنطاقات تعمل بشكل صحيح، ولا تتكرر أي ساعة ولا تختفي (وهو ما يحدث فعلًا في التوقيت المحلي عند تغيير التوقيت الصيفي، حيث تتكرر ساعة من اليوم مرتين أو لا توجد أبدًا).

ثلاث طبقات: التخزين والمنطق بتوقيت UTC والعرض بالتوقيت المحلي مع التقويم الهجري

النقطة المهمة في هذا المخطط هي نقطة التحويل: نقطة واحدة وليس عدة نقاط. إذا توزّع التحويل في أماكن متعددة فسيحدث عاجلًا أو آجلًا أن يُحوَّل مرتين فتحصل على فرق ست ساعات — وهو أصعب في التشخيص من فرق الثلاث ساعات.

إعداد لارافيل: app.timezone والمنطقة الزمنية لاتصال قاعدة البيانات#

تحتاج إلى ثلاثة إعدادات ويجب تنفيذها كلها معًا. واحد منها فقط لا يكفي.

أولًا، المنطقة الزمنية للتطبيق:

PHP
// config/app.php
'timezone' => 'UTC',

هذا يعني أن كل now() وكل Carbon تعيده النماذج (Models) وكل created_at ينشئه لارافيل عند الإدراج سيكون بتوقيت UTC.

ثانيًا، المنطقة الزمنية لاتصال قاعدة البيانات. يعرف لارافيل المفتاح timezone لمشغّل MySQL، وإذا ضُبط فإنه ينفّذ الأمر SET time_zone فور فتح الاتصال:

PHP
// config/database.php
'mysql' => [
    'driver' => 'mysql',
    // …
    'timezone' => '+00:00',
],

وضعنا إزاحة رقمية وليس UTC للسبب نفسه الذي ذكرناه أعلاه: +00:00 تعمل على أي خادم حتى لو لم تُحمَّل جداول المناطق الزمنية.

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

ثالثًا، ساعة PHP نفسها في php.ini. إذا بقيت date.timezone على قيمة قديمة مثل Asia/Riyadh فإن لارافيل يتجاوزها بـ app.timezone لكوده هو، لكن أي مكتبة أو سكربت مستقل يعمل خارج إقلاع لارافيل سيعود إلى php.ini من جديد. للتوحيد، ضع في محرر php.ini في cPanel:

INI
date.timezone = UTC

تحذير جدي: إذا كان مشروعك قد خزّن بياناته بالتوقيت المحلي وتغيّر الآن app.timezone إلى UTC، فإن البيانات القديمة لا تُصحَّح تلقائيًا. فقد كُتبت بالتوقيت المحلي وستُقرأ من الآن فصاعدًا على أنها UTC، أي ستُفسَّر كلها منزاحة بثلاث ساعات. قبل التحويل يجب أن تكتب ترحيلًا (Migration) يزيح أعمدة التاريخ الموجودة، وتختبره على نسخة احتياطية، وتعرف أي الأعمدة كُتبت فعلًا بكودك وأيها كُتبت بـ NOW() قاعدة البيانات — لأن الفئتين ليس لهما الإزاحة نفسها ولا يجوز إزاحتهما بالطريقة ذاتها.

التاريخ الهجري طبقة عرض وليس نوع بيانات#

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

لذلك لا تخزّن أبدًا نص التاريخ الهجري في عمود تاريخ. إذا رأيت عمود varchar مليئًا بقيم مثل 1447/04/10 فقد خسرت شيئين:

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

وهناك فخ عملي إضافي: يُحسب طول varchar في MySQL بالأحرف لا بالبايتات، لكن varchar(10) الذي يبدو مناسبًا لـ «1447/04/10» يفيض بمجرد إضافة ساعة أو فاصل إضافي إلى النص، فتصطدم بـ الخطأ 1406 ورسالة data too long — خطأ لا تشير رسالته إلى التاريخ إطلاقًا ويستغرق الأمر وقتًا لتعرف مصدره.

النمط الصحيح بسيط: يبقى عمود datetime بتوقيت UTC، وفي طبقة العرض تحوّل أولًا إلى التوقيت المحلي وبعد ذلك إلى التقويم الهجري:

PHP
$local = $order->created_at->copy()->timezone('Asia/Riyadh');
// ثم مرّر $local إلى حزمة تحويل التقويم

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

صياغة تحويل التقويم تختلف حسب الحزمة التي تستخدمها؛ والذي لا يختلف هو أن التحويل يجب أن يكون في طبقة العرض وعلى قيمة صارت بالفعل بالتوقيت المحلي.

الاستعلامات التي تستدعي NOW() و CURDATE() بنفسها#

لنعد إلى القسيمة نفسها. الاستعلام الذي أوقعنا في المشكلة كان بهذا الشكل:

PHP
$coupon = Coupon::where('code', $code)
    ->whereRaw('expires_at > NOW()')
    ->first();

يبدو غير ضار، ويعمل بشكل صحيح في بيئة مضبوطة جيدًا. المشكلة أن هذا الاستعلام يُخرج قرار الوقت من التطبيق ويسلّمه لقاعدة البيانات. أي أنه حتى لو ضبطت app.timezone على UTC فإن هذا السطر يظل يقرأ من ساعة MySQL، وإذا كانت تلك الساعة في مكان آخر فلن تلاحظ ذلك أبدًا.

النمط الصحيح أن تمرّر الوقت من PHP:

PHP
$coupon = Coupon::where('code', $code)
    ->where('expires_at', '>', now())
    ->first();

الآن أصبحت now() كائن Carbon بتوقيت UTC، ويربطه لارافيل كمعامل (Parameter) في الاستعلام، فتتم المقارنة بين قيمتين من مرجع واحد. لم تعد ساعة قاعدة البيانات تتدخل في القرار إطلاقًا.

الأمر نفسه ينطبق على CURDATE() وهنا أسوأ، لأن CURDATE() تحدد حدّ اليوم، وحد اليوم هو بالضبط المكان الذي يُحدث فيه فرق الساعات أكبر ضرر. لا تحصل على «طلبات اليوم» بهذه الطريقة:

PHP
Order::whereRaw('DATE(created_at) = CURDATE()')->count();

بل ابنِ حد اليوم في PHP بالتوقيت المحلي ثم حوّله إلى UTC:

PHP
$start = Carbon::now('Asia/Riyadh')->startOfDay()->utc();
$end   = $start->copy()->addDay();

Order::whereBetween('created_at', [$start, $end])->count();

لهذه النسخة ميزة أخرى: بما أن العمود نفسه لم تُطبَّق عليه دالة، فإن فهرس created_at يُستخدم. أما DATE(created_at) فيعطّل الفهرس ويُبطئ الاستعلام على الجداول الكبيرة.

الكرون يعمل بساعة النظام وليس بساعة التطبيق#

هنا تدخل ساعة ثالثة لا يحسب لها أحد حسابًا عادةً: ساعة نظام تشغيل الخادم التي يقرر الكرون بناءً عليها.

الكرون لا يعرف شيئًا عن app.timezone الخاص بك. عندما تكتب في cPanel أن مهمة ما تعمل في 0 0 * * * فالمقصود منتصف ليل النظام. وإذا كان النظام على UTC فتلك اللحظة في بلدك هي الثالثة فجرًا (في مثالنا). فإذا كانت المهمة «تقرير مبيعات الأمس» فإن تقريرك سحب ثلاث ساعات من اليوم الحالي وأسقط الكمية نفسها من بداية الأمس.

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

Bash
* * * * * /usr/local/bin/php /home/user/app/artisan schedule:run >> /dev/null 2>&1

ثم اكتب المنطقة الزمنية صراحةً في تعريف الجدولة (موضع التعريف يختلف حسب إصدار لارافيل — في الإصدارات الحديثة هو الملف routes/console.php):

PHP
Schedule::command('report:daily')
    ->dailyAt('00:15')
    ->timezone('Asia/Riyadh');

الآن يطلق الكرون كل دقيقة فقط، وتعمل المجدولات الفعلية بالساعة التي حدّدتها أنت. وإذا لم يكن مسار ملف PHP التنفيذي في الكرون هو الذي تراه في الطرفية فاقرأ أولًا فخ alias في كرون cPanel؛ وإلا فقد لا تعمل الجدولة كلها أصلًا وتظن أن المشكلة في المنطقة الزمنية.

فخ العمود: timestamp مقابل datetime#

يبدو هذان النوعان من الأعمدة متشابهين لكن سلوكهما مع المنطقة الزمنية مختلف تمامًا:

نوع العمودعند الكتابةعند القراءة
TIMESTAMPتُحوَّل القيمة من منطقة الجلسة إلى UTC وتُخزَّنتُعاد من UTC إلى منطقة الجلسة
DATETIMEتُخزَّن القيمة كما أعطيتهاتُعاد القيمة نفسها دون تغيير

أي أن TIMESTAMP يجري التحويل بنفسه وDATETIME لا يفعل. إذا كانت قيمة time_zone في جلستك تختلف في كل مرة فالقيمة التي تقرؤها من عمود TIMESTAMP ستختلف أيضًا — مع أن البايتات على القرص لم تتحرك.

ثلاث نتائج عملية:

  • إذا ثبّتَّ المنطقة الزمنية للاتصال صراحةً على +00:00 فإن النوعين يتصرفان بالطريقة نفسها ولا يزعجك هذا الفرق. وهذا سبب آخر لتثبيت ذلك الإعداد.
  • أسوأ حالة أن يحتوي جدول واحد على TIMESTAMP وDATETIME معًا. عندها يُقرأ عمودان من الصف نفسه بقاعدتين مختلفتين ويتغير الفرق بينهما حسب إعداد الجلسة.
  • لـ TIMESTAMP نطاق مسموح ينتهي في عام 2038. للتواريخ البعيدة في المستقبل — مثل صلاحية اشتراك طويل الأمد — استخدم DATETIME من الآن.

اخترنا في جداولنا DATETIME بتوقيت UTC مع تثبيت المنطقة الزمنية للاتصال على +00:00. هذا المزيج بلا سلوك خفي: ما تكتبه هو ما يعود إليك.

الاختبار: اكتب سجلًا واقرأه من المسارين#

بعد أي تغيير في هذه الإعدادات أجرِ اختبارًا من عشر ثوانٍ. منطق الاختبار بسيط: اكتب سجلًا، ثم اقرأه من مسار ORM ومن مسار SQL الخام، وضع القيم جنبًا إلى جنب.

PHP
$id = DB::table('orders')->insertGetId([
    'user_id'    => 1,
    'total'      => 0,
    'created_at' => now(),
    'updated_at' => now(),
]);

$raw = DB::selectOne(
    'SELECT created_at, NOW() AS db_now, UTC_TIMESTAMP() AS db_utc FROM orders WHERE id = ?',
    [$id]
);

dump([
    'php_now'    => now()->toIso8601String(),
    'model'      => Order::find($id)->created_at->toIso8601String(),
    'raw_column' => $raw->created_at,
    'db_now'     => $raw->db_now,
    'db_utc'     => $raw->db_utc,
]);

الخرج الصحيح يكون هكذا:

  • يجب أن تكون db_now وdb_utc متساويتين. إذا لم تكونا كذلك فالمنطقة الزمنية للاتصال ليست على UTC بعد.
  • يجب أن تكون raw_column هي لحظة php_now نفسها (بلا إزاحة، لأن كليهما UTC).
  • يجب أن تعرض model اللحظة نفسها بإزاحة صفرية (+00:00).

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

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

الدرس الرئيسي#

المنطقة الزمنية ليست إعدادًا؛ إنها عقد بين جميع المكونات التي تتعامل مع الوقت. PHP وMySQL والكرون وطبقة العرض — لكل منها ساعته الخاصة ولا يسأل أحدها الآخر.

لو أردنا جملة واحدة نأخذها معنا: كلما رأيت فرقًا زمنيًا ثابتًا فلا تبحث عن خطأ في المنطق؛ ابحث عن الساعة الثانية. رقم الفرق نفسه يعطيك العنوان. ثلاث ساعات تعني منطقتك الزمنية وUTC. ساعة واحدة زيادة أو نقصانًا تعني أن قاعدة التوقيت الصيفي تُطبَّق في مكان ما. وست ساعات تعني أنك حوّلت مرتين.

والحل واحد دائمًا: اعتمد مرجعًا زمنيًا واحدًا للتخزين والمقارنة — UTC — وأخّر التحويل إلى التوقيت المحلي والتقويم الهجري إلى الطبقة الأخيرة، حيث لا تفعل سوى طباعة القيمة.