> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# أخطاء شائعة وأسلوب PHP الحديث Modern PHP
- URL: https://arabroot.io/articles/اخطاء-شائعة-في-php-واسلوب-php-الحديث/
- Published: 2026-10-31T10:07:38.000Z
- Updated: 2026-10-31T11:07:02.000Z
- Description: ثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

بعد شهر من العمل على متجر القهوة والتمور بـ PHP، صار الكود الذي تكتبه يعمل، وتمر اختباراته، ويقبله PHPStan في أغلب الأحيان. ولكن في مراجعة الكود Code Review يكتب لك الزميل الذي يعمل بـ PHP منذ سنوات تعليقات صغيرة لا تفهم سببها أول مرة: «لا تستخدم `empty` هنا»، «أضف `true` في آخر `in_array`»، «احذف `&` من هذه الحلقة»، «استخدم `DateTimeImmutable`». وكل تعليق منها يبدو تفضيلاً شخصياً، إلى أن يصلك تقرير من الدعم أن سعر الهيل في الفاتورة صار مثل سعر التمر، أو أن عميلاً أدخل كود الخصم «0» فتجاهله المتجر. وهذه هي الأخطاء التي يقع فيها المطور القادم من لغة أخرى: الكود صحيح في Python أو JavaScript أو Java، ويعمل في PHP دون أي رسالة، ولكنه يعطي نتيجة أخرى.

