> ## 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 لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يوم
- URL: https://arabroot.io/articles/php-لمن-يعرف-البرمجة-الصيغة-والادوات/
- Published: 2026-10-31T07:00:20.000Z
- Updated: 2026-10-31T08:02:54.000Z
- Description: دليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

لنفرض أنك مطور تكتب Python أو JavaScript منذ سنوات، وانتقلت إلى شركة ناشئة تبيع القهوة والتمور عبر الإنترنت، وفي أول يوم تفتح المستودع فتجد أن الواجهة الخلفية Backend للمتجر مكتوبة بـ PHP، وأن أول مهمة لك هي إضافة حساب ضريبة القيمة المضافة VAT إلى سعر كل منتج وكتابة اختبار له. وعندها تمر في ذهنك صورة PHP التي سمعت عنها قبل عشر سنوات: ملفات فيها HTML وSQL معاً، ودوال بأسماء غير متسقة، و`$` قبل كل متغير. ولكن الكود الذي أمامك فيه `declare(strict_types=1)` في أول كل ملف، وكلاسات من نوع `readonly`، و`enum`، وملف `composer.json`، وأداة تحليل تفحص الأنواع قبل الدمج. وهذا هو الفرق الذي سوف تلاحظه سريعاً: PHP اليوم لغة مختلفة كثيراً عن PHP التي يتندر عليها الناس، ولكن فيها أيضاً سلوكيات قديمة بقيت لأسباب التوافق، وهي بالضبط ما يوقع المطور القادم من لغة أخرى.

وهذا المقال هو الأول في مرحلة [لغة PHP للمطورين](https://arabroot.io/start/#stage-lang-php) من مسار لغات البرمجة عملياً، وهو موجه لمن يعرف البرمجة ويريد أن يبدأ العمل بـ PHP بشكل صحيح من اليوم الأول، أو يعود إليه كمرجع. ولن نشرح فيه ما هو المتغير أو الحلقة، فهذا موجود في مقال [أساسيات البرمجة](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D9%85%D8%AA%D8%BA%D9%8A%D8%B1%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%B4%D8%B1%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AD%D9%84%D9%82%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%AF%D9%88%D8%A7%D9%84/)، وإنما سوف نقارن كل شيء بما تعرفه من لغتك، ونشغل كل أمر فعلاً بـ PHP 8.5.12 داخل Docker، ونقرأ المخرج سطراً سطراً.

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

- لماذا ما زالت PHP تعمل خلف أغلب مواقع الويب، وماذا تغير فيها منذ PHP 7 و8.
- تثبيت PHP 8.5.12 بالطرق الرسمية، وأي إصدار تختار بحسب جدول الدعم.
- جولة سريعة في الصيغة Syntax مقارنة بـ Python وJavaScript: المتغيرات والنصوص والمصفوفة الواحدة والمقارنة و`match` و`enum` والإضافات الجديدة في 8.4 و8.5.
- نموذج التشغيل Share-Nothing، ولماذا ينسى PHP كل شيء بعد كل طلب.
- الأدوات Toolchain: مدير الحزم Composer والتحميل التلقائي Autoload، والتنسيق بـ Pint، والتحليل الثابت بـ PHPStan، والاختبارات بـ PHPUnit.
- هيكل المشروع Project Layout، ومشكلتان حقيقيتان ظهرتا في التجربة، وتجربة «ماذا يحدث إذا».
- قائمة تحقق لأول يوم، والخلاصة، والمقالات التالية في المرحلة.

![خمسة صناديق من اليمين: المفسر php 8.5.12 ويقابل python وnode، ثم مدير الحزم composer 2.11 ويقابل npm وpip، ثم التنسيق pint ويقابل prettier وblack، ثم التحليل الثابت phpstan ويقابل mypy وtsc، ثم الاختبارات phpunit 13 ويقابل pytest وJUnit، وتحتها خط العمل composer install ثم pint --test ثم phpstan analyse ثم phpunit](https://arabroot.io/content/images/2026/10/php-for-devs-01-toolchain.webp)

أدوات PHP الخمس التي تحتاجها في اليوم الأول، وما يقابلها في اللغات التي تعرفها

## لماذا ما زالت PHP هنا؟ التاريخ History والانتشار

بدأت PHP في 1994 عندما كتب Rasmus Lerdorf مجموعة برامج صغيرة بلغة C تعمل عبر CGI ليتتبع زيارات سيرته الذاتية على الإنترنت، وسماها «Personal Home Page Tools»، ثم نشر الكود في 1995 وأضاف إليها معالجة النماذج Forms والاتصال بقواعد البيانات، كما يحكي [تاريخ PHP في التوثيق الرسمي](https://www.php.net/manual/en/history.php.php?ref=arabroot.io). وهذه البداية تفسر كثيراً من طباع اللغة حتى اليوم: فهي ولدت لتكتب صفحة ويب تستقبل طلباً وترسل رداً ثم تنتهي، وليس لتكتب برنامجاً يعمل أياماً في الذاكرة، ولذلك كان تحويل الأنواع تلقائياً، والمتغيرات بلا تعريف، والمصفوفة الواحدة تقوم بكل شيء. وهذه الخيارات جعلت كتابة أول صفحة ديناميكية سهلة جداً على ملايين الناس، وفي نفس الوقت جعلت الكود الكبير صعب الصيانة.

والتحول الكبير جاء على مرحلتين. الأولى في ديسمبر 2015 مع PHP 7.0، حيث أعيدت كتابة المحرك Zend Engine، ويذكر [إعلان الإصدار](https://www.php.net/releases/7%5F0%5F0.php?ref=arabroot.io) أنه أسرع حتى الضعف من PHP 5.6 مع استهلاك أقل للذاكرة، وأن كثيراً من الأخطاء القاتلة Fatal Errors صارت استثناءات Exceptions يمكن التقاطها، وظهرت معه أنواع المعاملات البسيطة `int` و`string` ونوع الإرجاع. والثانية مع PHP 8.0 في نوفمبر 2020، والتي أضافت كما في [صفحة الإصدار](https://www.php.net/releases/8.0/en.php?ref=arabroot.io) المعاملات المسماة Named Arguments، والأنواع المتحدة Union Types، والتعبير `match`، والعامل `?->`، ومترجم JIT، ثم تتابعت الإصدارات كل نوفمبر: `enum` و`readonly` في 8.1، والكلاسات `readonly` في 8.2، وخطافات الخصائص Property Hooks في 8.4، والعامل `|>` و`clone` مع التعديل في 8.5.

أما الانتشار فما زال كبيراً، فبحسب [إحصاءات W3Techs](https://w3techs.com/technologies/details/pl-php?ref=arabroot.io) تعمل PHP خلف 69.2% من المواقع التي تعرف لغة الخادم فيها، وأغلب ذلك بسبب WordPress وأنظمة إدارة المحتوى والمتاجر الجاهزة، والإصدار 8 يمثل 65.9% من هذه المواقع. وهذا الرقم لا يعني أن كل شركة جديدة تختار PHP، فقد ناقشنا في مقال [كيف تختار لغة البرمجة لمشروعك](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/) أن الاختيار يبدأ من الفريق والنظام البيئي، وإنما يعني أن احتمال أن تقابل كوداً بـ PHP في عملك كبير، سواءً كان متجراً على Laravel أو موقعاً على WordPress أو لوحة تحكم قديمة تحتاج تحديثاً.

## تثبيت PHP 8.5 بالطرق الرسمية Installation

قبل أن تثبت أي شيء عليك أن تعرف أي إصدار تحتاج، وصفحة [الإصدارات المدعومة](https://www.php.net/supported-versions.php?ref=arabroot.io) تجيب على ذلك: كل فرع Branch يحصل على سنتين من الدعم النشط Active Support تصلح فيهما الأخطاء العادية والأمنية، ثم سنتين من الإصلاحات الأمنية فقط، ثم يتوقف تماماً. وفي نهاية أكتوبر 2026 الفروع المدعومة هي 8.2 و8.3 و8.4 و8.5، والإصدار 8.2 تنتهي إصلاحاته الأمنية بعد شهرين في 31 ديسمبر 2026، لذلك إذا كان مشروعك عليه فهذا وقت الترقية وليس بعد سنة.

![مخطط زمني من 2022 إلى 2029 فيه أربعة أشرطة للإصدارات 8.2 و8.3 و8.4 و8.5، كل شريط جزء أخضر للدعم النشط ثم جزء أصفر للإصلاحات الأمنية فقط، وخط أحمر عند أكتوبر 2026 يقطع 8.2 قرب نهايته و8.5 في دعمه النشط](https://arabroot.io/content/images/2026/10/php-for-devs-06-versions.webp)

كل فرع من PHP يعيش أربع سنوات تقريباً: سنتين دعماً كاملاً وسنتين للأمان فقط

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

- صورة Docker الرسمية: [php على Docker Hub](https://hub.docker.com/%5F/php?ref=arabroot.io) فيها Tags لكل إصدار ونوع، مثل `8.5.12-cli-trixie` لسطر الأوامر و`8.5.12-fpm-trixie` للويب و`8.5.12-zts` للنسخة الآمنة مع الخيوط Thread Safe. وهي الطريقة التي سوف نستخدمها هنا لأنها تعطيك نفس الإصدار على Windows وmacOS وLinux.
- حزم التوزيعة: Debian وUbuntu فيهما PHP، ولكن الإصدار مرتبط بإصدار التوزيعة، لذلك يستخدم كثير من الناس مستودع [deb.sury.org](https://deb.sury.org/?ref=arabroot.io) الذي يديره أحد مطوري حزم PHP في Debian ليحصلوا على أحدث إصدار.
- Windows: الملفات المبنية رسمياً في [windows.php.net](https://windows.php.net/download/?ref=arabroot.io)، وفيها نسخة NTS للاستخدام مع سطر الأوامر وIIS.
- macOS: عبر Homebrew، وهو ما يذكره [دليل التثبيت في التوثيق الرسمي](https://www.php.net/manual/en/install.php?ref=arabroot.io) ضمن الطرق الأخرى.

ونحتاج مع PHP ثلاث إضافات Extensions غير موجودة في الصورة الأساسية: `pdo_pgsql` للاتصال بـ PostgreSQL في المقال الرابع، و`pcntl` للتجارب على العمليات في مقال التزامن، و`bcmath` للحساب العشري الدقيق، ونضيف Composer من صورته الرسمية. والملف التالي هو كل ما نحتاجه:

```dockerfile
FROM php:8.5.12-cli-trixie
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libpq-dev && rm -rf /var/lib/apt/lists/* \
 && docker-php-ext-install pdo_pgsql pcntl bcmath
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
```

في الإعداد أعلاه لاحظ التالي:

- الأمر `docker-php-ext-install` سكربت موجود في الصورة الرسمية يبني الإضافة من كود PHP المصدري ويفعلها، و`libpq-dev` هي مكتبة PostgreSQL التي تحتاجها `pdo_pgsql` وقت البناء.
- Composer ملف واحد بصيغة PHAR، لذلك ننسخه من صورته الرسمية بدل تنزيله بسكربت من الإنترنت، و`git` و`unzip` يحتاجهما لتنزيل الحزم وفكها.

بعد بناء الـ Image بالأمر `docker build -t slp-php-cli:8.5 .` نتحقق من الإصدارات:

```bash
docker run --rm slp-php-cli:8.5 sh -c 'php -v; composer --version; php -m'
```

والمخرج سوف يكون كما يلي (مع اختصار قائمة الإضافات):

```text
PHP 8.5.12 (cli) (built: Oct 28 2026 01:20:43) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.5.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies
Composer version 2.11.1 2026-10-30 14:52:10
PHP version 8.5.12 (/usr/local/bin/php)
[PHP Modules] bcmath Core ctype curl date dom ... mbstring ... pcntl pcre PDO pdo_pgsql pdo_sqlite ... uri ... Zend OPcache zlib
```

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

- `(cli)` هي واجهة التشغيل SAPI، وPHP يعمل بأكثر من واجهة: `cli` لسطر الأوامر، و`fpm-fcgi` عندما يعمل خلف Nginx عبر PHP-FPM، و`apache2handler` كوحدة داخل Apache. والكود نفسه يعمل في كلها، ولكن الإعدادات والسلوك تختلف قليلاً، ومثال ذلك أن `max_execution_time` لا يطبق على سطر الأوامر.
- `(NTS)` تعني Non Thread Safe، أي أن هذه النسخة لا تشغل أكثر من خيط Thread داخل نفس العملية، وهذا هو الوضع الطبيعي لـ PHP كما سوف نرى في مقال التزامن، والنسخة ZTS تحتاجها فقط لإضافات مثل `parallel`.
- `Zend OPcache` مفعل افتراضياً، وهو الذي يحفظ الكود بعد تحويله إلى Opcodes في الذاكرة المشتركة، فلا يعاد تحليل الملف في كل طلب. وبدونه يقرأ PHP كل ملف ويحلله من جديد مع كل طلب.
- الإضافة `uri` جديدة في PHP 8.5، وهي تحلل الروابط حسب المعايير RFC 3986 وWHATWG بدل الدالة القديمة `parse_url`.

وإذا شغلت `php --ini` سوف تجد السطر `Loaded Configuration File: (none)`، والسبب أن الصورة الرسمية لا تضع ملف `php.ini` افتراضياً، وإنما تضع نموذجين `php.ini-development` و`php.ini-production` في `/usr/local/etc/php`، وعليك أن تنسخ أحدهما بنفسك. وهذه نقطة يقع فيها الكثير: يعمل الكود بالإعدادات الافتراضية المدمجة في PHP، والتي تعرض الأخطاء أحياناً بشكل يختلف عما في الإنتاج، والتفاصيل في [قائمة إعدادات php.ini](https://www.php.net/manual/en/ini.list.php?ref=arabroot.io).

## جولة في الصيغة Syntax مقارنة بلغتك

الآن نصل إلى الجزء الذي يهمك كمطور قادم من لغة أخرى، وهو الفروق التي تجعل كوداً صحيحاً في عقلك يعطي نتيجة غير متوقعة في PHP. وكتبنا لذلك ملفاً واحداً هو `bin/tour.php` فيه عشرة أقسام، كل قسم يختبر فكرة ويطبع نتيجتها، والجدول التالي يلخص أهم الفروق قبل أن نشغله:

![جدول من أحد عشر صفاً يقارن الصيغة في PHP 8.5 وPython وJavaScript: المتغير والنص المدمج، ودمج النصوص بالنقطة في PHP، ونوع واحد للقائمة والقاموس، والمساواة الصارمة بثلاث علامات، والوصول الآمن، والدالة القصيرة fn، واستدعاء الخاصية بالسهم، والتمرير بالاسم، والعامل |> الموجود في PHP فقط، والتعداد enum، ونوع المعامل الذي يفحص وقت التشغيل في PHP وهو تلميح فقط في Python](https://arabroot.io/content/images/2026/10/php-for-devs-02-syntax.webp)

نفس الفكرة بثلاث لغات، والعمود البرتقالي هو ما سوف تكتبه في PHP

ونشغل الملف كاملاً بالأمر التالي، والمجلد الحالي مربوط داخل الـ Container على `/app`:

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

### المتغيرات والنصوص Strings: النقطة وليس علامة الجمع

كل متغير يبدأ بـ `$`، ولا يحتاج تعريفاً مسبقاً، والنص بين علامتي تنصيص مزدوجتين يقبل إدراج المتغيرات Interpolation، أما بين علامتين مفردتين فهو نص حرفي لا يتغير فيه شيء:

```php
$name = 'قهوة';
$qty = 3;
echo "1) {$name} x {$qty}\n";
echo '1) "10" + "5" = ', "10" + "5", ' | "10" . "5" = ', "10" . "5", "\n";
```

والمخرج سوف يكون كما يلي:

```text
1) قهوة x 3
1) "10" + "5" = 15 | "10" . "5" = 105
```

والسطر الثاني هو أول فخ: في JavaScript يعطيك `"10" + "5"` النص `"105"`، وفي Python يعطيك نفس النتيجة، أما في PHP فعلامة الجمع `+` عملية حسابية فقط، فيحول النصين إلى رقمين ويعطيك 15، ودمج النصوص له عامل مستقل هو النقطة `.`. وهذا التصميم في الحقيقة يحميك من خطأ شائع في JavaScript، لأن العملية واضحة من العامل وليس من نوع القيم، ولكنه يربك يدك في الأسبوع الأول. ولاحظ أيضاً أن النص العربي يعمل بدون أي إعداد، لأن PHP يتعامل مع النص كسلسلة من البايتات Bytes، وهذا يعني أن `strlen('قهوة')` تعطيك 8 وليس 4، والدوال التي تفهم الأحرف هي `mb_strlen` وأخواتها من إضافة `mbstring`، وسوف نعود لهذه النقطة في مقال الأخطاء الشائعة.

### المصفوفة Array: نوع واحد للقائمة والقاموس

في Python عندك `list` و`dict`، وفي JavaScript عندك `Array` و`Object` و`Map`، أما في PHP فعندك نوع واحد اسمه `array`، وهو في الداخل جدول هاش مرتب Ordered Hash Map: مفاتيح وقيم تحفظ بترتيب الإضافة، والمفتاح إما رقم صحيح أو نص. والقائمة List ليست نوعاً مستقلاً، وإنما مصفوفة مفاتيحها 0 و1 و2 بالترتيب، كما يشرح [توثيق المصفوفات](https://www.php.net/manual/en/language.types.array.php?ref=arabroot.io):

```php
$list = ['قهوة', 'تمر', 'هيل'];
$map  = ['coffee' => 2500, 'dates' => 4000];
$map[] = 999;                       // appends with the next integer key
var_dump(array_is_list($list), array_is_list($map));
print_r($map);
```

والمخرج:

```text
bool(true)
bool(false)
Array
(
    [coffee] => 2500
    [dates] => 4000
    [0] => 999
)
```

لاحظ أن `$map[] = 999` أضاف القيمة بالمفتاح 0، لأن PHP يختار أكبر مفتاح رقمي موجود زائد واحد، ولم يكن هناك أي مفتاح رقمي. والدالة `array_is_list` (منذ 8.1) هي الطريقة الصحيحة لتعرف هل المصفوفة قائمة أم قاموس، وهذا مهم عندما تحول المصفوفة إلى JSON: القائمة تصبح `[...]` والقاموس يصبح `{...}`، ومصفوفة حذفت منها عنصراً بـ `unset` فصارت مفاتيحها 0 و2 تصبح كائن JSON وليس قائمة، وهذا من أكثر الأخطاء التي تعطل الواجهات الأمامية التي تتوقع قائمة.

### المصفوفة قيمة Value والكائن مقبض Handle

وهذا الفرق هو الأهم في هذا القسم كله، لأنه يعاكس ما تعودت عليه في Python وJavaScript. في تلك اللغات عندما تكتب `b = a` لقائمة فالمتغيران يشيران إلى نفس القائمة، وتعديل أحدهما يظهر في الآخر. أما في PHP فالمصفوفة قيمة Value، والإسناد ينسخها، بينما الكائن Object يمرر كمقبض Handle يشير إلى نفس الكائن:

```php
$a = [1, 2, 3];
$b = $a;
$b[] = 4;
echo '3) count($a)=', count($a), ' count($b)=', count($b), "\n";
$o1 = new ArrayObject([1, 2, 3]);
$o2 = $o1;
$o2[] = 4;
echo '3) count($o1)=', count($o1), "\n";
```

والمخرج:

```text
3) count($a)=3 count($b)=4
3) count($o1)=4
```

![قسمان: الأول يبين أن المصفوفة جدول هاش مرتب فيه المفاتيح coffee وdates و0 بقيمها، وأن array_is_list تعطي true للقائمة وfalse للقاموس، والثاني يقارن نسخ المصفوفة حيث بقي count للأصل 3 بعد إضافة عنصر للنسخة، مع الكائن ArrayObject حيث صار count للأصل 4 لأن المتغيرين يشيران إلى نفس الكائن](https://arabroot.io/content/images/2026/10/php-for-devs-03-arrays.webp)

المصفوفة تنسخ عند أول تعديل، والكائن يشترك فيه كل من يحمل مقبضه

وقد يتساءل البعض: إذا كانت المصفوفة تنسخ في كل إسناد، أليس هذا بطيئاً مع المصفوفات الكبيرة؟ والإجابة أن PHP لا ينسخ عند الإسناد، وإنما عند أول تعديل، وهذا ما يسمى النسخ عند الكتابة Copy-on-Write. وقسنا ذلك في الملف `bin/pass.php` بمصفوفة فيها مليون رقم، ومعه دالة تستلم المصفوفة مرة بالقيمة ومرة بالمرجع `&`:

```php
function addFee(array $items): array
{
    $items[] = 'رسوم توصيل';
    return $items;
}
function addFeeInPlace(array &$items): void
{
    $items[] = 'رسوم توصيل';
}

$cart = ['قهوة', 'تمر'];
$new = addFee($cart);
echo 'by value:     cart=', count($cart), ' returned=', count($new), "\n";
addFeeInPlace($cart);
echo 'by reference: cart=', count($cart), "\n";

$big = range(1, 1_000_000);
$m0 = memory_get_usage();
$copy = $big;                 // no copy yet
$m1 = memory_get_usage();
$copy[] = 0;                  // first write: real copy
$m2 = memory_get_usage();
printf("assign: +%d bytes, first write: +%.1f MB\n", $m1 - $m0, ($m2 - $m1) / 1048576);
```

والمخرج سوف يكون كما يلي:

```text
by value:     cart=2 returned=3
by reference: cart=3
assign: +0 bytes, first write: +16.0 MB
```

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

- الدالة `addFee` أضافت عنصراً إلى نسختها فقط، وبقيت السلة الأصلية بعنصرين، وهذا عكس ما يحدث لو مررت قائمة إلى دالة في Python.
- الإسناد `$copy = $big` لم يستهلك ولا بايت واحداً، لأن المتغيرين يشتركان في نفس البيانات مع عداد مراجع Reference Count، وأول تعديل هو الذي نسخ المصفوفة كلها، فظهرت 16 ميجابايت دفعة واحدة. وبالتالي تمرير مصفوفة كبيرة إلى دالة تقرأها فقط رخيص، وتعديلها داخل الدالة هو المكلف.
- المرجع `&` يجعل الدالة تعدل الأصل، ولكن لا تستخدمه للأداء، وإنما فقط عندما يكون تعديل الأصل هو المقصود، والسبب أن [المراجع في PHP](https://www.php.net/manual/en/language.references.php?ref=arabroot.io) لها سلوك مربك مع الحلقات `foreach` سوف نراه في مقال الأخطاء الشائعة.

### المقارنة Comparison: لماذا تكتب === دائماً؟

العامل `==` في PHP يحول القيمتين إلى نوع مشترك ثم يقارن، والعامل `===` يقارن النوع والقيمة معاً بدون تحويل. وقواعد التحويل تحسنت كثيراً في PHP 8، ولكنها ما زالت تفاجئ من لم يقرأها:

```php
var_dump(0 == 'a', '1' == '01', '10' == '1e1', 100 == '1e2', null == false, '1' === '01');
```

والمخرج:

```text
bool(false)
bool(true)
bool(true)
bool(true)
bool(true)
bool(false)
```

والقيمة الأولى `0 == 'a'` كانت `true` قبل PHP 8، لأن النص `'a'` كان يتحول إلى صفر، وكانت سبباً في ثغرات أمنية حقيقية في مقارنة التوكن والباسورد، وصارت `false` بعد تغيير قواعد مقارنة الرقم بالنص في 8.0\. ولكن انظر إلى الثلاثة التالية: `'1' == '01'` و`'10' == '1e1'` كلها `true`، لأن النصين اللذين يشبهان الأرقام يقارنان كأرقام، فـ `'1e1'` هي عشرة بالصيغة العلمية. تخيل الآن أنك تقارن رمز تحقق OTP أو رقم طلب بـ `==`، وأن أحد الطرفين جاء من المستخدم. لذلك القاعدة بسيطة: استخدم `===` و`!==` في كل مكان، ولا تستخدم `==` إلا إذا كنت تريد التحويل فعلاً وتعرف قواعده من [جدول المقارنة في التوثيق](https://www.php.net/manual/en/language.operators.comparison.php?ref=arabroot.io).

### التعبير match والتعداد Enum والكلاس readonly

هذه الأدوات الثلاث هي ما يجعل PHP الحديثة تشبه Kotlin أو TypeScript أكثر من PHP القديمة. والتعبير `match` يشبه `switch` ولكنه يعيد قيمة، ويقارن بـ `===`، ولا يحتاج `break`، ويرمي خطأ `UnhandledMatchError` إذا لم تطابق أي حالة بدل أن يمر بصمت كما يفعل `switch`:

```php
$status = 'paid';
echo '5) ', match ($status) {
    'new' => 'جديد',
    'paid', 'shipped' => 'مدفوع',
}, "\n";
```

والمخرج `5) مدفوع`. أما التعداد `enum` فهو نوع حقيقي وليس ثوابت نصية، ويمكن أن يحمل قيمة `string` أو `int` تخزن في قاعدة البيانات، ويمكن أن تكون له دوال، ومعه كلاس `readonly` بخصائص تعرف في الـ Constructor مباشرة Constructor Promotion:

```php
enum Category: string
{
    case Coffee = 'coffee';
    case Dates = 'dates';

    public function label(): string
    {
        return match ($this) {
            self::Coffee => 'قهوة',
            self::Dates => 'تمور',
        };
    }
}

final readonly class Product
{
    public function __construct(
        public string $name,
        public int $priceHalalas,
        public Category $category = Category::Coffee,
    ) {}

    public function withPrice(int $halalas): static
    {
        return clone($this, ['priceHalalas' => $halalas]);
    }
}

$p = new Product(priceHalalas: 4500, name: 'تمر سكري', category: Category::Dates);
echo "6) {$p->name} {$p->priceHalalas} {$p->category->label()}\n";
try {
    $p->priceHalalas = 1;
} catch (Error $e) {
    echo '6) ', $e::class, ': ', $e->getMessage(), "\n";
}
```

والمخرج سوف يكون كما يلي:

```text
6) تمر سكري 4500 تمور
6) Error: Cannot modify readonly property Product::$priceHalalas
```

في الكود أعلاه لاحظ التالي:

- المعاملات المسماة Named Arguments سمحت لنا بتمرير السعر قبل الاسم، وهي نفس فكرة `name=` في Python، ولكن انتبه إلى أن اسم المعامل صار جزءاً من الواجهة العامة للدالة، فتغيير اسمه يعطل كل من يستدعيها بالاسم.
- محاولة تعديل خاصية `readonly` رمت `Error` وليس `Exception`، وهذا تقسيم مهم في PHP: `Error` لأخطاء البرمجة مثل النوع الخاطئ والخاصية غير القابلة للتعديل، و`Exception` للحالات التي يتوقعها البرنامج، وكلاهما يطبق الواجهة `Throwable`. وهذا موضوع المقال الثاني في المرحلة بالتفصيل.
- السعر محفوظ بالهللة كرقم صحيح `int` وليس بالريال كرقم عشري، والسبب شرحناه في مقال الأرقام العشرية: الأعداد من نوع `float` لا تمثل 0.1 بدقة، والحساب المالي يجب أن يكون بأصغر وحدة.
- الدالة `withPrice` تستخدم `clone` مع مصفوفة تعديل، وهي إضافة PHP 8.5، وسوف نشرح بعد قليل لماذا كتبناها داخل الكلاس وليس في الخارج.

### الدوال المجهولة Closures: متى تحتاج use؟

في JavaScript وPython ترى الدالة الداخلية كل المتغيرات المحيطة بها تلقائياً، أما في PHP فهناك نوعان: الدالة القصيرة `fn` تأخذ نسخة من المتغيرات المحيطة تلقائياً، والدالة الكاملة `function` لا ترى أي متغير خارجي إلا إذا ذكرته في `use`، وإذا أردت أن تعدله فعليك أن تمرره بالمرجع:

```php
$vat = 15;
$withVat = fn (int $h): int => intdiv($h * (100 + $vat) + 50, 100);
echo '8) ', $withVat(990), "\n";
$counter = 0;
$inc = function () use (&$counter): void {
    $counter++;
};
$inc();
$inc();
echo "8) counter={$counter}\n";
```

والمخرج `8) 1139` ثم `8) counter=2`. والرقم 1139 هو سعر القهوة 990 هللة مع الضريبة 15% مقرباً لأقرب هللة، فالقيمة الدقيقة 1138.5 وأضفنا 50 قبل القسمة على 100 ليصبح التقريب نصف للأعلى Round Half Up. ولو نسيت `&` في `use (&$counter)` لبقي العداد صفراً، لأن الدالة كانت ستعدل نسختها فقط، وهذا من أكثر الأخطاء التي تراها عند من جاء من JavaScript.

### الجديد في PHP 8.4 و8.5: الخطافات Property Hooks والعامل |>

القسم التاسع من الجولة يجمع إضافات آخر إصدارين، لأنها ستظهر في الكود الحديث وفي الأمثلة الرسمية، ومن المفيد أن تعرفها حتى لا تظنها صيغة غريبة:

```php
final class Cart
{
    public array $items = [];

    public int $total {
        get => array_sum(array_map(fn ($i) => $i->priceHalalas, $this->items));
    }
}
$cart = new Cart();
$cart->items = [new Product('قهوة', 2500), $p];
echo '9) total=', $cart->total, "\n";
echo '9) first dates: ', array_find($cart->items, fn ($i) => $i->category === Category::Dates)->name, "\n";
echo '9) array_first: ', array_first($cart->items)->name, "\n";
$slug = '  Arabic Coffee  ' |> trim(...) |> strtolower(...) |> (fn ($s) => str_replace(' ', '-', $s));
echo "9) slug={$slug}\n";
$cheaper = $p->withPrice(4000);
echo "9) clone with: {$cheaper->priceHalalas} (original {$p->priceHalalas})\n";
```

والمخرج سوف يكون كما يلي:

```text
9) total=7000
9) first dates: تمر سكري
9) array_first: قهوة
9) slug=arabic-coffee
9) clone with: 4000 (original 4500)
```

في الكود أعلاه لاحظ التالي:

- الخاصية `$total` لها خطاف `get`، أي أنها تحسب عند القراءة مثل `@property` في Python أو `get total()` في JavaScript، وهذا من [خطافات الخصائص في PHP 8.4](https://www.php.net/manual/en/language.oop5.property-hooks.php?ref=arabroot.io)، وكان البديل قبلها دالة `getTotal()`.
- `array_find` من 8.4 و`array_first` من 8.5، وقبلها كان الناس يكتبون حلقة أو يستخدمون `reset()` التي تحرك مؤشراً داخلياً في المصفوفة.
- `trim(...)` بثلاث نقاط تصنع Closure من دالة موجودة First-class Callable، والعامل `|>` من [مقترح Pipe Operator](https://wiki.php.net/rfc/pipe-operator-v3?ref=arabroot.io) يمرر القيمة من اليسار إلى الدالة على اليمين، فتقرأ السطر من اليسار لليمين بدل الدوال المتداخلة `str_replace(' ', '-', strtolower(trim($s)))`. ولاحظ أن الدالة القصيرة الأخيرة بين أقواس، وهذا مطلوب في الصيغة.

وأول مرة كتبنا هذا القسم كتبنا `clone($p, ['priceHalalas' => 4000])` مباشرة في الملف وليس داخل الكلاس، فتوقف التشغيل بهذا الخطأ:

```text
Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Product::$priceHalalas from global scope in /app/bin/tour.php:99
Stack trace:
#0 /app/bin/tour.php(99): clone(Object(Product), Array)
#1 {main}
  thrown in /app/bin/tour.php on line 99
```

والرسالة تشرح نفسها إذا عرفت قاعدة واحدة: الخاصية `public readonly` تعامل كأنها `protected(set)`، أي أن القراءة عامة ولكن الكتابة من داخل الكلاس فقط، و`clone` مع التعديل يحترم هذه القاعدة كما يذكر [توثيق النسخ](https://www.php.net/manual/en/language.oop5.cloning.php?ref=arabroot.io). لذلك الحل الصحيح هو دالة `withPrice` داخل الكلاس كما في الكود الذي رأيته، وهو نمط Withers المعروف في Java وKotlin.

### الوضع الصارم strict\_types: النوع يفحص وقت التشغيل

والقسم الأخير يوضح أهم فرق بين أنواع PHP وأنواع Python: في Python التلميح Type Hint لا يفحص وقت التشغيل أبداً، وتحتاج mypy ليفحصه، أما في PHP فالنوع المكتوب على المعامل يفحص فعلاً عند كل استدعاء. ولكن كيف يفحص يعتمد على السطر `declare(strict_types=1)` في أول الملف الذي يستدعي الدالة:

```php
function addHalalas(int $a, int $b): int { return $a + $b; }
try {
    echo addHalalas('5', 10), "\n";
} catch (TypeError $e) {
    echo '10) TypeError: ', $e->getMessage(), "\n";
}
```

والمخرج مع الوضع الصارم:

```text
10) TypeError: addHalalas(): Argument #1 ($a) must be of type int, string given, called in /app/bin/tour.php on line 118
```

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

## نموذج التشغيل Share-Nothing: كل طلب يبدأ من الصفر

إذا جئت من Node.js أو FastAPI أو Spring فأنت معتاد على أن التطبيق عملية Process واحدة تعمل طوال الوقت، تحمل الإعدادات مرة واحدة، وتفتح اتصالات قاعدة البيانات مرة واحدة، وتستطيع أن تضع أي شيء في متغير عام فيبقى بين الطلبات. وPHP في وضعه التقليدي يعمل بطريقة مختلفة تماماً: كل طلب Request يبدأ بذاكرة فارغة، يحمل الكود، يبني الكائنات، يكتب الرد، ثم يحذف كل شيء، وهذا ما يسمى Share-Nothing. وحتى نرى ذلك بأعيننا كتبنا ملفاً صغيراً يزيد عداداً ثابتاً `static` وعداداً عاماً:

```php
<?php
// Every request starts from zero: nothing survives between requests (share-nothing)
static $hits = 0;
$hits++;
$GLOBALS['total'] = ($GLOBALS['total'] ?? 0) + 1;
header('Content-Type: text/plain');
echo "pid=", getmypid(), " hits=", $hits, " total=", $GLOBALS['total'], "\n";
```

ثم شغلنا السيرفر المدمج في PHP مع ثلاثة Workers، وأرسلنا ستة طلبات:

```bash
docker run -d --name slp-php-web -p 19531:8000 -e PHP_CLI_SERVER_WORKERS=3 -v "$PWD:/app" -w /app slp-php-cli:8.5 php -S 0.0.0.0:8000 -t public
for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1:19531/counter.php; done
```

والمخرج سوف يكون كما يلي:

```text
pid=7 hits=1 total=1
pid=7 hits=1 total=1
pid=1 hits=1 total=1
pid=8 hits=1 total=1
pid=1 hits=1 total=1
pid=6 hits=1 total=1
```

![خمس مراحل لحياة الطلب: يصل إلى Worker جاهز، ثم تحميل الكود من OPcache وملف autoload.php، ثم بناء الكائنات والاتصال بقاعدة البيانات، ثم كتابة الرد، ثم حذف كل المتغيرات، وتحتها كود counter.php ومخرج الطلبات الستة حيث العداد 1 في كل مرة رغم تكرار نفس رقم العملية](https://arabroot.io/content/images/2026/10/php-for-devs-04-share-nothing.webp)

بعد كل طلب تحذف كل المتغيرات، حتى static والمتغيرات العامة، والعملية نفسها تبقى للطلب التالي

لاحظ أن العملية رقم 7 استقبلت طلبين متتاليين، ومع ذلك كان العداد 1 في المرتين، فالعملية Process بقيت، ولكن كل متغيرات الطلب السابق حذفت. وهذا التصميم له نتائج تحتاج أن تفهمها من اليوم الأول:

- تسرب الذاكرة Memory Leak في طلب واحد لا يتراكم، لأن كل شيء يحذف في نهايته، وخطأ قاتل في طلب لا يسقط الطلبات الأخرى، وهذا من أسباب أن PHP «يتسامح» مع كود سيئ لسنوات.
- أي حالة يجب أن تبقى بين الطلبات، مثل الجلسة Session أو العداد أو الكاش، يجب أن تخزن خارج PHP: في قاعدة البيانات أو Redis أو ملفات.
- كل طلب يدفع ثمن البداية: تحميل الكلاسات وقراءة الإعدادات وفتح اتصال قاعدة البيانات، ولهذا يوجد OPcache الذي يحفظ الكود المترجم في الذاكرة المشتركة بين العمليات، ولهذا ظهرت في السنوات الأخيرة أدوات مثل FrankenPHP وRoadRunner وLaravel Octane التي تبقي التطبيق في الذاكرة بين الطلبات، وتعيد لك في المقابل مشاكل الحالة المشتركة التي يعرفها مطورو Node.js.

والسيرفر المدمج `php -S` للتطوير فقط، كما يحذر [توثيقه الرسمي](https://www.php.net/manual/en/features.commandline.webserver.php?ref=arabroot.io)، وفي الإنتاج يعمل PHP خلف Nginx عبر [PHP-FPM](https://www.php.net/manual/en/install.fpm.php?ref=arabroot.io) الذي يدير مجموعة من العمليات Worker Pool، وهذا موضوع مقال التزامن في هذه المرحلة، حيث نقيس كم طلباً متزامناً يتحمل كل إعداد.

## مدير الحزم Composer والتحميل التلقائي Autoload

قبل 2012 كان مشروع PHP يضع المكتبات بنسخها يدوياً في مجلد `lib`، ويكتب `require_once` لكل ملف، ثم جاء Composer فغير ذلك تماماً، وأصبح اليوم الطريقة الوحيدة المقبولة لإدارة الاعتماديات Dependencies في أي مشروع جاد. وهو يقوم بعملين، والفصل بينهما مهم لتفهم الأخطاء: الأول تنزيل الحزم من [Packagist](https://packagist.org/?ref=arabroot.io) إلى `vendor/` وتثبيت إصداراتها في `composer.lock`، والثاني توليد ملف `vendor/autoload.php` الذي يحمل الكلاسات تلقائياً عند أول استخدام. وقد شرحنا الفكرة العامة لمديري الحزم والملفات المقفلة Lock Files في مقال [مديرو الحزم](https://arabroot.io/articles/%D9%85%D8%AF%D9%8A%D8%B1%D9%88-%D8%A7%D9%84%D8%AD%D8%B2%D9%85-npm-%D9%88-pip-%D9%88-maven-%D9%88-cargo/)، لذلك سوف نركز هنا على ما يخص PHP.

ملف `composer.json` لمشروعنا كما يلي:

```json
{
    "name": "arabroot/store",
    "description": "Small store library for the php-for-devs lab",
    "type": "project",
    "require": {
        "php": ">=8.5"
    },
    "autoload": {
        "psr-4": { "App\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Tests\\": "tests/" }
    },
    "scripts": {
        "test": "phpunit",
        "lint": "pint --test",
        "stan": "phpstan analyse --no-progress"
    }
}
```

في الإعداد أعلاه لاحظ التالي:

- `"php": ">=8.5"` في `require` ليس حزمة، وإنما شرط على إصدار PHP نفسه، فيرفض Composer التثبيت على PHP 8.4، ويمكنك بنفس الطريقة أن تطلب إضافة مثل `"ext-pdo_pgsql": "*"`.
- `psr-4` تعني أن النطاق Namespace `App\` يقابل المجلد `src/`، فالكلاس `App\Pricing\Discount` يجب أن يكون في الملف `src/Pricing/Discount.php` بنفس حالة الأحرف بالضبط، وهذا هو معيار [PSR-4](https://www.php-fig.org/psr/psr-4/?ref=arabroot.io). وسوف نرى بعد قليل ماذا يحدث عندما تخطئ في حرف واحد.
- `autoload-dev` للاختبارات فقط، فلا تدخل في التحميل عندما تثبت بـ `composer install --no-dev` في الإنتاج.

ثم نضيف أدوات التطوير الثلاث بأمر واحد:

```bash
composer require --dev phpunit/phpunit phpstan/phpstan laravel/pint
```

والمخرج (مختصراً):

```text
Lock file operations: 28 installs, 0 updates, 0 removals
  - Locking laravel/pint (v1.32.1)
  - Locking phpstan/phpstan (2.3.4)
  - Locking phpunit/phpunit (13.4.5)
  ...
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 28 installs, 0 updates, 0 removals
Generating autoload files
No security vulnerability advisories found.
Using version ^13.4 for phpunit/phpunit
Using version ^2.3 for phpstan/phpstan
Using version ^1.32 for laravel/pint
```

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

- طلبنا ثلاث حزم فثبت Composer 28، والباقي اعتماديات PHPUnit من حزم `sebastian/*` و`nikic/php-parser`. وPHPStan وPint يأتيان كملف PHAR واحد تقريباً، فلا يضيفان اعتماديات إلى مشروعك.
- كتب Composer القيود `^13.4` و`^2.3` و`^1.32` في `composer.json`، والعلامة `^` تعني أي إصدار متوافق حسب [قواعد الإصدارات](https://getcomposer.org/doc/articles/versions.md?ref=arabroot.io)، أي من 13.4 حتى قبل 14.0.
- السطر `No security vulnerability advisories found` يأتي من فحص تلقائي يقوم به Composer منذ الإصدار 2.4 مقابل قاعدة الثغرات المعروفة، ويمكنك تشغيله وحده بـ `composer audit` في خط CI.
- `composer.lock` يدخل Git في كل تطبيق، أما في المكتبة التي ينشرها غيرك فالعادة ألا يعتمد عليه أحد.

📌

في مارس 2021 دفع مخترق Commit خبيثاً إلى مستودع php-src باسم Rasmus Lerdorf ومعه Commit آخر باسم Nikita Popov، وكان الكود يفتح باباً خلفياً Backdoor ينفذ أي كود يأتي في ترويسة HTTP معينة، واكتشفه المطورون في مراجعة الكود قبل أي إصدار. وكتب Nikita Popov في [رسالته إلى قائمة المطورين](https://news-web.php.net/php.internals/113838?ref=arabroot.io) أن كل الأدلة تشير إلى اختراق سيرفر git.php.net نفسه، فقرر الفريق إيقافه ونقل المستودع الأساسي إلى GitHub مع إلزام كل عضو بالتحقق الثنائي 2FA. والدرس لك كمستخدم: ثبت PHP من الصور والحزم الرسمية بإصدار محدد، وراجع `composer.lock` في كل Pull Request مثل الكود تماماً.

### كيف يعمل التحميل التلقائي Autoload من الداخل؟

عندما يكتب الكود `new App\Catalog()` ولم يكن الكلاس محملاً بعد، يستدعي PHP كل دالة سجلت عبر `spl_autoload_register` ويعطيها اسم الكلاس، وهذا ما يشرحه [توثيق التحميل التلقائي](https://www.php.net/manual/en/language.oop5.autoload.php?ref=arabroot.io). والدالة التي يسجلها Composer تأخذ الاسم `App\Catalog`، وتحذف البادئة `App\`، وتحول باقي الشرطات المائلة إلى مسار، فتصبح `src/Catalog.php`، ثم تتحقق من وجود الملف وتعمل له `require`. وهذا يعني أن البحث عن الملف يحدث وقت التشغيل على نظام الملفات، ولهذا يوصي [دليل تحسين الـ Autoloader](https://getcomposer.org/doc/articles/autoloader-optimization.md?ref=arabroot.io) بتشغيل `composer install --optimize-autoloader` في الإنتاج، والذي يولد خريطة Classmap جاهزة لكل الكلاسات فلا يحتاج PHP أن يسأل نظام الملفات.

## التنسيق Formatting بـ Pint

في Python عندك black، وفي JavaScript عندك prettier، وفي PHP أداتان رئيسيتان: [PHP-CS-Fixer](https://cs.symfony.com/?ref=arabroot.io) وهو الأقدم والأكثر قابلية للإعداد، و[Pint](https://github.com/laravel/pint?ref=arabroot.io) وهو طبقة فوقه من فريق Laravel بإعدادات جاهزة، ونستخدمه هنا لأنه يعمل بدون أي ملف إعداد. والمعيار الذي يطبقانه مبني على [PER Coding Style](https://www.php-fig.org/per/coding-style/?ref=arabroot.io)، وهو المعيار الذي ورث PSR-12\. وعند تشغيله على الكود كما كتبناه أول مرة كان المخرج كما يلي:

```bash
vendor/bin/pint --test
```

```text
  ⨯⨯⨯⨯⨯⨯⨯

  ──────────────────────────────────────────────────────────────────── Laravel
    FAIL   ........................................... 7 files, 7 style issues
  ⨯ bin/pass.php     blank_line_after_opening_tag, blank_line_before_statement
  ⨯ bin/tour.php class_attributes_separation, new_with_parentheses, single_sp…
  ⨯ public/counter.php              single_quote, blank_line_after_opening_tag
  ⨯ public/index.php new_with_parentheses, blank_line_after_opening_tag, conc…
  ⨯ src/Catalog.php  blank_line_after_opening_tag, blank_line_before_statement
  ⨯ src/Vat.php      blank_line_after_opening_tag, blank_line_before_statement
  ⨯ tests/VatTest.php     blank_line_after_opening_tag, php_unit_method_casing
```

وكل اسم في المخرج قاعدة Rule، مثل `blank_line_after_opening_tag` التي تطلب سطراً فارغاً بعد `<?php`، و`php_unit_method_casing` التي تحول أسماء دوال الاختبار إلى `snake_case` لأن الإعداد الافتراضي هو إعداد Laravel. وإذا أردت أن ترى التغيير قبل تطبيقه فأضف `-v` لملف واحد:

```text
  ⨯ src/Vat.php      blank_line_after_opening_tag, blank_line_before_statement
  @@ -1,4 +1,5 @@
   <?php
  +
   declare(strict_types=1);
  @@ -13,6 +14,7 @@
           if ($halalas < 0) {
               throw new \InvalidArgumentException("amount must be >= 0, got {$halalas}");
           }
  +
           return intdiv($halalas * self::RATE_PERCENT * 10 + 500, 1000);
```

ثم `vendor/bin/pint` بدون `--test` يصلح الملفات، وكان المخرج `FIXED ... 11 files, 11 style issues fixed`. والخيار `--test` هو ما تضعه في CI، لأنه لا يعدل شيئاً ويعيد رمز خروج 1 عند وجود أي مخالفة. وإذا كان فريقك يستخدم Symfony أو مشروعاً غير Laravel فاستخدم `"preset": "per"` في ملف `pint.json`، حتى لا تفرض أسلوب Laravel على كود لا يتبعه.

## التحليل الثابت Static Analysis بـ PHPStan

هنا نصل إلى الأداة التي تعطيك أكبر فائدة في أول أسبوع. فالأنواع في PHP تفحص وقت التشغيل، ولكن فقط عندما يمر الكود في السطر، أما [PHPStan](https://phpstan.org/user-guide/getting-started?ref=arabroot.io) فيقرأ الكود كله دون تشغيله ويبحث عن الأخطاء التي سوف تحدث، مثل mypy في Python وtsc في TypeScript. وله مستويات من 0 إلى 10 حسب [جدول المستويات](https://phpstan.org/user-guide/rule-levels?ref=arabroot.io)، والمستوى `max` هو الأعلى المتاح في الإصدار الذي تستخدمه. وفي الكلاس `Catalog` كتبنا عمداً دالة تحسب سعر السطر في الطلب:

```php
public function find(int $id): ?array
{
    return $this->products[$id] ?? null;
}

public function lineTotal(int $id, int $qty): int
{
    $product = $this->find($id);
    return $product['price_halalas'] * $qty;
}
```

وهذا كود يبدو سليماً، وقد يمر في مراجعة سريعة، فماذا يحدث إذا طلب العميل منتجاً غير موجود؟ شغلنا السكربت `bin/bug.php` الذي يطلب المنتج 1 ثم المنتج 99:

```text
product 1 x 3 = 2970
product 99 x 3 =
Warning: Trying to access array offset on null in /app/src/Catalog.php on line 29
0
done
exit=0
```

وهذا المخرج هو أخطر ما في PHP القديمة: لم يتوقف البرنامج، وإنما طبع تحذيراً Warning وأكمل، وكانت النتيجة صفراً، ورمز الخروج 0 كأن كل شيء سليم. فلو كان هذا داخل حساب طلب لأصبح سعر السطر صفراً وأكمل المتجر الطلب. ونفس الكود عند PHPStan بالمستوى `max`:

```bash
vendor/bin/phpstan analyse --no-progress
```

```text
 ------ -----------------------------------------------------------------------
  Line   src/Catalog.php
 ------ -----------------------------------------------------------------------
  21     Method App\Catalog::find() return type has no value type specified in
         iterable type array.
  29     Binary operation "*" between mixed and int results in an error.
  29     Offset 'price_halalas' might not exist on array|null.
 ------ -----------------------------------------------------------------------
 ...
 [ERROR] Found 6 errors
```

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

- الخطأ الأخير في السطر 29 هو خطأنا بالضبط: `find` قد تعيد `null`، والكود يقرأ منها مفتاحاً دون تحقق.
- الخطأ في السطر 21 يطلب منك أن تصف شكل المصفوفة، لأن `array` وحدها لا تقول شيئاً عن مفاتيحها وقيمها، ولذلك لم يعرف PHPStan نوع `$product['price_halalas']` فاعتبره `mixed` وظهر الخطأ الثاني.
- الأخطاء الثلاثة الأخرى في `public/` كانت من نوع `Parameter #1 $url of function parse_url expects string, mixed given`، لأن `$_SERVER['REQUEST_URI']` نوعه `mixed`، وعليك أن تتحقق منه قبل استخدامه.

والإصلاح يكون على مرحلتين: نصف شكل المصفوفة بتعليق PHPDoc يفهمه PHPStan، ونحول `null` إلى استثناء واضح باستخدام `throw` كتعبير بعد `??`:

```php
/**
 * @phpstan-type Product array{id:int, name_ar:string, price_halalas:int}
 */
final class Catalog
{
    /** @return Product|null */
    public function find(int $id): ?array
    {
        return $this->products[$id] ?? null;
    }

    public function lineTotal(int $id, int $qty): int
    {
        $product = $this->find($id) ?? throw new ProductNotFound("product {$id} not found");

        return $product['price_halalas'] * $qty;
    }
}
```

وبعد الإصلاح أعطى PHPStan النتيجة `[OK] No errors`، وأصبح نفس السكربت يتوقف عند المنتج 99 برسالة واضحة بدل الصفر الصامت:

```text
product 1 x 3 = 2970
product 99 x 3 =
Fatal error: Uncaught App\ProductNotFound: product 99 not found in /app/src/Catalog.php:33
Stack trace:
#0 /app/bin/bug.php(10): App\Catalog->lineTotal(99, 3)
#1 {main}
  thrown in /app/src/Catalog.php on line 33
exit=255
```

والأفضل في مشروع حقيقي أن تستبدل المصفوفة بكلاس `readonly` كما فعلنا مع `Product` في الجولة، فتصبح الأنواع حقيقية وتفحص وقت التشغيل أيضاً، وأما المصفوفات بأشكال PHPDoc فهي الحل المناسب للكود القديم الذي لا تستطيع إعادة كتابته مرة واحدة. وفي المشاريع القديمة الكبيرة لا تبدأ بالمستوى `max`، وإنما ابدأ بمستوى منخفض، واستخدم ملف Baseline يسجل الأخطاء الحالية حتى لا تظهر، ثم ارفع المستوى تدريجياً بحيث لا يضيف أي كود جديد خطأً جديداً.

## الاختبارات Testing بـ PHPUnit

PHPUnit هو أداة الاختبارات القياسية في PHP منذ أكثر من عشرين سنة، و[Pest](https://pestphp.com/?ref=arabroot.io) طبقة فوقه بصيغة قريبة من Jest، وأي منهما مناسب، ولكن PHPUnit هو ما تجده في أغلب المشاريع وفي كل المكتبات. وأول دالة نختبرها هي حساب الضريبة، وهي نفس الدالة التي سوف نستخدمها في كل لغات المسار، لذلك نكتبها بعناية:

```php
final class Vat
{
    public const RATE_PERCENT = 15;

    /** VAT on an amount in halalas, rounded half up to the nearest halala. */
    public static function of(int $halalas): int
    {
        if ($halalas < 0) {
            throw new \InvalidArgumentException("amount must be >= 0, got {$halalas}");
        }

        return intdiv($halalas * self::RATE_PERCENT * 10 + 500, 1000);
    }

    public static function total(int $halalas): int
    {
        return $halalas + self::of($halalas);
    }
}
```

والفكرة في `intdiv($halalas * 150 + 500, 1000)` أن 15% تساوي 150 من ألف، فنضرب في 150 ونضيف نصف المقسوم عليه 500 ثم نقسم قسمة صحيحة، فيحدث التقريب نصف للأعلى بأرقام صحيحة فقط دون أي `float`. ومثال ذلك ثلاث عبوات قهوة سعر الواحدة 9.90 ريال: المجموع 2970 هللة، والضريبة الدقيقة 445.5 هللة، فتصبح 446 هللة، والإجمالي 3416 هللة أي 34.16 ريالاً. والاختبار يستخدم مزود البيانات Data Provider ليجرب عدة حالات بدالة واحدة:

```php
final class VatTest extends TestCase
{
    public static function cases(): array
    {
        return [
            '9.90 x 3' => [2970, 446, 3416],
            'zero' => [0, 0, 0],
            'one halala' => [1, 0, 1],
            'half rounds up' => [10, 2, 12],
        ];
    }

    #[DataProvider('cases')]
    public function test_vat_and_total(int $net, int $vat, int $total): void
    {
        self::assertSame($vat, Vat::of($net));
        self::assertSame($total, Vat::total($net));
    }

    public function test_negative_amount_is_rejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        Vat::of(-1);
    }
}
```

ونشغله بالأمر `vendor/bin/phpunit --testdox`، والمخرج سوف يكون كما يلي:

```text
PHPUnit 13.4.5 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.12
Configuration: /app/phpunit.xml

.....                                                               5 / 5 (100%)

Time: 00:02.236, Memory: 20.00 MB

Vat (Tests\Vat)
 ✔ Vat and total with data set "9.90 x 3"
 ✔ Vat and total with data set "zero"
 ✔ Vat and total with data set "one halala"
 ✔ Vat and total with data set "half rounds up"
 ✔ Negative amount is rejected

OK (5 tests, 9 assertions)
```

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

- `#[DataProvider('cases')]` سمة Attribute وهي الطريقة الحديثة، أما التعليق `@dataProvider` القديم فلم يعد مدعوماً في PHPUnit الحديث، وستجده في الكود القديم ويجب تحويله عند الترقية.
- استخدمنا `assertSame` وليس `assertEquals`، والفرق هو نفس فرق `===` و`==`: `assertEquals(446, '446')` ينجح، أما `assertSame` فيفشل لأن النوع مختلف، وفي حساب مالي تريد أن تعرف إذا تحولت القيمة إلى نص في الطريق.
- خمسة اختبارات من دالتين، لأن مزود البيانات أعطى أربع حالات، وتسعة تأكيدات Assertions لأن كل حالة فيها تأكيدان مع تأكيد الاستثناء.
- الزمن 2.2 ثانية أغلبه بدء Docker وقراءة الملفات من المجلد المربوط على Windows، وعند التشغيل الثاني على نفس الملفات كان 0.2 ثانية.

وملف الإعداد `phpunit.xml` يحدد مجلد الاختبارات ومجلد الكود الذي يقاس فيه تغطية الاختبارات Coverage، والتفاصيل في [توثيق PHPUnit 13.4](https://docs.phpunit.de/en/13.4/?ref=arabroot.io). وفي مقال [أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/) شرحنا الفرق بين اختبار الوحدة Unit Test واختبار التكامل Integration Test، وسوف نكتب اختبارات تكامل حقيقية مع PostgreSQL في مقال الخدمة الحقيقية.

## هيكل المشروع Project Layout: أين يذهب كل ملف؟

بعد هذه الخطوات صار عندنا مشروع صغير بالهيكل الذي ستجده في أغلب مشاريع PHP الحديثة، سواءً كانت مكتبة أو تطبيقاً، والفكرة الأساسية فيه أن مجلد `public/` وحده هو ما يراه السيرفر، وكل الكود الباقي خارجه:

![شجرة مجلدات مشروع store فيها composer.json وcomposer.lock وphpunit.xml وphpstan.neon ومجلدات public وsrc وtests وbin وvendor، وبجانبها شرح لكل عنصر: public هو Document Root وفيه Front Controller، وsrc فيه كلاس في كل ملف بنطاق App، وvendor لا يدخل Git ويعاد بناؤه من composer install](https://arabroot.io/content/images/2026/10/php-for-devs-05-layout.webp)

هيكل المشروع: public وحده مكشوف للسيرفر، وكل شيء آخر خارجه

وهذا الفصل هو ما يحمي المشاريع الحديثة من خطأ قديم ومكلف: في المشاريع القديمة كان كل الكود داخل مجلد الموقع، فإذا أخطأ أحدهم في إعداد السيرفر وظهر ملف `config.php` كنص، أو بقي ملف `.env` أو `composer.json` في مجلد الموقع، أصبح أي شخص قادراً على تنزيله. أما مع `public/index.php` كنقطة دخول وحيدة Front Controller، فالسيرفر لا يرى إلا ملفاً واحداً يحمل باقي الكود من خارج المجلد، وهذا ما يفعله Laravel وSymfony بالضبط. وفي تطبيق ويب سوف تجد أيضاً مجلدات مثل `config/` و`resources/` و`storage/`، وسوف نراها في مقال الخدمة الحقيقية مع Laravel.

## مشكلتان ظهرتا في التجربة Lab Problems

المشكلة الأولى ظهرت قبل أن نكتب أي سطر: أول تشغيل لـ `composer require` كان على مجلد المشروع المربوط من Windows إلى الـ Container، فاستمر عشر دقائق ثم توقف بهذا الخطأ:

```text
    Install of phpstan/phpstan failed
    Install of nikic/php-parser failed
The following exception is caused by a process timeout
  The process "'/usr/bin/unzip' '-qq' '/app/vendor/composer/tmp-c343f922593db
  d4e22e792c16a589994.zip' '-d' '/app/vendor/composer/37593a63'" exceeded the
   timeout of 300 seconds.
```

والسبب أن مجلد `vendor/` فيه آلاف الملفات الصغيرة، وكل عملية كتابة أو قراءة على مجلد Windows مربوط داخل Docker Desktop تمر عبر طبقة مشاركة الملفات وهي بطيئة جداً مع الملفات الكثيرة، و`unzip` تجاوز المهلة الافتراضية لـ Composer وهي 300 ثانية. والحل ليس رفع المهلة، وإنما وضع `vendor/` في Volume مستقل داخل Docker:

```bash
docker run --rm -v "$PWD:/app" -v slp-php-vendor:/app/vendor -w /app slp-php-cli:8.5 composer install
```

وبعد هذا التغيير انتهى نفس الأمر في أقل من أربع دقائق بدون أي خطأ، وأغلب هذا الوقت تنزيل من الشبكة. وقسنا أثر نفس المشكلة على السيرفر: طلب `/products` من داخل الـ Container كان يأخذ بين 30 و175 مللي ثانية عندما يقرأ PHP الكود من المجلد المربوط، وبين 1.4 و2.2 مللي ثانية عندما نسخنا نفس الكود إلى نظام ملفات الـ Container. لذلك إذا كنت تطور على Windows أو macOS بـ Docker، فضع الكود داخل WSL2 أو داخل الـ Container، ولا تقس أداء PHP أبداً على مجلد مربوط.

والمشكلة الثانية أخطر لأنها لا تظهر على جهازك: أضفنا كلاساً جديداً `App\Pricing\Discount` وحفظناه بالخطأ في ملف اسمه `src/Pricing/discount.php` بحرف صغير، فعمل كل شيء على جهاز Windows وطبع السكربت النتيجة 4050\. ثم نسخنا نفس المشروع إلى نظام ملفات Linux داخل الـ Container وشغلناه:

```text
Fatal error: Uncaught Error: Class "App\Pricing\Discount" not found in /tmp/a/whatif/autoload.php:7
Stack trace:
#0 {main}
  thrown in /tmp/a/whatif/autoload.php on line 7
exit=255
```

والسبب أن نظام الملفات NTFS في Windows لا يفرق بين الأحرف الكبيرة والصغيرة، فيجد الملف `Discount.php` رغم أن اسمه `discount.php`، أما ext4 في Linux فيفرق بينهما، فلا يجد الـ Autoloader الملف. وهذا الخطأ يظهر عادة أول مرة في خط CI أو في الإنتاج، وليس على جهاز المطور. والحل أن يكون اسم الملف مطابقاً لاسم الكلاس حرفاً حرفاً، وأن يعمل CI على Linux، وأن تفعل في Git الإعداد `core.ignorecase=false` حتى يرى تغيير حالة الأحرف في اسم الملف.

## ماذا يحدث إذا حذفت strict\_types؟ What If

رأينا أن الدالة `addHalalas(int $a, int $b)` رفضت النص `'5'` مع الوضع الصارم، فماذا يحدث لنفس الدالة في ملف ليس فيه `declare(strict_types=1)`؟ كتبنا الملف `whatif/no-strict.php` وجربنا فيه خمس قيم:

```php
echo addHalalas('5', 10), "\n";
echo addHalalas('5.0', 10), "\n";
echo addHalalas(' 5', 10), "\n";
echo addHalalas('5 ريال', 10), "\n";   // inside try/catch
echo addHalalas(5.5, 10), "\n";        // inside try/catch
```

والمخرج سوف يكون كما يلي:

```text
15
15
15
TypeError: addHalalas(): Argument #1 ($a) must be of type int, string given, called in /app/whatif/no-strict.php on line 12

Deprecated: Implicit conversion from float 5.5 to int loses precision in /app/whatif/no-strict.php on line 3
15
```

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

- بدون الوضع الصارم يعمل PHP في وضع التحويل Coercive Mode، فيقبل النص `'5'` و`'5.0'` و`' 5'` لأنها نصوص رقمية Numeric Strings، ويحولها إلى 5، كما يشرح [توثيق تحويل الأنواع](https://www.php.net/manual/en/language.types.type-juggling.php?ref=arabroot.io).
- النص `'5 ريال'` رفض حتى بدون الوضع الصارم، لأن PHP 8 لم يعد يقبل النصوص التي تبدأ برقم ثم فيها كلام، وكانت PHP 7 تقبلها مع تنبيه.
- الرقم 5.5 تحول إلى 5 وضاع نصف الريال مع تنبيه `Deprecated` فقط، وهذا التنبيه يمكن أن يضيع في السجلات، ونتيجته في حساب مالي خطأ صامت.
- الوضع الصارم يطبق على الاستدعاءات من الملف الذي فيه `declare`، وليس على الملف الذي عرفت فيه الدالة. لذلك ضعه في أول كل ملف، وPint وPHPStan يمكن إعدادهما ليرفضا أي ملف بدونه.

## قائمة تحقق Checklist لأول يوم مع PHP

- هل تعرف إصدار PHP في الإنتاج، وهل هو ضمن جدول الدعم؟ وهل نفس الإصدار في Docker عندك وفي CI؟
- هل في أول كل ملف `declare(strict_types=1)`؟
- هل تستخدم `===` في كل مقارنة، وخاصة مع قيم جاءت من المستخدم؟
- هل تتذكر أن المصفوفة تنسخ عند التعديل والكائن يشترك فيه الجميع؟
- هل `composer.lock` في Git، و`vendor/` خارجه، و`composer audit` في CI؟
- هل أسماء الملفات تطابق أسماء الكلاسات حرفاً حرفاً، وهل CI يعمل على Linux؟
- هل يعمل `pint --test` و`phpstan analyse` و`phpunit` في كل Pull Request، وتوقف الدمج عند الفشل؟
- هل المبالغ المالية أرقام صحيحة بالهللة وليست `float`؟
- هل مجلد `public/` وحده هو ما يراه السيرفر؟
- هل الكود على مجلد مربوط من Windows أو macOS؟ إذا كان كذلك فلا تقس الأداء عليه.

## تدريب عملي Practice

- أضف إلى `Vat` دالة تحسب الضريبة لسلة كاملة، واكتب لها حالة في مزود البيانات فيها ثلاثة منتجات مختلفة، ثم قرر: هل تقرب الضريبة لكل سطر أم للإجمالي؟ وقارن النتيجتين.
- غير مستوى PHPStan إلى 5، واحذف تعليق `@phpstan-type`، ولاحظ أي الأخطاء اختفت وأيها بقي.
- استبدل المصفوفة في `Catalog` بكلاس `Product` من نوع `readonly`، وشغل الأدوات الثلاث بعد التغيير.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- PHP الحالية هي 8.5.12، والفروع المدعومة من 8.2 إلى 8.5، و8.2 تنتهي إصلاحاته الأمنية في نهاية 2026.
- الفروق التي توقع القادم من لغة أخرى: النقطة لدمج النصوص، والمصفوفة الواحدة المرتبة التي تنسخ كقيمة، و`==` التي تحول الأنواع، و`use` في الدوال المجهولة.
- الأنواع في PHP تفحص وقت التشغيل فعلاً، ومع `strict_types` ترفض أي تحويل، وبدونه تحول النصوص الرقمية بصمت.
- كل طلب يبدأ من ذاكرة فارغة، وكل ما يجب أن يبقى يذهب إلى قاعدة البيانات أو Redis.
- Composer للحزم والتحميل التلقائي، وPint للتنسيق، وPHPStan للأخطاء قبل التشغيل، وPHPUnit للاختبارات، وفي التجربة كشف PHPStan خطأ المنتج غير الموجود الذي جعل PHP يعطي صفراً بصمت.
- على Windows ضع `vendor/` في Volume، وطابق أسماء الملفات مع الكلاسات حرفاً حرفاً، لأن Linux يفرق بين الحالتين.

وفي المقال التالي من المرحلة سوف ندخل إلى نظام الأنواع والذاكرة والأخطاء في PHP 8 بالتفصيل: عداد المراجع وجامع الدورات Cycle Collector مقيساً بالأرقام، والفرق بين `Error` و`Exception` والتحذيرات، ثم التزامن مع PHP-FPM وFibers، ثم خدمة الطلبات الكاملة بـ Laravel وPostgreSQL.

[![](https://arabroot.io/content/images/2026/10/choose-language-cover-cover.webp)دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/)[![](https://arabroot.io/content/images/2026/10/package-managers-cover-cover.webp)دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.](https://arabroot.io/articles/%D9%85%D8%AF%D9%8A%D8%B1%D9%88-%D8%A7%D9%84%D8%AD%D8%B2%D9%85-npm-%D9%88-pip-%D9%88-maven-%D9%88-cargo/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بـ PHP 8.5.12 وComposer 2.11.1 وPHPUnit 13.4.5 وPHPStan 2.3.4 وPint 1.32.1 داخل Docker.