وهذا المقال الأخير في مرحلة [لغة PHP للمطورين](https://arabroot.io/start/#stage-lang-php)، وفيه ثلاثة عشر خطأ من هذا النوع، كل خطأ في قسم مستقل من ملف واحد `mistakes.php` يشغل النسخة الخاطئة ثم النسخة الصحيحة، ونقرأ المخرج الحقيقي من PHP 8.5.12\. وبعدها نرى أي هذه الأخطاء أشار إليها PHPStan وأيها مر دون أي تنبيه، ثم نضع خطة قراءة من المصادر الرسمية لمن يريد أن يكمل. وبعض هذه الأخطاء لمسناها في المقالات السابقة، مثل المقارنة `==` في المقال الأول، والمصفوفة التي تصبح كائناً في JSON، فهنا نجمعها في مكان واحد كمرجع ترجع إليه.

وسوف نناقش في هذا المقال ما يلي:

- كيف شغلنا التجربة، ولماذا ثلاثة عشر خطأ بالذات.
- أخطاء المصفوفات Arrays: المرجع في `foreach`، و`array_merge` والمفاتيح، و`unset` قبل JSON، و`sort` التي تعيد `true`.
- أخطاء النصوص Strings: `strlen` بالبايت مع العربية، و`strpos` التي تعيد صفراً.
- أخطاء المقارنة Comparison: `array_search` و`in_array`، و`empty` مع «0»، و`switch`.
- الكائنات والتواريخ: `DateTime` القابل للتعديل والمنطقة الزمنية، و`clone` السطحي.
- الأمان: حقن SQL، وتخزين الباسورد بـ md5.
- ما الذي كشفه PHPStan وما الذي فاته، ثم خطة قراءة من المصادر الرسمية.

## التجربة Lab: ملف واحد وثلاثة عشر قسماً

اخترنا الأخطاء من معيار واحد: أن يكون الكود الخاطئ طبيعياً لمن جاء من لغة أخرى، وأن يعمل في PHP دون أي خطأ أو تحذير، وأن يعطي نتيجة مختلفة عما يتوقعه. فالأخطاء التي يرفضها PHP بنفسه، مثل نوع خاطئ مع `strict_types`، لا تحتاج مقالاً، لأن PHP يخبرك بها. وكل قسم في الملف يطبع سطر `WRONG` بالنسخة الخاطئة وسطر `FIXED` بالنسخة الصحيحة، ويمكن تشغيل قسم واحد برقمه:

```bash
docker run --rm -v "$PWD:/app" -w /app slp-php-cli:8.5 php mistakes.php
docker run --rm -v "$PWD:/app" -w /app slp-php-cli:8.5 php mistakes.php 9
```

وفي أول الملف سطر `date_default_timezone_set('UTC')` يحاكي ما يحدث في صورة Docker الرسمية عندما لا يحدد `php.ini` أي منطقة زمنية، وسوف ترى أثره في الخطأ التاسع. والشكل التالي يلخص الأخطاء الثلاثة عشر في خمس مجموعات، ومعها ما إذا كان PHPStan قد أشار إليها في التجربة:

![خمس مجموعات: المصفوفات وفيها foreach بالمرجع وarray_merge والمفاتيح وunset ثم JSON وsort التي تعيد true، والنصوص وفيها strlen بالبايت وstrpos التي تعيد 0، والمقارنة وفيها array_search وin_array وempty مع الصفر وswitch، والكائنات والتواريخ وفيها DateTime القابل للتعديل وclone السطحي، والأمان وفيها SQL بالدمج وmd5 للباسورد، وبجانب كل خطأ علامة تبين أن PHPStan أشار إلى خطأين فقط منها](https://arabroot.io/content/images/2026/10/php-idioms-01-map.webp)

ثلاثة عشر خطأ تعمل كلها دون أي رسالة، وPHPStan أشار إلى اثنين منها فقط

ويمكنك أن تستخدم الملف نفسه بطريقة أخرى إذا كنت تقود فريقاً يستقبل مطورين جدداً من لغات أخرى: اطلب من المطور الجديد أن يقرأ كل قسم ويتوقع النتيجة قبل أن يشغله، ثم يقارن توقعه بالمخرج. والفكرة أن القسم الذي يتوقع المطور نتيجته بشكل خاطئ هو الخطأ المرشح أن يكتبه في أول شهر، لأن التوقع الخاطئ يعني أن عادته من لغته السابقة ما زالت تقود يده، وهذه العادات تختلف حسب اللغة التي جاء منها: القادم من JavaScript يتوقع أن `sort` تعيد المصفوفة، والقادم من Python يتوقع أن `strlen` تعد الحروف، والقادم من Java يتوقع أن `switch` صارم. وبعد ساعة واحدة مع الملف يصبح عنده قائمة شخصية قصيرة يراجع بها كوده قبل أن يرسله للمراجعة، وهذه القائمة الشخصية أنفع له من أي قائمة عامة، لأنها مبنية على عاداته هو وليس على عادات غيره. وأضف إلى الملف كل خطأ جديد يظهر في مراجعات فريقك، بنسخة خاطئة ونسخة صحيحة ومخرج حقيقي، فيصبح مع الوقت دليل الفريق نفسه المبني على أخطائه الحقيقية، بدل وثيقة طويلة لا يقرؤها أحد بعد أسبوعها الأول.

## أخطاء المصفوفات Arrays

المصفوفة في PHP نوع واحد للقائمة والقاموس، وتنسخ كقيمة، كما رأينا في المقال الأول، وأغلب أخطاء هذه المجموعة تأتي من نسيان واحدة من هاتين الحقيقتين.

### الخطأ 1: المرجع Reference الذي يبقى بعد foreach

القادم من Python أو JavaScript يريد أن يعدل عناصر المصفوفة داخل الحلقة، فيتعلم أن PHP يحتاج `&` لذلك، ويكتب:

```php
$prices = [990, 4500, 1250];
foreach ($prices as &$p) {
    $p = intdiv($p * 115 + 50, 100);
}
foreach ($prices as $p) {          // $p is still a reference to $prices[2]
}
echo 'WRONG ', json_encode($prices), "\n";
```

```text
WRONG [1139,5175,5175]
FIXED [1139,5175,1438]
```

![أربع مراحل للمصفوفة: بعد الحلقة الأولى بالمرجع يبقى $p مرجعاً إلى العنصر الأخير 1438، ثم في الحلقة الثانية يكتب كل إسناد في العنصر الأخير فيصبح 1139 ثم 5175، والنتيجة أن سعر الهيل ضاع وصار مثل التمر، وتحتها الإصلاح بـ array_map أو unset بعد الحلقة](https://arabroot.io/content/images/2026/10/php-idioms-02-foreach-ref.webp)

المتغير $p بقي مرجعاً بعد نهاية الحلقة، والحلقة الثانية كتبت فيه

والسعر الأخير، الهيل بعد الضريبة 1438، صار 5175 وهو سعر التمر. والسبب أن المتغير `$p` لا يختفي بعد الحلقة الأولى في PHP، لأن نطاق المتغيرات في PHP هو الدالة وليس الحلقة، فيبقى مرجعاً إلى العنصر الأخير. ثم تأتي الحلقة الثانية، وكل دورة فيها تكتب قيمة العنصر الحالي في `$p`، أي في العنصر الأخير، حتى تصل إلى العنصر الأخير نفسه فتكتب فيه قيمته الجديدة. وهذا السلوك موثق بتحذير صريح في [توثيق foreach](https://www.php.net/manual/en/control-structures.foreach.php?ref=arabroot.io). والإصلاح الذي في الملف هو `array_map(fn (int $p) => intdiv($p * 115 + 50, 100), $prices)`، والذي يعيد مصفوفة جديدة ولا يعدل الأصل، وهذا أوضح عند القراءة. وإذا كنت تحتاج التعديل في المكان لأن المصفوفة كبيرة جداً، فاكتب `unset($p)` في السطر التالي للحلقة مباشرة، دائماً.

### الخطأ 5: array\_merge تعيد ترقيم المفاتيح Keys

في المتجر نحفظ المخزون في مصفوفة مفاتيحها أرقام المنتجات، ونريد أن ندمج مخزون مستودعين. والقادم من JavaScript يبحث عن ما يقابل `{...a, ...b}` فيجد `array_merge`:

```php
$stockA = [101 => 5, 205 => 2];      // product id => qty
$stockB = [307 => 9];
echo 'WRONG ', json_encode(array_merge($stockA, $stockB)), "\n";
echo 'FIXED ', json_encode($stockA + $stockB), ' | array_replace: ', json_encode(array_replace($stockA, [205 => 7])), "\n";
```

```text
WRONG [5,2,9]
FIXED {"101":5,"205":2,"307":9} | array_replace: {"101":5,"205":7}
```

اختفت أرقام المنتجات كلها، لأن [array\_merge](https://www.php.net/manual/en/function.array-merge.php?ref=arabroot.io) تعامل المفاتيح الرقمية كمواقع في قائمة، فتعيد ترقيمها من الصفر، وتحفظ المفاتيح النصية فقط. أما العامل `+` فيحفظ كل المفاتيح، وعند التكرار يأخذ قيمة المصفوفة الأولى، و`array_replace` يحفظها أيضاً ويأخذ قيمة الثانية. والقاعدة: إذا كانت مفاتيح المصفوفة معرفات IDs فهي قاموس، ولا تستخدم معها `array_merge` أبداً. ونفس الخطر في `array_merge(...$lists)` عندما تجمع قوائم، فهي صحيحة هناك لأن المفاتيح مواقع.

### الخطأ 6: unset في قائمة يحول JSON إلى كائن

رأينا هذا في المقال الأول باختصار، وهنا بمخرجه:

```php
$items = ['قهوة', 'تمر', 'هيل'];
unset($items[1]);
echo 'WRONG ', json_encode($items, JSON_UNESCAPED_UNICODE), "\n";
```

```text
WRONG {"0":"قهوة","2":"هيل"}
FIXED ["قهوة","هيل"] | array_filter+values: ["قهوة","هيل"]
```

![ثلاثة صناديق: الخطأ 5 حيث array_merge على مخزون مفاتيحه 101 و205 و307 أعطت قائمة 5 و2 و9 بينما العامل + حفظ المفاتيح، والخطأ 6 حيث unset للعنصر الثاني جعل JSON كائناً بالمفاتيح 0 و2 وarray_values أعادته قائمة، والخطأ 8 حيث sort أعادت true وعدلت المصفوفة في مكانها وasort حفظت المفاتيح](https://arabroot.io/content/images/2026/10/php-idioms-04-arrays-json.webp)

في PHP المفاتيح هي التي تقرر هل المصفوفة قائمة أم قاموس

بعد `unset` صارت المفاتيح 0 و2، وهذه ليست قائمة متصلة، فكتبها `json_encode` ككائن JSON، والتطبيق الذي ينتظر قائمة ويستدعي `.map()` عليها سوف يتوقف. ونفس الشيء يحدث مع `array_filter` لأنها تحفظ المفاتيح الأصلية، وهذا أكثر شيوعاً لأن من يكتبها لا يرى أي `unset`. والإصلاح أن تمرر النتيجة على [array\_values](https://www.php.net/manual/en/function.array-values.php?ref=arabroot.io) قبل أي `json_encode`، أو أن تتحقق بـ `array_is_list` في الاختبار. وفي المقال الرابع رأيت أن قاعدة التحقق `list` في Laravel تمنع العكس، أي أن يرسل العميل كائناً بدل القائمة.

### الخطأ 8: sort ترتب في المكان وتعيد true

في Python تعيد `sorted(prices)` قائمة جديدة، وفي JavaScript تعيد `prices.sort()` نفس المصفوفة بعد ترتيبها، فيكتب القادم منهما:

```php
$prices = [4500, 990, 1250];
$sorted = sort($prices);
echo 'WRONG $sorted = ', var_export($sorted, true), "\n";
```

```text
WRONG $sorted = true
FIXED [990,1250,4500] | asort keeps keys: {"قهوة":990,"هيل":1250}
```

دوال الترتيب في PHP تأخذ المصفوفة بالمرجع، وترتبها في مكانها، وتعيد `true` دائماً منذ PHP 8.2، كما في [توثيق sort](https://www.php.net/manual/en/function.sort.php?ref=arabroot.io). ولاحظ أن `sort` تحذف المفاتيح وتعيد ترقيمها، فإذا كانت المفاتيح أسماء منتجات أو معرفات فاستخدم `asort` للترتيب حسب القيمة مع حفظ المفاتيح، و`ksort` للترتيب حسب المفتاح، و`usort` مع العامل `<=>` للترتيب بقاعدتك. والأسماء نفسها تخبرك: الحرف `a` يعني Associative أي يحفظ المفاتيح، و`k` يعني حسب المفتاح، و`u` يعني بدالة منك.

## أخطاء النصوص Strings

### الخطأ 2: strlen وsubstr تعدان البايتات Bytes

في Python 3 وJava وJavaScript النص سلسلة من الحروف، أما في PHP فهو سلسلة من البايتات، وكل حرف عربي في UTF-8 بايتان. والمتجر يعرض أول أربعة حروف من اسم المنتج في إشعار قصير:

```php
$name = 'قهوة خولانية';
echo 'WRONG strlen=', strlen($name), ' substr=', substr($name, 0, 5), ' valid utf-8? ', var_export(mb_check_encoding(substr($name, 0, 5), 'UTF-8'), true), "\n";
echo 'FIXED mb_strlen=', mb_strlen($name), ' mb_substr=', mb_substr($name, 0, 4), ' strtoupper(é)=', strtoupper('é'), ' mb_strtoupper(é)=', mb_strtoupper('é'), "\n";
```

```text
WRONG strlen=23 substr=قه� valid utf-8? false
FIXED mb_strlen=12 mb_substr=قهوة strtoupper(é)=é mb_strtoupper(é)=É
```

![كلمة قهوة بأربعة حروف تحت كل حرف بايتان، والحرف الثالث محاط بخط أحمر لأن substr بخمسة بايتات قطعته من منتصفه، وتحتها صندوقان: دوال البايت حيث strlen أعطت 23 وsubstr أعطت قه ثم بايت مكسور وstrtoupper لم تغير é، ودوال mbstring حيث mb_strlen أعطت 12 وmb_substr أعطت قهوة وmb_strtoupper أعطت É](https://arabroot.io/content/images/2026/10/php-idioms-03-bytes.webp)

كل حرف عربي بايتان، ودوال PHP الأساسية تعد البايتات

الاسم «قهوة خولانية» فيه 12 حرفاً بالمسافة، و`strlen` أعطت 23 لأنها تعد 11 حرفاً عربياً بايتين لكل منها مع مسافة ببايت واحد. و`substr($name, 0, 5)` أخذت خمسة بايتات، أي حرفين ونصف، فخرج نص غير صالح بترميز UTF-8، وهذا النص المكسور إذا وصل إلى `json_encode` يجعلها تفشل وتعيد `false`. والدوال الصحيحة من إضافة [mbstring](https://www.php.net/manual/en/book.mbstring.php?ref=arabroot.io) تبدأ بـ `mb_` وتعد الحروف. ولاحظ أيضاً أن `strtoupper('é')` لم تغير شيئاً، لأن الدوال الأساسية منذ PHP 8.2 لا تعرف إلا حروف ASCII مهما كانت إعدادات اللغة Locale. والقاعدة لأي متجر أو نظام عربي: كل عملية على طول النص أو جزء منه أو حالة حروفه تكون بـ `mb_`، وحدود الطول في التحقق من المدخلات بالحروف وليس بالبايتات.

### الخطأ 3: strpos تعيد صفراً عندما يكون النص في البداية

رموز المنتجات السعودية في المتجر تبدأ بـ `SA`، ونريد أن نعرضها بعلامة خاصة:

```php
$sku = 'SA-COFFEE-01';
echo 'WRONG ', strpos($sku, 'SA') ? 'Saudi product' : 'not Saudi', "\n";
echo 'FIXED ', str_starts_with($sku, 'SA') ? 'Saudi product' : 'not Saudi', ' | str_contains: ', var_export(str_contains($sku, 'COFFEE'), true), "\n";
```

```text
WRONG not Saudi
FIXED Saudi product | str_contains: true
```

الدالة `strpos` تعيد موقع النص، وهو هنا صفر لأن `SA` في البداية، وتعيد `false` إذا لم تجده. والصفر في الشرط يعامل كـ `false`، فخرج المنتج السعودي «غير سعودي». وهذا خطأ قديم جداً لدرجة أن PHP 8.0 أضاف ثلاث دوال لتجنبه: `str_contains` و[str\_starts\_with](https://www.php.net/manual/en/function.str-starts-with.php?ref=arabroot.io) و`str_ends_with`، وكلها تعيد `bool` فقط. وإذا احتجت الموقع نفسه فقارن بـ `!== false`، ولا تستخدم `strpos` في شرط مباشرة أبداً.

## أخطاء المقارنة Comparison

### الخطأ 4: array\_search وin\_array

نفس مشكلة الصفر تتكرر مع `array_search`، ومعها مشكلة المقارنة المتساهلة التي رأيناها في المقال الأول:

```php
$codes = ['10', '20', '30'];
$i = array_search('10', $codes);
echo 'WRONG ', $i ? "found at {$i}" : 'not found', ' | in_array("1e1"): ', var_export(in_array('1e1', $codes), true), "\n";
$i = array_search('10', $codes, true);
echo 'FIXED ', $i !== false ? "found at {$i}" : 'not found', ' | in_array("1e1", strict): ', var_export(in_array('1e1', $codes, true), true), "\n";
```

```text
WRONG not found | in_array("1e1"): true
FIXED found at 0 | in_array("1e1", strict): false
```

الكود «10» موجود في الموقع صفر، فخرج «غير موجود». وفي نفس السطر `in_array('1e1', $codes)` أعطت `true`، أي أن PHP وجد «1e1» في قائمة ليس فيها، لأن النصين الرقميين يقارنان كرقمين، و`1e1` عشرة. والدالتان [array\_search](https://www.php.net/manual/en/function.array-search.php?ref=arabroot.io) و`in_array` تقبلان معاملاً ثالثاً `strict`، وهو `false` افتراضياً لأسباب التوافق مع الكود القديم. والقاعدة: اكتب `true` في المعامل الثالث دائماً، وقارن نتيجة `array_search` بـ `!== false`. وتخيل أن القائمة هي قائمة أكواد خصم صالحة، وأن المستخدم يرسل «1e1».

### الخطأ 7: empty تعتبر «0» فارغاً

عميل أدخل كود خصم «0»، وهو كود حقيقي في حملة سابقة، فتجاهله المتجر:

```php
$discountCode = '0';               // a real code typed by the customer
echo 'WRONG ', empty($discountCode) ? 'no code' : "code {$discountCode}", "\n";
```

```text
WRONG no code
FIXED code 0 | isset+!== "": true
```

[empty](https://www.php.net/manual/en/function.empty.php?ref=arabroot.io) تعيد `true` لكل قيمة تساوي `false` عند التحويل، وهذه القائمة تشمل النص `"0"` مع `""` و`0` و`0.0` و`null` والمصفوفة الفارغة. والنص «0» حالة خاصة في PHP لا توجد في Python ولا JavaScript، فهناك النص غير الفارغ دائماً صحيح. والإصلاح أن تقول ما تقصده بالضبط: إذا كنت تقصد «نص فارغ» فاكتب `$code === ''`، وإذا كنت تقصد «غير موجود» فاكتب `isset` أو `=== null`. ولا تستخدم `empty` مع مدخلات المستخدم أبداً.

### الخطأ 11: switch يقارن بـ == وmatch يقارن بـ ===

```php
$code = '1e1';
switch ($code) {
    case '10':
        $r = 'matched 10';
        break;
    default:
        $r = 'no match';
}
echo "WRONG switch('1e1'): {$r}\n";
echo "FIXED match('1e1'): ", match ($code) { '10' => 'matched 10', default => 'no match' }, "\n";
```

```text
WRONG switch('1e1'): matched 10
FIXED match('1e1'): no match
```

القادم من Java أو C يتوقع أن `switch` يقارن بدقة، ولكنه في PHP يستخدم `==`، فطابق «1e1» الحالة «10». و[match](https://www.php.net/manual/en/control-structures.match.php?ref=arabroot.io) الذي أضيف في PHP 8.0 يستخدم `===`، ويعيد قيمة، ويرمي `UnhandledMatchError` إذا لم تطابق أي حالة ولم يكن هناك `default`، ولا يحتاج `break` فلا يمكن أن تنسى واحدة فيمر التنفيذ إلى الحالة التالية. والقاعدة: في أي كود جديد استخدم `match`، واترك `switch` للحالات التي تحتاج فيها عدة أسطر في كل حالة، مع الانتباه إلى أنواع القيم.

## أخطاء الكائنات والتواريخ Objects and Dates

### الخطأ 9: DateTime قابل للتعديل والمنطقة الزمنية UTC

طلب أنشئ الساعة 10:30 مساءً بتوقيت السعودية، ونريد تاريخ التسليم بعد يومين:

```php
$created = new DateTime('2026-10-31 22:30:00');
$due = $created->modify('+2 days');
echo 'WRONG created=', $created->format('Y-m-d H:i'), ' due=', $due->format('Y-m-d H:i'), ' tz=', date_default_timezone_get(), "\n";
$created = new DateTimeImmutable('2026-10-31 22:30:00', new DateTimeZone('Asia/Riyadh'));
$due = $created->modify('+2 days');
```

```text
WRONG created=2026-11-02 22:30 due=2026-11-02 22:30 tz=UTC
FIXED created=2026-10-31 22:30 +03 due=2026-11-02 22:30 +03 in UTC=2026-10-31 19:30
```

في المخرج الخاطئ مشكلتان. الأولى أن تاريخ الإنشاء نفسه تغير إلى 2 نوفمبر، لأن `modify` في `DateTime` تعدل الكائن نفسه وتعيده، فـ `$created` و`$due` هما نفس الكائن، وهذا ما رأيناه في المقال الأول عن الكائنات التي تمرر كمقبض. و[DateTimeImmutable](https://www.php.net/manual/en/class.datetimeimmutable.php?ref=arabroot.io) تعيد كائناً جديداً في كل تعديل، فيبقى الأصل كما هو، وهي ما تستخدمه أطر العمل الحديثة. والمشكلة الثانية أن المنطقة الزمنية الافتراضية UTC، فالساعة 22:30 فهمت بتوقيت UTC، وهي في الحقيقة 1:30 فجراً من اليوم التالي بتوقيت السعودية، والطلب قد يظهر في تقرير اليوم الخطأ. والإصلاح أن تحدد المنطقة الزمنية صراحة عند إنشاء التاريخ، وأن تحفظ في قاعدة البيانات بنوع يحمل المنطقة مثل `timestamptz` كما في المقال الرابع، وأن تحول إلى توقيت العرض عند العرض فقط. والسطر الأخير يبين ذلك: 22:30 بتوقيت السعودية هي 19:30 بتوقيت UTC.

### الخطأ 10: clone نسخ سطحي Shallow Copy

القادم من Java يعرف هذا الخطأ، والقادم من JavaScript يعرفه من `{...obj}`، ولكنه في PHP يفاجئ لأن المصفوفات تنسخ بعمق تلقائياً، فيظن المطور أن الكائنات كذلك:

```php
$cart = new ArrayObject(['items' => new ArrayObject(['قهوة'])]);
$copy = clone $cart;
$copy['items'][] = 'تمر';
echo 'WRONG original has ', count($cart['items']), " items\n";

final class Cart
{
    public function __construct(public ArrayObject $items) {}

    public function __clone()
    {
        $this->items = clone $this->items;   // deep copy what the object owns
    }
}
```

```text
WRONG original has 2 items
FIXED original has 1 item, copy has 2
```

`clone` ينسخ الكائن نفسه، ولكن الخصائص التي هي كائنات تنسخ كمقابض، فالسلة الأصلية ونسختها تشتركان في نفس قائمة الأسطر، وإضافة التمر إلى النسخة أضافته إلى الأصل. والإصلاح الدالة السحرية `__clone` التي يستدعيها PHP على النسخة بعد نسخها، وفيها تنسخ كل كائن يملكه الكائن، كما في [توثيق النسخ](https://www.php.net/manual/en/language.oop5.cloning.php?ref=arabroot.io). والحل الأفضل في كثير من الحالات هو ما رأيناه في المقال الأول: كائنات `readonly` لا تتغير، والتعديل ينتج كائناً جديداً بـ `clone` مع التعديل، فلا يوجد ما يتغير من خلفك أصلاً.

## أخطاء الأمان Security

### الخطأ 12: بناء SQL بدمج النصوص

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

```php
$input = "a' OR '1'='1";
$rows = $db->query("SELECT id, total FROM orders WHERE customer = '{$input}'")->fetchAll(PDO::FETCH_ASSOC);
echo 'WRONG rows returned: ', count($rows), "\n";
$stmt = $db->prepare('SELECT id, total FROM orders WHERE customer = ?');
$stmt->execute([$input]);
echo 'FIXED rows returned: ', count($stmt->fetchAll()), "\n";
```

```text
WRONG rows returned: 3
FIXED rows returned: 0
```

![صندوقان: حقن SQL حيث المدخل a' OR '1'='1 مدمجاً في الاستعلام أعاد 3 صفوف أي كل الطلبات، ومع الاستعلام المعد أعاد صفراً، وتخزين الباسورد حيث md5 تعطي نفس الهاش مرتين وتجرب 100 ألف باسورد في 44 مللي ثانية، بينما password_hash بتكلفة 12 تأخذ 231 مللي ثانية للهاش الواحد وتعطي نتيجة مختلفة كل مرة](https://arabroot.io/content/images/2026/10/php-idioms-05-security.webp)

الأمان يأتي من الدوال الصحيحة، وليس من الصيغة

المدخل أغلق علامة التنصيص وأضاف شرطاً صحيحاً دائماً، فأعاد الاستعلام طلبات كل العملاء. أما الاستعلام المعد Prepared Statement فيرسل نص الاستعلام والقيم منفصلين إلى قاعدة البيانات، فتعامل القيمة كنص كامل يبحث عن عميل اسمه حرفياً `a' OR '1'='1` ولا يجده. والتفاصيل في [توثيق الاستعلامات المعدة في PDO](https://www.php.net/manual/en/pdo.prepared-statements.php?ref=arabroot.io)، وفي Laravel تفعل Eloquent والـ Query Builder ذلك تلقائياً، إلا عندما تستخدم `DB::raw` أو `whereRaw` وتدمج فيها قيمة من المستخدم، وهذا هو المكان الذي يجب أن تبحث عنه في المراجعة.

📌

في أكتوبر 2014 نشر فريق Drupal الإعلان الأمني [SA-CORE-2014-005](https://www.drupal.org/SA-CORE-2014-005?ref=arabroot.io) عن ثغرة حقن SQL في طبقة قاعدة البيانات في Drupal 7 نفسها، وهي الطبقة التي وجدت أصلاً لتعقيم الاستعلامات، وكان يستطيع استغلالها زائر غير مسجل بطلب مصمم خصيصاً، فينفذ SQL كما يريد، وقد يصل إلى تنفيذ كود PHP. وأضاف الفريق بعد الإعلان أن الاستغلال بدأ فعلاً، وأن مواقع Drupal 7 التي لم تحدث إلى 7.32 بسرعة قد تكون مخترقة. والدرس أن الاستعلام المعد هو القاعدة، وأن تحديث الإطار جزء من الأمان مثل كتابة الكود الصحيح.

### الخطأ 13: md5 أو sha1 لتخزين الباسورد

والخطأ الأخير يأتي غالباً من كود قديم أو من دروس قديمة: تخزين `md5($password)` في جدول المستخدمين. وفي التجربة قسنا لماذا هذا خطير:

```php
$a = md5('qahwa2026');
$b = md5('qahwa2026');
$t = hrtime(true);
for ($i = 0; $i < 100_000; $i++) {
    md5("guess{$i}");
}
$h1 = password_hash('qahwa2026', PASSWORD_DEFAULT);
$h2 = password_hash('qahwa2026', PASSWORD_DEFAULT);
```

```text
WRONG md5 same hash twice: true, 100,000 guesses in 44 ms
FIXED password_hash: $2y$12$... different each time: true, verify: true, one hash 231 ms
```

في المخرج أعلاه لاحظ التالي:

- md5 تعطي نفس الهاش لنفس الباسورد دائماً، فمن سرق الجدول يرى فوراً كل المستخدمين الذين يستخدمون نفس الباسورد، ويستطيع أن يستخدم جداول جاهزة لأشهر الباسوردات.
- مئة ألف محاولة أخذت 44 مللي ثانية في PHP على جهاز مشغول، أي أكثر من مليوني محاولة في الثانية، وأدوات الكسر المتخصصة على بطاقات الرسوميات أسرع بكثير.
- [password\_hash](https://www.php.net/manual/en/function.password-hash.php?ref=arabroot.io) مع `PASSWORD_DEFAULT` استخدمت bcrypt بتكلفة 12، وهذا ما يعنيه `$2y$12$` في بداية النتيجة، وأضافت ملحاً Salt عشوائياً، فخرج هاش مختلف لنفس الباسورد في كل مرة، وأخذ الهاش الواحد 231 مللي ثانية، أي أن نفس المئة ألف محاولة تحتاج أكثر من ست ساعات.
- `password_verify` تقرأ الخوارزمية والتكلفة والملح من النص نفسه، فلا تحتاج أن تحفظها في أعمدة منفصلة، و`password_needs_rehash` تخبرك متى ترفع التكلفة لمستخدم قديم عند دخوله التالي.

والقاعدة: لا تكتب أي كود لتخزين الباسورد بنفسك، واستخدم `password_hash` و`password_verify` فقط، أو ما يبنيه إطارك فوقهما، والأسئلة الشائعة في [صفحة الباسوردات في دليل PHP](https://www.php.net/manual/en/faq.passwords.php?ref=arabroot.io) تشرح لماذا لا تصلح دوال الهاش السريعة لهذا الغرض.

## ماذا يحدث إذا رفعت تكلفة bcrypt؟ What If

رأيت أن الهاش الواحد بتكلفة 12 أخذ 231 مللي ثانية، والسؤال الطبيعي: ما الرقم الصحيح للتكلفة؟ وماذا يحدث للمستخدمين القدامى عندما ترفعها؟ كتبنا الملف `whatif-cost.php` الذي يحسب ثلاثة هاشات بكل تكلفة من 10 و12 و14، ثم يتحقق من باسورد قديم حفظ بتكلفة 10:

```php
foreach ([10, 12, 14] as $cost) {
    $times = [];
    for ($i = 0; $i < 3; $i++) {
        $t = hrtime(true);
        $h = password_hash('qahwa2026', PASSWORD_BCRYPT, ['cost' => $cost]);
        $times[] = (hrtime(true) - $t) / 1e6;
    }
    printf("cost %d: %s ms  prefix %s\n", $cost, implode(' / ', array_map(fn ($x) => sprintf('%.0f', $x), $times)), substr($h, 0, 7));
}
$old = password_hash('qahwa2026', PASSWORD_BCRYPT, ['cost' => 10]);
var_dump(password_verify('qahwa2026', $old), password_needs_rehash($old, PASSWORD_DEFAULT));
```

```text
cost 10: 70 / 73 / 86 ms  prefix $2y$10$
cost 12: 363 / 405 / 344 ms  prefix $2y$12$
cost 14: 1036 / 1048 / 1244 ms  prefix $2y$14$
bool(true)
bool(true)
```

في المخرج أعلاه لاحظ التالي:

- التكلفة في bcrypt لوغاريتمية، فكل زيادة بواحد تضاعف عدد الدورات، ومن 10 إلى 12 يجب أن يتضاعف الزمن أربع مرات، وهذا قريب مما قسنا مع تذبذب الجهاز: من حوالي 70 إلى حوالي 370 مللي ثانية.
- زمن التكلفة 12 هنا بين 344 و405 مللي ثانية، بينما كان 231 في تشغيل الملف الرئيسي قبل دقائق، وهذا هو الجهاز المشغول نفسه الذي ذكرناه في المقالات السابقة، فلا تأخذ الرقم المطلق، وإنما النسبة بين التكاليف.
- الباسورد القديم بتكلفة 10 ما زال يتحقق بنجاح، لأن التكلفة محفوظة داخل النص `$2y$10$`، و`password_needs_rehash` أعادت `true` لأن التكلفة الافتراضية الحالية أعلى. والنمط الصحيح أن تستدعيها بعد كل دخول ناجح، فإذا أعادت `true` تحسب هاشاً جديداً بالباسورد الذي أدخله المستخدم للتو وتحفظه، فيترقى كل المستخدمين تدريجياً دون أن يشعروا.
- تكلفة 14 تعني أكثر من ثانية لكل دخول على هذا الجهاز، وهذا يجعل صفحة الدخول نفسها هدفاً سهلاً لإغراق السيرفر بطلبات دخول خاطئة. والقاعدة العملية أن تختار أعلى تكلفة تبقي الهاش الواحد في حدود بضع مئات من المللي ثواني على سيرفرك الحقيقي، وأن تضع حداً لعدد محاولات الدخول.

## أين تظهر هذه الأخطاء في مشروع حقيقي؟

الأمثلة في التجربة صغيرة حتى ترى السبب بوضوح، ولكنها في المشاريع الحقيقية تختبئ في أماكن محددة، ومعرفة هذه الأماكن تجعل المراجعة أسرع. فالمرجع بعد `foreach` يظهر عادة في دوال طويلة تعالج سلة أو تقريراً، حيث تكون الحلقتان متباعدتين بعشرات الأسطر، فلا يرى من يضيف الحلقة الثانية أن الأولى استخدمت `&`. و`array_merge` مع المفاتيح الرقمية يظهر عندما تجمع نتائج استعلامين مفهرسين بالمعرف، مثل منتجات من مستودعين أو صلاحيات من دورين، والنتيجة لا تفشل وإنما تفقد ارتباط القيمة بصاحبها. و`array_filter` قبل `json_encode` يظهر في كل API تحذف عناصر غير مسموحة من قائمة قبل إرسالها، فتعمل الشاشة في المتصفح لأن JavaScript يقبل الكائن في بعض الأماكن، ثم يتعطل تطبيق الجوال الذي يتوقع قائمة.

وأخطاء النصوص تظهر في كل مكان يقص النص أو يقيس طوله: ملخص المنتج في صفحة القائمة، ورسالة SMS بحد أقصى للحروف، وحقل في قاعدة البيانات بطول محدد، والتحقق من طول الاسم في النموذج. وفي نظام عربي كل هذه الأماكن تخطئ مع `strlen` و`substr`، وأحياناً تكتب حرفاً مكسوراً في قاعدة البيانات يظهر لاحقاً كعلامة استفهام. وأخطاء المقارنة تظهر في قوائم السماح Allow Lists، مثل أكواد الخصم والدول المسموحة وأدوار المستخدمين، وفي التحقق من التوكن، وهذه بالضبط الأماكن التي يبحث فيها المخترق عن مقارنة متساهلة. وأخطاء التواريخ تظهر في التقارير اليومية، والمهام المجدولة التي تعمل منتصف الليل، ومواعيد انتهاء العروض، حيث يغلق العرض قبل وقته بثلاث ساعات لأن السيرفر بتوقيت UTC. وأخطاء الأمان تظهر في الكود الذي كتب بسرعة خارج الإطار، مثل سكربت تصدير أو لوحة إدارة داخلية، لأنه المكان الذي لا يمر عليه Eloquent ولا المراجعة المعتادة.

## ما الذي كشفه PHPStan؟ وما الذي فاته؟

بعد أن جمعنا الأخطاء شغلنا PHPStan بالمستوى `max` على نفس الملف، لنعرف أيها كان سيكشفه لنا قبل التشغيل:

```text
/app/mistakes.php:52:Ternary operator condition is always false.
/app/mistakes.php:54:Call to function in_array() with arguments '1e1', array{'10', '20', '30'} and true will always evaluate to false.
/app/mistakes.php:73:Variable $discountCode in empty() always exists and is always falsy.
/app/mistakes.php:101:Parameter #1 $value of function count expects array|Countable, ArrayObject<int, string>|null given.
/app/mistakes.php:128:Match arm comparison between '1e1' and '10' is always false.
/app/mistakes.php:136:Cannot call method fetchAll() on PDOStatement|false.
/app/mistakes.php:148:Call to function md5() on a separate line has no effect.
```

وهذا مختصر من 15 رسالة في الملف `phpstan-output.txt`. والسطر 52 هو شرط الخطأ 4 الذي استخدم نتيجة `array_search` مباشرة، والسطر 73 هو `empty` في الخطأ 7، وفي الحالتين عرف PHPStan المشكلة لأن القيم في التجربة ثابتة، فحسب أن الشرط دائماً خاطئ. أما الرسائل الأخرى فهي عن النسخ الصحيحة أو عن تفاصيل في كود التجربة نفسه، مثل أن `query` قد تعيد `false`. والمهم ما لم يظهر: لا شيء عن المرجع بعد `foreach`، ولا عن `strlen` مع العربية، ولا عن `array_merge` والمفاتيح، ولا عن `sort` التي أسندنا نتيجتها، ولا عن `switch`، ولا عن `DateTime`، ولا عن حقن SQL، ولا عن md5\. والسبب أن هذا الكود صحيح من ناحية الأنواع، وكل دالة استدعيت بما تقبله، والخطأ في المعنى. وهناك حزم إضافية تغطي جزءاً من ذلك، مثل [phpstan-strict-rules](https://github.com/phpstan/phpstan-strict-rules?ref=arabroot.io) التي تمنع المقارنة المتساهلة واستخدام `empty`، ولكن أخطاء مثل `array_merge` والتواريخ والأمان لا يكشفها إلا اختبار يفحص النتيجة، ومراجع يعرف PHP.

## أخطاء لم تعد أخطاء: ما الذي أصلحته PHP 8 بنفسها؟

من المفيد أن تعرف أيضاً الأخطاء الشهيرة التي لم نضعها في القائمة، لأن PHP الحديثة صارت ترفضها بنفسها، فإذا قرأت عنها في مقال قديم فلا تقلق منها في PHP 8.5، ولكن احذر منها إذا كان مشروعك على إصدار أقدم. أشهرها المقارنة `0 == 'a'` التي كانت تعطي `true` قبل PHP 8.0 وسببت ثغرات في مقارنة التوكن، وصارت `false` كما رأينا في المقال الأول. ومنها النص الذي يبدأ برقم ثم فيه كلام، مثل «5 ريال»، والذي كان يقبل كرقم مع تنبيه في PHP 7، وصار يرمي `TypeError` عند تمريره لمعامل `int` حتى بدون الوضع الصارم، كما رأينا في تجربة «ماذا يحدث إذا» في المقال الأول. ومنها استدعاء دالة على `null`، والذي كان خطأ قاتلاً لا يلتقط، وصار `Error` يمكن التقاطه وتسجيله منذ PHP 7، وقراءة متغير غير معرف التي ارتفعت من تنبيه Notice إلى تحذير Warning في PHP 8.0، والقسمة على صفر التي صارت ترمي `DivisionByZeroError` كما رأينا في المقال الثاني. وكل هذه التغييرات موثقة في [دليل الترقية إلى 8.0](https://www.php.net/manual/en/migration80.php?ref=arabroot.io)، وهو أهم دليل ترقية في تاريخ اللغة الحديث، لأنه الإصدار الذي حول كثيراً من السلوكيات الصامتة إلى أخطاء واضحة. والنتيجة أن قائمة الأخطاء التي تحتاج أن تحفظها في PHP 8.5 أقصر بكثير مما كانت في PHP 5، والأخطاء الثلاثة عشر في هذا المقال هي تقريباً ما بقي منها.

## لماذا بقيت هذه السلوكيات؟ التوافق مع الكود القديم Backward Compatibility

قد يتساءل البعض بعد هذه القائمة: إذا كان فريق PHP يعرف أن `in_array` بدون `strict` خطر، وأن `switch` يقارن بتساهل، فلماذا لا يغيرونها؟ والإجابة في الرقم الذي رأيناه في المقال الأول: PHP تعمل خلف حوالي سبعين بالمئة من المواقع التي تعرف لغتها، وأغلب هذه المواقع لا يحدث كودها أحد، وإنما يحدث فقط إصدار PHP الذي تعمل عليه من مزود الاستضافة. فلو تغير معنى `in_array` في إصدار جديد، لتغير سلوك ملايين المواقع بصمت في يوم الترقية، دون أي رسالة خطأ، وهذا أسوأ من الخطأ نفسه. لذلك تتبع PHP طريقاً آخر في تطويرها: تضيف البديل الصحيح بجانب القديم، مثل `match` بجانب `switch`، و`str_contains` بجانب `strpos`، و`DateTimeImmutable` بجانب `DateTime`، ثم تطبع تحذير `E_DEPRECATED` للسلوك القديم لعدة إصدارات، ثم تزيله في إصدار رئيسي مع توثيقه في دليل الترقية.

وهذا الطريق نجح في حالات كثيرة رأيناها في هذه المرحلة: مقارنة الرقم بالنص تغيرت في PHP 8.0 بعد نقاش طويل، والتحويل الضمني من `float` إلى `int` مع فقدان الكسر صار يعطي `Deprecated`، و`$http_response_header` صار مهملاً في 8.5\. ولكنه يعني أن عبء الاختيار عليك: اللغة تعطيك الأداتين، القديمة والحديثة، وأنت الذي تختار. ولهذا يقال إن PHP الحديثة لغة جيدة مختبئة داخل لغة قديمة، والفرق بينهما ليس في الإصدار الذي تثبته، وإنما في الدوال والإعدادات التي تختارها، وهذا بالضبط ما يحدده أسلوب PHP الحديث.

## ما هو أسلوب PHP الحديث Idiomatic PHP؟

إذا جمعت ما رأيته في المقالات الخمسة، فأسلوب PHP الحديث له صفات واضحة تستطيع أن تراها في أي ملف خلال ثوان، وهي ما يبحث عنه المراجع الخبير عندما يقرأ كودك:

- الوضع الصارم Strict Types: كل ملف يبدأ بـ `declare(strict_types=1)`، كما شرحنا في المقال الأول، فلا يتحول النص إلى رقم بصمت عند استدعاء دالة.
- الأنواع في كل مكان Types: على كل معامل وكل قيمة إرجاع وكل خاصية، و`mixed` فقط عندما تقصدها، وأشكال المصفوفات في PHPDoc عندما لا تستطيع استبدالها بكلاس.
- كائنات القيمة Value Objects: كلاسات `final readonly` صغيرة بخصائص في الـ Constructor، مثل `Product` في المقال الأول، بدل مصفوفات تمر بين الدوال بمفاتيح نصية لا يفحصها أحد.
- التعداد Enum بدل الثوابت النصية، و`match` بدل `switch`، و`?->` و`??` بدل سلاسل `isset` المتداخلة.
- المعاملات المسماة Named Arguments عندما تكون القيم من نفس النوع، مثل `new Product(name: 'تمر', priceHalalas: 4500)`، حتى لا تمرر رقمين بترتيب معكوس، كما في [توثيق المعاملات](https://www.php.net/manual/en/functions.arguments.php?ref=arabroot.io).
- المقارنة الصارمة في كل مكان: `===` و`!==`، و`true` في `in_array` و`array_search`، وبلا `empty` مع المدخلات.
- الاستثناءات بدل قيم الفشل: `JSON_THROW_ON_ERROR`، ومعالج يحول التحذيرات إلى `ErrorException`، واستثناءات خاصة بالمنطق تحمل بياناتها، كما في المقال الثاني.
- Composer للتحميل التلقائي PSR-4، وPint أو PHP-CS-Fixer للتنسيق، وPHPStan بمستوى عال، وPHPUnit، وكلها في CI.

ولاحظ أن كل نقطة في هذه القائمة تمنع واحداً أو أكثر من الأخطاء الثلاثة عشر: الأنواع والكائنات تمنع خلط المفاتيح، و`match` يمنع الخطأ 11، والمقارنة الصارمة تمنع الأخطاء 4 و7، والكائنات الثابتة تمنع الخطأين 9 و10\. أي أن الأسلوب الحديث ليس ذوقاً في الكتابة، وإنما طريقة لتجنب سلوكيات اللغة القديمة دون أن تحفظها كلها.

## كيف تمنع هذه الأخطاء في فريق؟ الأدوات Tooling والمراجعة

رأينا أن PHPStan بإعداده الافتراضي أشار إلى خطأين فقط، والسؤال العملي: كيف تمنع البقية في فريق فيه مطورون قادمون من لغات مختلفة؟ والإجابة طبقات، وكل طبقة تمسك جزءاً:

- قواعد التنسيق: PHP-CS-Fixer فيه قاعدتان من نوع المخاطرة Risky تحولان الكود تلقائياً، الأولى [strict\_comparison](https://cs.symfony.com/doc/rules/strict/strict%5Fcomparison.html?ref=arabroot.io) التي تحول `==` إلى `===`، والثانية [strict\_param](https://cs.symfony.com/doc/rules/strict/strict%5Fparam.html?ref=arabroot.io) التي تضيف `true` إلى `in_array` وأخواتها. وسميت Risky لأنها قد تغير سلوك كود يعتمد على المقارنة المتساهلة، فشغلها مرة واحدة على المشروع مع الاختبارات، ثم اجعلها جزءاً من CI.
- قواعد التحليل: `phpstan-strict-rules` تمنع `empty` والمقارنة المتساهلة والشروط على غير `bool`، أي أنها كانت ستشير إلى الأخطاء 3 و4 و7 حتى مع قيم غير ثابتة.
- إعادة الكتابة الآلية: [Rector](https://getrector.com/documentation?ref=arabroot.io) يحول الصيغ القديمة إلى الحديثة على مستوى المشروع كله، مثل `strpos(...) !== false` إلى `str_contains`، و`switch` البسيط إلى `match`.
- الاختبارات: الأخطاء التي في المعنى، مثل `array_merge` والمراجع والتواريخ والمناطق الزمنية، لا يمسكها إلا اختبار يتحقق من النتيجة، مثل اختبارات المقال الرابع التي تتحقق من المخزون بعد كل طلب.
- المراجعة: قائمة التحقق في هذا المقال مصممة لتكون قائمة مراجعة، ومع الوقت تتحول إلى عادة لا تحتاج قائمة.

## قد يتساءل البعض: هل PHP لغة سيئة إذن؟

بعد ثلاثة عشر خطأ في مقال واحد قد تخرج بانطباع أن PHP لغة مليئة بالفخاخ، وهذا الانطباع منتشر بين المطورين لأسباب تاريخية. ولكن انظر إلى ما رأيته في المرحلة كلها: لغة بأنواع تفحص وقت التشغيل، وتعداد وكائنات ثابتة وخطافات خصائص، ومحلل ثابت يفهم الأنواع العامة، ومدير حزم من أفضل مديري الحزم في كل اللغات، ونموذج تشغيل يجعل تسرب الذاكرة وحالات السباق داخل الكود نادرة في تطبيقات الويب، وإطار مثل Laravel بنى فيه خدمة كاملة مع معاملة وأقفال واختبارات بخمسة ملفات. ولكل لغة فخاخها التي يحفظها أهلها: في JavaScript المقارنة `==` و`this`، وفي Python المعاملات الافتراضية القابلة للتعديل والنسخ السطحي، وفي Java المقارنة `==` بين النصوص، وفي Go الحلقة التي تلتقط متغيراً مشتركاً في الإصدارات القديمة. والفرق في PHP أن فخاخها قديمة ومشهورة ومكتوبة في الدليل، وأن اللغة أضافت لكل واحد منها بديلاً صحيحاً.

والسؤال الأهم ليس «هل PHP جيدة؟»، وإنما «هل تكتب PHP الحديثة؟». فالمطور الذي يكتب بالوضع الصارم وبالأنواع وبالمقارنة الصارمة وبالأدوات في CI لن يقابل أغلب هذه الأخطاء، والمطور الذي ينسخ كوداً من إجابة كتبت قبل عشر سنوات سوف يقابلها كلها. ولهذا كانت خطة القراءة في هذا المقال من المصادر الرسمية فقط: لأن الدليل الرسمي وأدلة الترقية تعكس اللغة كما هي اليوم، بينما أغلب ما تجده في البحث يعكسها كما كانت.

## قائمة تحقق Checklist لمراجعة كود PHP

- هل بعد كل `foreach (... as &$x)` سطر `unset($x)`، أو هل يمكن استبدالها بـ `array_map`؟
- هل كل عملية على طول نص عربي أو جزء منه بدوال `mb_`؟
- هل تستخدم `str_contains` و`str_starts_with` بدل `strpos` في الشروط؟
- هل في كل `in_array` و`array_search` المعامل الثالث `true`، وهل نتيجة `array_search` تقارن بـ `!== false`؟
- هل المصفوفات التي مفاتيحها معرفات تدمج بـ `+` أو `array_replace` وليس `array_merge`؟
- هل تمر كل قائمة على `array_values` قبل `json_encode` إذا مرت على `unset` أو `array_filter`؟
- هل اختفت `empty` من التعامل مع المدخلات؟
- هل التواريخ `DateTimeImmutable` بمنطقة زمنية صريحة؟
- هل الكائنات التي تملك كائنات أخرى فيها `__clone`، أو هل هي `readonly`؟
- هل `match` بدل `switch` في الكود الجديد؟
- هل كل استعلام SQL معد، وهل كل `whereRaw` و`DB::raw` بلا قيم من المستخدم؟
- هل الباسوردات بـ `password_hash` فقط؟

## خطة القراءة Reading Plan من المصادر الرسمية

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

![أربعة أسابيع: الأول اللغة نفسها من دليل PHP والمرجع اللغوي والأنواع والمصفوفات، والثاني ما تغير في الإصدارات من أدلة الترقية ومقترحات RFC وجدول الدعم، والثالث الأدوات والمعايير من توثيق Composer ومعايير PHP-FIG وPHPStan وPHPUnit، والرابع الإطار والأمان من توثيق Laravel أو Symfony وفصل الأمان في دليل PHP](https://arabroot.io/content/images/2026/10/php-idioms-06-reading.webp)

خطة قراءة من المصادر الرسمية فقط، أسبوع لكل طبقة

1. الأسبوع الأول، اللغة: ابدأ بـ [البداية في دليل PHP](https://www.php.net/manual/en/getting-started.php?ref=arabroot.io)، ثم اقرأ [المرجع اللغوي Language Reference](https://www.php.net/manual/en/langref.php?ref=arabroot.io) كاملاً، وركز على الأنواع والمقارنة وتحويل الأنواع والمصفوفات والمراجع، فهي مصدر أغلب أخطاء هذا المقال. والتعليقات أسفل صفحات الدليل قديمة أحياناً، فاعتمد على نص الصفحة نفسه.
2. الأسبوع الثاني، ما تغير: اقرأ أدلة الترقية من [الترقية إلى 8.0](https://www.php.net/manual/en/migration80.php?ref=arabroot.io) حتى [الترقية إلى 8.5](https://www.php.net/manual/en/migration85.php?ref=arabroot.io)، فهي أقصر طريق لتعرف الفرق بين PHP التي في الدروس القديمة وPHP الحالية. وعندما تستخدم ميزة جديدة اقرأ مقترحها في [قائمة RFC](https://wiki.php.net/rfc?ref=arabroot.io)، لأن المقترح يشرح لماذا صممت الميزة بهذا الشكل وما الذي رفض. وراجع [جدول الإصدارات المدعومة](https://www.php.net/supported-versions.php?ref=arabroot.io) كل سنة.
3. الأسبوع الثالث، الأدوات: [توثيق Composer](https://getcomposer.org/doc/?ref=arabroot.io)، ثم [معايير PHP-FIG](https://www.php-fig.org/psr/?ref=arabroot.io) وأهمها PSR-4 للتحميل التلقائي وPER لأسلوب الكود وPSR-7 وPSR-15 لطلبات HTTP، ثم [دليل PHPStan](https://phpstan.org/user-guide/getting-started?ref=arabroot.io) و[توثيق PHPUnit](https://docs.phpunit.de/en/13.4/?ref=arabroot.io).
4. الأسبوع الرابع، الإطار والأمان: اقرأ [توثيق Laravel](https://laravel.com/docs/13.x?ref=arabroot.io) أو [توثيق Symfony](https://symfony.com/doc/current/index.html?ref=arabroot.io) مرة كاملة من أوله إلى آخره، وليس فقط الصفحة التي تحتاجها اليوم، لأن الإطار يحل كثيراً من مشاكلك بطرق لن تعرفها إلا إذا قرأت عنها. ثم اقرأ [فصل الأمان في دليل PHP](https://www.php.net/manual/en/security.php?ref=arabroot.io)، ومعه [صفحة OWASP عن حقن SQL](https://owasp.org/www-community/attacks/SQL%5FInjection?ref=arabroot.io).

وإذا أردت أن تتابع اللغة نفسها، فـ [مؤسسة PHP](https://thephp.foundation/?ref=arabroot.io) تمول جزءاً كبيراً من تطوير PHP اليوم وتنشر تقارير عنه، والقرارات نفسها تتخذ بالتصويت على مقترحات RFC التي ذكرناها.

### كيف تقرأ صفحة دالة في دليل PHP؟

أغلب الأخطاء الثلاثة عشر مكتوبة في صفحة الدالة نفسها في الدليل، ولكن في قسم لا يقرؤه من يبحث عن مثال سريع. فكل صفحة دالة في دليل PHP لها نفس الأقسام، وكل قسم يجيب على سؤال محدد. القسم الأول هو التوقيع Description، مثل `sort(array &$array, int $flags = SORT_REGULAR): true`، وفيه وحده ثلاث معلومات من الخطأ 8: العلامة `&` تعني أن الدالة تعدل المصفوفة نفسها، ونوع الإرجاع `true` يعني أنها لا تعيد شيئاً مفيداً، والمعامل الثاني يحدد طريقة المقارنة. ثم قسم القيم المعادة Return Values، وهو الذي يقول إن `strpos` قد تعيد `0` أو `false`، ومعه غالباً صندوق تحذير Warning يقول صراحة استخدم `===`، وهذا الصندوق هو الخطأ 3 مكتوباً بلغة الدليل. ثم قسم سجل التغييرات Changelog، وفيه في أي إصدار تغير سلوك الدالة، مثل تغير نوع الإرجاع أو رمي `ValueError` بدل التحذير، وهذا القسم هو ما تحتاجه عندما يعمل كود على سيرفر ويختلف على آخر. ثم الأمثلة Examples والملاحظات Notes. والعادة التي تستحق أن تبنيها: قبل أن تستخدم أي دالة لأول مرة اقرأ التوقيع وقسم القيم المعادة وصناديق التحذير، فهي لا تأخذ أكثر من دقيقة، وتوفر عليك أغلب ما في هذا المقال.

## الخلاصة

وصلنا لنهاية الموضوع ولنهاية المرحلة، وأهم ما في هذا المقال:

- ثلاثة عشر خطأ تعمل كلها في PHP 8.5 دون أي رسالة، وتعطي نتيجة غير التي يتوقعها القادم من لغة أخرى.
- المصفوفات: المرجع يبقى بعد `foreach`، و`array_merge` تعيد ترقيم المفاتيح، و`unset` يحول JSON إلى كائن، و`sort` تعيد `true`.
- النصوص: `strlen` أعطت 23 لاسم من 12 حرفاً، و`strpos` أعطت صفراً جعل المنتج السعودي غير سعودي.
- المقارنة: `in_array` وجدت «1e1» في قائمة ليس فيها، و`empty` تجاهلت الكود «0»، و`switch` طابق «1e1» مع «10».
- الكائنات: `DateTime` عدل تاريخ الإنشاء، والمنطقة الافتراضية UTC، و`clone` شارك قائمة الأسطر.
- الأمان: الدمج أعاد طلبات كل العملاء، وmd5 جربت مئة ألف باسورد في 44 مللي ثانية مقابل 231 مللي ثانية لهاش واحد بـ bcrypt.
- PHPStan أشار إلى خطأين فقط من الثلاثة عشر، والباقي تكشفه الاختبارات والمراجعة.

وبهذا نكون قد أكملنا مرحلة PHP: الأدوات والصيغة، والأنواع والذاكرة والأخطاء، والتزامن، وخدمة حقيقية، والأخطاء الشائعة. والمرحلة نفسها مكتوبة بنفس الشكل لكل لغات المسار، فإذا أردت أن تقارن PHP بلغة أخرى فاقرأ نفس المقال في مرحلتها.

[![](https://arabroot.io/content/images/2026/10/php-for-devs-cover-cover.webp)دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.](https://arabroot.io/articles/php-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%B5%D9%8A%D8%BA%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/php-types-memory-cover-cover.webp)دليلالأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسةنحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%81%D9%8A-php-8/)[![](https://arabroot.io/content/images/2026/10/php-concurrency-cover-cover.webp)دليلالتزامن Concurrency في PHP: عمليات FPM والألياف Fibersقسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بعمليات PHP-FPM وFibers وAMPHP والتفرع والخيوط، ثم صنعنا حالة سباق على المخزون بين عمليات FPM وأصلحناها بالقفل.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-php-fpm-%D9%88-fibers-%D9%88-parallel/)[![](https://arabroot.io/content/images/2026/10/php-real-service-cover-cover.webp)دليلخدمة حقيقية بـ PHP: Laravel مع PostgreSQL والاختباراتبنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A9-%D8%AD%D9%82%D9%8A%D9%82%D9%8A%D8%A9-%D8%A8-php-laravel-%D9%88-postgresql/)[![](https://arabroot.io/content/images/2026/10/python-idioms-cover-cover.webp)دليلأخطاء شائعة وأسلوب Python الصحيح Pythonicخمسة عشر خطأً يقع فيها من يأتي إلى Python من Java وJavaScript وC#، كل واحد بتشغيل خاطئ حقيقي وإصلاحه وما يمسكه ruff منه، ثم خطة قراءة لأربعة أسابيع من التوثيق الرسمي.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D8%B4%D8%A7%D8%A6%D8%B9%D8%A9-%D9%88%D8%A7%D8%B3%D9%84%D9%88%D8%A8-python-%D8%A7%D9%84%D8%B5%D8%AD%D9%8A%D8%AD-pythonic/)[![](https://arabroot.io/content/images/2026/10/debugging-basics-cover-cover.webp)دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتشغيل الأخطاء الثلاثة عشر بـ PHP 8.5.12 وتحليلها بـ PHPStan 2.3.4 داخل Docker.