> ## 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.

# الأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسة
- URL: https://arabroot.io/articles/الانواع-والذاكرة-والاخطاء-في-php-8/
- Published: 2026-10-31T07:33:32.000Z
- Updated: 2026-10-31T11:08:02.000Z
- Description: نحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في مقال [PHP لمن يعرف البرمجة](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/) بدأنا العمل على متجر صغير يبيع القهوة والتمور، وكتبنا دالة الضريبة واختبارها، ورأينا أن PHPStan كشف منتجاً غير موجود كان PHP يحسب سعره صفراً بصمت. والآن في الأسبوع الثاني تصلك من فريق الدعم ثلاث شكاوى في يوم واحد: عميل دفع ثمن علبة هيل بسعر 1.50 ريال وظهرت ضريبتها في الفاتورة 22 هللة بينما يقول المحاسب إنها 23، وطلب وصل من تطبيق الجوال فارغاً تماماً دون أي رسالة خطأ في السجلات، وعملية Worker تعالج الطلبات في الخلفية يزيد استهلاكها للذاكرة كل يوم حتى يعيد أحدهم تشغيلها. وهذه الشكاوى الثلاث لها نفس الجذر: أنت تكتب PHP بعقل لغة أخرى، ولم تتعرف بعد على طريقة PHP في الأنواع Types، وفي إدارة الذاكرة Memory Management، وفي الأخطاء Errors.

وهذا المقال هو الثاني في مرحلة [لغة PHP للمطورين](https://arabroot.io/start/#stage-lang-php)، وسوف نأخذ فيه كل شكوى من الثلاث إلى جذرها بتجربة تعمل فعلاً على PHP 8.5.12: نحسب الضريبة لمئة ألف سعر بطريقتين ونعد الفروق، ونصنع مئتي ألف كائن في دورات ونقيس الذاكرة مع جامع الدورات وبدونه، ونشغل Worker يعالج مئتي ألف مهمة ونرى ذاكرته تكبر، ثم نشغل كل نوع من أنواع الفشل في PHP ونقرأ ما يطبعه. والمفاهيم العامة شرحناها في مقالات مستقلة: أنظمة الأنواع في [أنماط البرمجة وأنظمة الأنواع](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، وطرق إدارة الذاكرة في [إدارة الذاكرة](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)، وأساليب الأخطاء في [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، لذلك سوف نركز هنا على ما يخص PHP فقط.

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

- كيف يمثل PHP القيم من الداخل، ولماذا هو ديناميكي الأنواع ويفحص الأنواع وقت التشغيل في نفس الوقت.
- الأرقام Numbers: تجاوز الحد Overflow، والأرقام العشرية float، وتجربة الضريبة على مئة ألف سعر، والتقريب Rounding.
- النصوص الرقمية Numeric Strings والتحويل Casting، ثم التصريحات Type Declarations: المتحد والتقاطع وDNF و`never` والخصائص ذات النوع والتعداد.
- الأنواع العامة Generics التي لا توجد إلا في PHPStan.
- من يحرر الذاكرة: عداد المراجع Reference Counting وجامع الدورات Cycle Collector مقيسين بالأرقام، و`memory_limit`، و`WeakMap`.
- العمليات الطويلة Long-Running Workers وتسرب الذاكرة فيها.
- الأخطاء: التحذير Warning والخطأ Error والاستثناء Exception، وكيف تحول التحذيرات إلى استثناءات، و`finally` و`#[\NoDiscard]`، وإعدادات الأخطاء في التطوير والإنتاج.
- مشكلتان ظهرتا في التجربة، وتجربة «ماذا يحدث إذا»، وأخطاء شائعة، وقائمة تحقق.

## كيف يمثل PHP القيمة Value من الداخل؟

كل قيمة في PHP، سواءً كانت رقماً أو نصاً أو مصفوفة أو كائناً، تحفظ داخل المحرك في بنية صغيرة اسمها zval، وفيها شيئان: علامة النوع Type Tag تقول هذا `int` أو `string` أو `array`، والقيمة نفسها أو مؤشر إليها. والأرقام الصحيحة والعشرية والقيم المنطقية تحفظ داخل الـ zval مباشرة، أما النصوص والمصفوفات والكائنات فتحفظ في مكان آخر في الذاكرة ومعها عداد مراجع Reference Count يقول كم متغيراً يشير إليها، وهذا ما يشرحه [توثيق أساسيات عداد المراجع](https://www.php.net/manual/en/features.gc.refcounting-basics.php?ref=arabroot.io).

وهذا التصميم يفسر لك صفتين في PHP قد تبدوان متعارضتين. الأولى أن PHP ديناميكي الأنواع Dynamically Typed: المتغير نفسه ليس له نوع، وإنما القيمة التي فيه، فيمكن أن يحمل `$x` رقماً ثم نصاً. والثانية أن PHP يفحص الأنواع وقت التشغيل Runtime Type Checking: عندما تكتب `function addHalalas(int $a)` فالمحرك ينظر إلى علامة النوع في الـ zval الذي وصل، ويرفضه إذا لم يكن `int`. وهنا يختلف PHP عن Python التي لا تفحص التلميحات أبداً، وعن TypeScript التي تفحص قبل التشغيل ثم تمحو الأنواع، فـ PHP يقع في المنتصف: لا يوجد فحص قبل التشغيل إلا بأداة خارجية مثل PHPStan، ولكن كل دالة عليها أنواع تتحقق منها في كل استدعاء.

![ثلاثة صناديق للأنواع: البسيطة مثل int وfloat وstring وbool وarray والكائنات والتعداد، ثم الأنواع المركبة مثل int|string و?Product وA&B و(A&B)|null، ثم الخاصة مثل void وnever وmixed، وتحتها صندوقان: ما يفحصه PHP وقت التشغيل مع مثال TypeError وخطأ الخاصية غير المهيأة، وما يفحصه PHPStan فقط مثل TypedList<int> التي قبلت نصاً وقت التشغيل](https://arabroot.io/content/images/2026/10/php-types-memory-01-types.webp)

ما تكتبه على الدوال والخصائص يفحص في كل استدعاء، وما تكتبه في تعليقات PHPDoc يفحصه PHPStan فقط

## الأرقام Numbers: int وfloat وما بينهما

في PHP نوعان من الأرقام فقط: `int` وهو رقم صحيح بحجم 64 بت على كل الأنظمة الحديثة، و`float` وهو رقم عشري بمعيار IEEE 754 بدقة مضاعفة Double Precision، ولا يوجد `decimal` مدمج كما في C# أو `BigInteger` كما في Java. وأول ما شغلناه في الملف `types.php` كان حدود هذين النوعين:

```php
var_dump(PHP_INT_MAX + 1);
var_dump(0.1 + 0.2 === 0.3, 9.90 * 3);
printf("%.20f\n", 9.90 * 3);
var_dump(7 / 2, intdiv(7, 2), -7 % 3, fmod(-7.5, 2));
var_dump(round(2.5), round(-2.5), round(4.455, 2));
```

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

```text
float(9.223372036854776E+18)
bool(false)
float(29.700000000000003)
29.70000000000000284217
float(3.5)
int(3)
int(-1)
float(-1.5)
float(3)
float(-3)
float(4.46)
```

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

- `PHP_INT_MAX + 1` لم يرم خطأ تجاوز Overflow كما في C# مع `checked`، ولم يلتف إلى رقم سالب كما في C وJava، وإنما تحول بصمت إلى `float`، وفقد دقته لأن الـ float يحفظ 53 بت فقط للأرقام المعنوية. وهذا السلوك موثق في [صفحة الأعداد الصحيحة](https://www.php.net/manual/en/language.types.integer.php?ref=arabroot.io)، ونتيجته أن معرفاً رقمياً كبيراً جاء من نظام آخر قد يتغير آخر رقمين فيه دون أي تنبيه.
- `9.90 * 3` أعطت `29.700000000000003`، وهذه ليست مشكلة في PHP، وإنما في تمثيل الأرقام العشرية بالنظام الثنائي في كل اللغات، وقد شرحنا السبب بالتفصيل في مقال [الأرقام العشرية Floating Point من الداخل](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/).
- القسمة `/` تعطي `float` دائماً عندما لا تكون القسمة تامة، والقسمة الصحيحة لها دالة مستقلة `intdiv`، أما Python فلها عامل `//`.
- باقي القسمة `-7 % 3` يساوي `-1` في PHP، أي أنه يأخذ إشارة المقسوم كما في C وJava وJavaScript، أما Python فتعطيك `2`. وهذا الفرق يظهر عندما تحسب يوم الأسبوع أو رقم الدفعة من رقم سالب.
- `round(2.5)` أعطت 3 و`round(-2.5)` أعطت -3، أي أن التقريب الافتراضي في PHP نصف بعيداً عن الصفر Half Away From Zero، بينما `round(2.5)` في Python تعطيك 2 لأنها تقرب إلى الزوجي Banker's Rounding. فإذا نقلت حساباً من Python إلى PHP ستختلف النتائج في كل حالة نصف بالضبط، والتفاصيل وخيارات التقريب الأخرى في [توثيق round](https://www.php.net/manual/en/function.round.php?ref=arabroot.io).

### تجربة الضريبة VAT على مئة ألف سعر

والآن نعود إلى الشكوى الأولى: علبة الهيل بسعر 1.50 ريال وضريبتها 22 هللة. كتبنا الملف `money.php` الذي يحسب الضريبة بطريقتين لكل سعر من هللة واحدة حتى ألف ريال، أي مئة ألف سعر: الطريقة الأولى كما يكتبها أغلب من يبدأ، بالريال كرقم عشري ثم `round` لخانتين، والطريقة الثانية بالهللة كرقم صحيح مع التقريب نصف للأعلى بأرقام صحيحة فقط، وهي نفس دالة المقال الأول:

```php
function vatFloat(float $riyals): float
{
    return round($riyals * 0.15, 2);
}
function vatInt(int $halalas): int
{
    return intdiv($halalas * 150 + 500, 1000);
}

$diff = 0;
$examples = [];
$total = 0;
for ($h = 1; $h <= 100_000; $h++) {      // every price from 0.01 to 1000.00 SAR
    $total++;
    $f = (int) round(vatFloat($h / 100) * 100);
    $i = vatInt($h);
    if ($f !== $i) {
        $diff++;
        if (count($examples) < 5) {
            $examples[] = sprintf('%.2f SAR: float %d, int %d (exact %s)', $h / 100, $f, $i, bcmul((string) $h, '0.15', 3));
        }
    }
}
```

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

```text
prices checked: 100000, disagreements: 1276
1.50 SAR: float 22, int 23 (exact 22.500)
3.10 SAR: float 46, int 47 (exact 46.500)
3.30 SAR: float 49, int 50 (exact 49.500)
4.10 SAR: float 61, int 62 (exact 61.500)
4.30 SAR: float 64, int 65 (exact 64.500)
9.90 x 3 = 29.700000000000003, vat float = 4.46, vat int = 446 halalas
0.1 + 0.2 = 0.30000000000000004
```

![ثلاثة أرقام: مئة ألف سعر من 0.01 إلى 1000 ريال، و1276 سعراً أعطت فيه الطريقتان ضريبة مختلفة، و1.28% من الأسعار كلها حالات نصف هللة بالضبط، وتحتها جدول لثلاثة أمثلة: 1.50 ريال ضريبتها الدقيقة 22.5 هللة أعطى الحساب بالريال 22 والحساب بالهللة 23، و3.10 ريال أعطى 46 و47، و4.30 ريال أعطى 64 و65](https://arabroot.io/content/images/2026/10/php-types-memory-02-money.webp)

1,276 فاتورة من كل مئة ألف سعر تختلف بهللة واحدة عندما تحسب الضريبة بالريال العشري

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

- اختلفت الطريقتان في 1,276 سعراً من مئة ألف، وتحققنا بسطر إضافي في التجربة أن كل هذه الحالات بلا استثناء هي الحالات التي تكون فيها الضريبة الدقيقة نصف هللة بالضبط، مثل 22.5\. وفي المدى الذي جربناه يوجد 5,000 سعر من هذا النوع، أي أن الحساب بالريال أخطأ في ربعها تقريباً.
- السبب أن `1.50 * 0.15` في الـ float ليست 0.225 بالضبط، وإنما رقم أصغر منها بقليل، فتقربه `round` إلى الأسفل، ولو كان أكبر بقليل لقربته إلى الأعلى، ولذلك لا تستطيع أن تتوقع أي الأسعار سوف تخطئ دون أن تجربها كلها.
- العمود `exact` حسبناه بالدالة `bcmul` من إضافة [BCMath](https://www.php.net/manual/en/book.bc.php?ref=arabroot.io) التي تحسب بالنصوص العشرية بدقة كاملة، وهي الأداة الصحيحة عندما تحتاج كسوراً دقيقة مثل نسبة عمولة بثلاث خانات، ومنذ PHP 8.4 يوجد كلاس [BcMath\\Number](https://www.php.net/manual/en/class.bcmath-number.php?ref=arabroot.io) يسمح بكتابة `$a * $b` عليها مباشرة.
- مثال المسار كله، ثلاث عبوات قهوة بـ 9.90، أعطى نفس النتيجة بالطريقتين: 4.46 و446 هللة. أي أن اختبار حالة واحدة لم يكن ليكشف المشكلة، وهذا ما يجعلها تصل إلى الإنتاج.

والقاعدة التي تخرج بها من هذه التجربة: كل مبلغ مالي في PHP رقم صحيح `int` بأصغر وحدة، والتحويل إلى ريالات يكون فقط عند العرض، وإذا احتجت كسوراً لا تنتهي بالهللة فاستخدم BCMath ولا تستخدم `float` إطلاقاً. وحتى مع `int` بحجم 64 بت عندك حد أعلى يقارب 92 مليون مليار هللة، وهو أكبر من أي فاتورة سوف تراها.

ونفس حد الـ 64 بت يظهر بشكل أخطر عندما يأتي الرقم من نظام آخر داخل JSON، مثل رقم طلب من بوابة دفع أو معرف من نظام ERP، لأن `json_decode` تحوله إلى `float` إذا تجاوز `PHP_INT_MAX` دون أي تحذير. وجربنا ذلك في الملف `bigint.php` برقم من عشرين خانة:

```php
$json = '{"external_id": 92233720368547758070, "qty": 3}';
var_dump(json_decode($json, true)['external_id']);
var_dump(json_decode($json, true, flags: JSON_BIGINT_AS_STRING)['external_id']);
```

والمخرج:

```text
float(9.223372036854776E+19)
string(20) "92233720368547758070"
```

في السطر الأول ضاعت آخر أربع خانات من الرقم، ولو حفظته في قاعدة البيانات ثم بحثت به عن الطلب لما وجدته. والخيار `JSON_BIGINT_AS_STRING` يبقيه نصاً كما هو، وهذه هي القاعدة لكل معرف يأتي من خارج نظامك: المعرف نص وليس رقماً، حتى لو كان كل ما فيه أرقاماً، لأنك لا تجري عليه أي عملية حسابية.

## النصوص الرقمية Numeric Strings والتحويل Casting

لأن PHP ولد ليعالج النماذج Forms، وكل ما يأتي من النموذج نص، فقد بنيت اللغة على فكرة النص الرقمي Numeric String: نص يشبه الرقم يعامل كرقم في العمليات الحسابية والمقارنات. وهذه الفكرة مريحة في صفحة بسيطة ومربكة في نظام مالي، والقسم الثاني من `types.php` يبين حدودها:

```php
var_dump((int) '12abc', (int) 'abc', (float) '1e3', is_numeric('1e3'), is_numeric(' 5'), is_numeric('5 '));
var_dump('abc' <=> 'abd', [1, 2] == [2 => 1], '1e3' == '1000');
```

والمخرج:

```text
int(12)
int(0)
float(1000)
bool(true)
bool(true)
bool(true)
int(-1)
bool(false)
bool(true)
```

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

- التحويل الصريح `(int)` لا يفشل أبداً: `'12abc'` تصبح 12، و`'abc'` تصبح صفراً. لذلك لا تستخدم `(int) $_POST['qty']` للتحقق من المدخلات، لأن كمية مكتوبة خطأ سوف تصبح صفراً أو رقماً آخر بصمت، واستخدم `filter_var($v, FILTER_VALIDATE_INT)` التي تعيد `false` عند أي نص غير صحيح.
- `is_numeric` تقبل `'1e3'` والمسافة قبل الرقم وبعده، وهذا موثق في [صفحة النصوص الرقمية](https://www.php.net/manual/en/language.types.numeric-strings.php?ref=arabroot.io)، ولكنه يعني أن «رقمي» في PHP أوسع مما تتوقع.
- العامل `<=>` Spaceship يعيد -1 أو 0 أو 1، وهو ما تحتاجه دوال الترتيب `usort`.
- `'1e3' == '1000'` صحيحة، لأن النصين رقميان فيقارنان كرقمين، وهذا سبب آخر لـ `===` الذي ذكرناه في المقال الأول، وتغيير قواعد هذه المقارنات في PHP 8 موثق في [مقترح Saner String to Number Comparisons](https://wiki.php.net/rfc/string%5Fto%5Fnumber%5Fcomparison?ref=arabroot.io).

## التصريحات Type Declarations: من Union إلى never

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

```php
interface HasPrice { public function price(): int; }
interface HasStock { public function stock(): int; }

function parseQty(int|string $raw): int
{
    return is_int($raw) ? $raw : (int) $raw;
}
function describe((HasPrice&HasStock)|null $item): string   // DNF type (PHP 8.2)
{
    return $item === null ? 'none' : "{$item->price()}/{$item->stock()}";
}
function fail(string $msg): never
{
    throw new RuntimeException($msg);
}
var_dump(parseQty(3), parseQty('4'), describe(new Item(990, 5)), describe(null));
try {
    parseQty(2.5);
} catch (TypeError $e) {
    echo 'TypeError: ', $e->getMessage(), "\n";
}
```

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

```text
int(3)
int(4)
string(5) "990/5"
string(4) "none"
TypeError: parseQty(): Argument #1 ($raw) must be of type string|int, float given, called in /app/types.php on line 43
```

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

- `int|string` نوع متحد Union Type يقبل أحد النوعين، وهو مناسب للقيم التي تأتي من مصادر مختلفة، ولكن لاحظ أن الرسالة كتبته `string|int` بترتيب مختلف، لأن PHP يرتب الأنواع داخلياً.
- `(HasPrice&HasStock)|null` صيغة DNF أي Disjunctive Normal Form، وهي تقاطع Intersection بين واجهتين أو `null`، أي «أي كائن له سعر ومخزون معاً، أو لا شيء». وهي من [مقترح DNF Types](https://wiki.php.net/rfc/dnf%5Ftypes?ref=arabroot.io) في PHP 8.2.
- `never` نوع إرجاع لدالة لا تعود إطلاقاً، فهي إما ترمي استثناء أو تنهي البرنامج، وفائدته أن PHPStan يعرف أن الكود بعد استدعائها لن ينفذ.
- `parseQty(2.5)` فشلت رغم أن الدالة تقبل نوعين، لأن الوضع الصارم لا يحول `float` إلى `int` أو `string`.

### الخصائص ذات النوع Typed Properties: غير مهيأة وليست null

والقسم الرابع يوضح نقطة تفاجئ القادم من Java أو C#، حيث كل حقل له قيمة افتراضية، أو من JavaScript حيث الحقل غير الموجود `undefined`:

```php
final class Order
{
    public int $id;
    public ?string $note = null;
}
$o = new Order();
var_dump($o->note);
try {
    echo $o->id;
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
```

والمخرج:

```text
NULL
Error: Typed property Order::$id must not be accessed before initialization
```

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

### التعداد Enum: from أم tryFrom؟

والحالة التي يعيش فيها الطلب في المتجر مثال مناسب للتعداد المدعوم بقيمة Backed Enum، حيث تحفظ القيمة النصية في قاعدة البيانات ويتعامل الكود مع نوع حقيقي:

```php
enum Status: string
{
    case New = 'new';
    case Paid = 'paid';
}
var_dump(Status::from('paid'), Status::tryFrom('refunded'));
try {
    Status::from('refunded');
} catch (ValueError $e) {
    echo 'ValueError: ', $e->getMessage(), "\n";
}
```

والمخرج:

```text
enum(Status::Paid)
NULL
ValueError: "refunded" is not a valid backing value for enum Status
```

والفرق بين الدالتين هو نفس الفرق بين الخطأ والحالة المتوقعة: `from` ترمي `ValueError` عندما تكون القيمة غير معروفة، فاستخدمها عندما تقرأ من قاعدة بياناتك وتعرف أن القيمة يجب أن تكون صحيحة، و`tryFrom` تعيد `null`، فاستخدمها عندما تقرأ مدخلات من مستخدم أو من نظام خارجي وتريد أن ترد بخطأ 400 بنفسك.

## الأنواع العامة Generics: موجودة في PHPStan فقط

إذا جئت من Java أو C# أو TypeScript فأول ما تبحث عنه هو `List<int>`، وPHP لا يدعم الأنواع العامة وقت التشغيل، ونوقشت الفكرة في عدة مقترحات دون أن تصل، والسبب أن فحصها في كل استدعاء مكلف في محرك يفحص الأنواع وقت التشغيل. والحل الذي اتفق عليه المجتمع هو أن تكتب الأنواع العامة في تعليقات PHPDoc، ويفهمها PHPStan وPsalm، كما يشرح [دليل Generics في PHPStan](https://phpstan.org/blog/generics-in-php-using-phpdocs?ref=arabroot.io):

```php
/**
 * @template T
 */
final class TypedList
{
    /** @var list<T> */
    private array $items = [];

    /** @param T $item */
    public function add(mixed $item): void
    {
        $this->items[] = $item;
    }

    /** @return T|null */
    public function first(): mixed
    {
        return $this->items[0] ?? null;
    }
}

/** @var TypedList<int> $prices */
$prices = new TypedList();
$prices->add(990);
$prices->add('4500');          // runs fine: no check at runtime
echo cheapest($prices), "\n";
```

وعند التشغيل طبع الملف `990` دون أي خطأ، رغم أننا أضفنا نصاً إلى قائمة أرقام. أما PHPStan بالمستوى `max` فأعطى:

```text
 ------ -----------------------------------------------------------------------
  Line   generics.php
 ------ -----------------------------------------------------------------------
  37     Parameter #1 $item of method TypedList<int>::add() expects int, strin
         g given.
 ------ -----------------------------------------------------------------------
 [ERROR] Found 1 error
```

وهنا ترى حدود نظام الأنواع في PHP بوضوح: ما تكتبه في الكود نفسه يفحص في كل استدعاء، وما تكتبه في التعليقات لا يراه PHP أبداً، وإنما يراه PHPStan فقط. لذلك يصبح تشغيل PHPStan في CI جزءاً من نظام الأنواع، وليس أداة إضافية تختارها حسب ذوقك، وهذا يشبه وضع Python مع mypy أكثر مما يشبه Java.

## من يحرر الذاكرة Memory Management في PHP؟

الآن ننتقل إلى الشكوى الثالثة، الـ Worker الذي تكبر ذاكرته، ولكن قبلها نحتاج أن نفهم كيف يحرر PHP الذاكرة أصلاً. وPHP يستخدم آليتين معاً: عداد المراجع Reference Counting كآلية رئيسية، وجامع الدورات Cycle Collector كآلية مساعدة للحالة الوحيدة التي يفشل فيها العداد. وهذا يختلف عن Java وGo وNode.js التي تعتمد على جامع قمامة Garbage Collector يتتبع الكائنات الحية Tracing GC، ويقترب من Python التي تستخدم نفس الفكرة تقريباً، ومن ARC في Swift مع فرق أن Swift لا تملك جامعاً للدورات أصلاً.

### عداد المراجع Reference Counting: التحرير الفوري

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

```php
$a = new Node('demo-A', true);
$b = $a;
echo "  unset(\$a)\n";
unset($a);
echo "  unset(\$b)\n";
unset($b);
echo "  after unset\n";
```

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

```text
== 1. refcount: freed the moment the last reference goes
  construct demo-A
  unset($a)
  unset($b)
  destruct demo-A
  after unset
```

لاحظ أن `unset($a)` لم يحرر شيئاً، لأن `$b` ما زال يشير إلى الكائن، وأن `destruct demo-A` ظهر مباشرة بعد `unset($b)` وقبل السطر التالي. وهذا التحرير المحدد التوقيت Deterministic مفيد جداً: الملف الذي فتحه كائن يغلق في `__destruct` لحظة انتهاء استخدامه، وليس عندما يقرر جامع القمامة في Java أن يعمل بعد ثوان.

### الدورات Cycles وجامعها

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

```php
$x = new Node('demo-X', true);
$y = new Node('demo-Y', true);
$x->other = $y;
$y->other = $x;
unset($x, $y);
echo "  after unset (no destruct yet)\n";
$freed = gc_collect_cycles();
echo "  gc_collect_cycles() freed {$freed} objects\n";
```

والمخرج:

```text
== 2. a cycle is not freed by refcount
  construct demo-X
  construct demo-Y
  after unset (no destruct yet)
  destruct demo-X
  destruct demo-Y
  gc_collect_cycles() freed 2 objects
```

![صندوقان: الأول كائن demo-A يشير إليه المتغيران $a و$b والعداد 2، وبعد حذف الاثنين يصبح صفراً ويحرر فوراً، والثاني كائنان demo-X وdemo-Y يشير كل منهما إلى الآخر والعداد 1 لكل منهما بعد حذف المتغيرات، فيبقيان حتى يعمل جامع الدورات بعد 10,000 جذر أو عند gc_collect_cycles](https://arabroot.io/content/images/2026/10/php-types-memory-03-refcount.webp)

العداد يحرر فوراً، والدورات تنتظر جامعها

وجامع الدورات لا يعمل باستمرار، وإنما يحفظ في مخزن الجذور Root Buffer كل قيمة نقص عدادها دون أن يصل إلى صفر، لأنها قد تكون جزءاً من دورة، وعندما يمتلئ المخزن بـ 10,000 جذر محتمل يبدأ الخوارزمية التي تبحث عن الدورات وتحررها، كما يشرح [توثيق جمع الدورات](https://www.php.net/manual/en/features.gc.collecting-cycles.php?ref=arabroot.io). ويمكنك أن تستدعيه بنفسك بـ `gc_collect_cycles()`، وأن ترى إحصاءاته بـ [gc\_status](https://www.php.net/manual/en/function.gc-status.php?ref=arabroot.io).

### القياس: مئتا ألف كائن مع الجامع وبدونه

حتى نرى ثمن الدورات بالأرقام كتبنا الملف `cycles.php`، والذي ينشئ مئة ألف زوج من الكائنات، كل كائن يحمل نصاً بحجم كيلوبايت، وكل زوج يشير أحدهما إلى الآخر، ثم يطبع الزمن والذاكرة وإحصاءات الجامع، ويقبل معاملاً `on` أو `off` لتشغيل الجامع أو إيقافه بـ `gc_disable()`:

```php
$enabled = ($argv[1] ?? 'on') === 'on';
$enabled ? gc_enable() : gc_disable();
$start = memory_get_usage();
$t = hrtime(true);
for ($i = 0; $i < 100_000; $i++) {
    $p = new Node("p{$i}");
    $q = new Node("q{$i}");
    $p->other = $q;
    $q->other = $p;
}
unset($p, $q);
```

وشغلناه ثلاث مرات بكل وضع، مع رفع حد الذاكرة في وضع الإيقاف إلى 512 ميجابايت لسبب سوف تراه بعد قليل:

```bash
for i in 1 2 3; do php cycles.php on; php -d memory_limit=512M cycles.php off; done
```

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

```text
gc on  memory_limit 128M  time  393.1 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time 1054.9 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
gc on  memory_limit 128M  time  450.0 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time  626.2 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
gc on  memory_limit 128M  time  498.5 ms  memory now +0.2 MB  peak 14.0 MB  gc runs 20  collected 199960
gc off memory_limit 512M  time  253.6 ms  memory now +270.5 MB  peak 271.0 MB  gc runs 0  collected 0
```

![ثلاثة أشرطة: ذروة الذاكرة مع الجامع 14 ميجابايت، وبدونه 271 ميجابايت، وحد الذاكرة الافتراضي 128 ميجابايت، وتحتها ثلاث بطاقات: الجامع عمل 20 مرة وحرر 199,960 كائناً، وبدون الجامع مع الحد الافتراضي توقف البرنامج برسالة Allowed memory size exhausted، والزمن بين 273 و631 مللي ثانية مع الجامع وبين 228 و1055 بدونه](https://arabroot.io/content/images/2026/10/php-types-memory-04-gc-measured.webp)

الفرق الحقيقي في الذاكرة وليس في الزمن: 14 ميجابايت مقابل 271

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

- مع الجامع لم تتجاوز الذروة 14 ميجابايت، وعمل الجامع 20 مرة بالضبط، وهذا يطابق ما في التوثيق: كل كائن من المئتي ألف يدخل المخزن كجذر محتمل عندما ينقص عداده دون أن يصل إلى صفر، ومئتا ألف مقسومة على 10,000 تعطي 20.
- جمع 199,960 كائناً وليس 200,000، لأن آخر الأزواج ما زالت في المخزن ولم يمتلئ بعد، وتحرر عند نهاية البرنامج.
- بدون الجامع بقيت 270.5 ميجابايت في الذاكرة بعد انتهاء الحلقة، أي كل الكائنات تقريباً، رغم أن الكود لم يعد يشير إلى أي واحد منها.
- الزمن في هذه التشغيلات الثلاث ومعها تسع تشغيلات سابقة محفوظة في الملف `cycles-earlier-runs.txt` كان بين 273 و631 مللي ثانية مع الجامع، وبين 228 و1055 بدونه، والجهاز كان يشغل أعمالاً أخرى في نفس الوقت، لذلك لا نستطيع أن نقول من هذه الأرقام إن أحد الوضعين أسرع، والنتيجة الواضحة الوحيدة هي الذاكرة.

وقد يتساءل البعض: إذا كان الجامع يعمل تلقائياً، فلماذا أهتم؟ والإجابة أن الطلب العادي في PHP يحذف كل شيء في نهايته كما رأينا في نموذج Share-Nothing، فالدورات فيه لا تضر غالباً، أما سكربت يستورد ملف منتجات فيه مليون سطر، أو Worker يعمل أياماً، فالدورات فيه تتراكم بين مرات عمل الجامع، وكل مرة يعمل فيها يمر على عشرة آلاف جذر. والحل أن تتجنب الدورات في التصميم من البداية، مثلاً بأن يحفظ السطر رقم الطلب بدل الطلب نفسه، أو أن تستخدم `WeakReference` للمرجع العكسي.

### الكاش الضعيف WeakMap: لا يبقي الكائن حياً

ومن الأخطاء الشائعة أن تحفظ معلومات إضافية عن كائنات في مصفوفة كاش، فتبقى الكائنات حية لأن الكاش يشير إليها حتى بعد أن ينتهي استخدامها. والكلاس [WeakMap](https://www.php.net/manual/en/class.weakmap.php?ref=arabroot.io) منذ PHP 8.0 يحل ذلك، فهو يستخدم الكائن مفتاحاً دون أن يزيد عداده، وعندما يحرر الكائن يختفي مدخله من الكاش تلقائياً:

```text
== 3. WeakMap: a cache that does not keep objects alive
  WeakMap entries while referenced: 3
  WeakMap entries after dropping references: 0
```

وهذا النمط مفيد عندما تحتاج أن تربط بيانات محسوبة بكائنات لا تملكها، مثل كيانات Entities من مكتبة ORM.

### حد الذاكرة memory\_limit

في أول تشغيل لتجربة الدورات لم نرفع حد الذاكرة، فتوقف وضع الإيقاف بهذه الرسالة:

```text
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}
```

والرقم 134217728 بايت هو 128 ميجابايت، وهو القيمة الافتراضية للإعداد [memory\_limit](https://www.php.net/manual/en/ini.core.php?ref=arabroot.io#ini.memory-limit)، وهو حد لكل عملية PHP وليس للسيرفر كله. وهذا الحد هو خط الدفاع الذي يمنع طلباً واحداً سيئاً من أن يأخذ ذاكرة السيرفر كلها، لذلك لا ترفعه إلى `-1` (بدون حد) في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر، وإنما ارفعه لذلك السكربت فقط بـ `php -d memory_limit=512M` أو `ini_set` في أوله. ولاحظ أن هذا الخطأ من نوع Fatal Error الذي لا يمكن التقاطه بـ `try`، وسوف نعود لذلك في قسم الأخطاء.

ولاحظ الرقم في الرسالة: `tried to allocate 2097152 bytes`، أي 2 ميجابايت بالضبط، رغم أن الكائن الذي نصنعه كيلوبايت واحد فقط. والسبب أن مدير الذاكرة في PHP لا يطلب الذاكرة من نظام التشغيل كائناً كائناً، وإنما يحجزها بقطع Chunks حجم كل منها 2 ميجابايت، ثم يقسمها داخلياً على القيم الصغيرة، فالرسالة تذكر القطعة التي فشل حجزها وليس الكائن. ولهذا أيضاً تعطيك `memory_get_usage(true)` رقماً أكبر من `memory_get_usage()`: الأولى تعد القطع المحجوزة من النظام، والثانية تعد ما تستخدمه القيم داخلها فعلاً، وهي التي استخدمناها في كل القياسات.

ولهذا إذا رأيت في سجل الإنتاج رسالة `Allowed memory size` فلا تبدأ برفع الحد، وإنما ابحث أولاً عن الحلقة أو الاستعلام الذي حمل بيانات أكثر مما يحتاج.

## العمليات الطويلة Long-Running Workers: لماذا تكبر الذاكرة؟

نعود الآن إلى الشكوى الثالثة. في المتجر عملية تعمل في الخلفية بالأمر `php artisan queue:work` تعالج الطلبات الجديدة وترسل رسائل التأكيد، وهي عملية واحدة تعمل أياماً، أي أنها لا تحصل على «البداية النظيفة» التي يحصل عليها كل طلب ويب. وكتبنا الملف `worker.php` الذي يحاكي ذلك بكلاس يحفظ سعر كل طلب في كاش ثابت `static` حتى لا يحسبه مرتين:

```php
final class PriceLookup
{
    /** @var array<string, int> */
    private static array $cache = [];

    public static function priceFor(string $orderRef): int
    {
        return self::$cache[$orderRef] ??= random_int(100, 10_000);   // cached per order: grows forever
    }
}

$limitMb = (int) ($argv[1] ?? 0);
for ($job = 1; $job <= 200_000; $job++) {
    PriceLookup::priceFor("order-{$job}");
    if ($job % 50_000 === 0) {
        printf("job %6d  memory %5.1f MB\n", $job, memory_get_usage() / 1048576);
    }
    if ($limitMb > 0 && memory_get_usage() > $limitMb * 1048576) {
        printf("job %6d  memory over %d MB: exit so the supervisor starts a fresh process\n", $job, $limitMb);
        exit(0);
    }
}
```

والمخرج بدون حد ثم مع حد 8 ميجابايت:

```text
job  50000  memory   4.9 MB
job 100000  memory   9.3 MB
job 150000  memory  16.2 MB
job 200000  memory  18.1 MB
job  50000  memory   4.9 MB
job  66985  memory over 8 MB: exit so the supervisor starts a fresh process
```

![أعمدة تبين ذاكرة الـ Worker بعد 50 ألف مهمة 4.9 ميجابايت، ثم 9.3 بعد مئة ألف، ثم 16.2، ثم 18.1 بعد مئتي ألف مهمة، وتحتها صندوقان: السبب كاش ثابت يحفظ سعراً لكل طلب ولا يحذف شيئاً، والعلاج حد للذاكرة يخرج بعده الـ Worker عند المهمة 66,985 ويعيد المشرف تشغيله](https://arabroot.io/content/images/2026/10/php-types-memory-06-worker.webp)

في الـ Worker لا يوجد نهاية طلب تحذف الكاش، فيكبر مع كل مهمة

وهذه ليست دورة يحلها الجامع، وإنما كاش يحتفظ بكل شيء عن قصد، فلا يوجد أي آلية في PHP سوف تحرره. والعلاج له طبقتان: الأولى في الكود، بأن يكون للكاش حجم أقصى أو مدة، أو أن يفرغ بعد كل مهمة، والثانية في التشغيل، بأن يخرج الـ Worker بشكل نظيف عندما يتجاوز حداً معيناً من الذاكرة أو عدداً من المهام، ويعيد تشغيله مشرف Supervisor مثل systemd أو supervisord. وهذه بالضبط فكرة الخيارين `--memory` و`--max-jobs` في [طوابير Laravel](https://laravel.com/docs/13.x/queues?ref=arabroot.io#memory-limits). وتذكر أن نفس المشكلة تظهر في أي وضع يبقي التطبيق في الذاكرة، مثل FrankenPHP في وضع Worker وLaravel Octane، فهناك كل `static` وكل Singleton يعيش بين الطلبات.

## الأخطاء Errors في PHP: ثلاثة أنواع من الفشل

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

- التحذير Warning والتنبيه Notice والإهمال Deprecated: رسالة تكتب في السجل أو على الشاشة، ثم يكمل الكود بقيمة مثل `false` أو `null`. وهذا هو الإرث الأقدم في اللغة، وما زالت دوال كثيرة في المكتبة القياسية تعمل به.
- الخطأ `Error`: يرمى عند خلل في الكود نفسه، مثل نوع خاطئ أو استدعاء دالة على `null` أو قسمة على صفر، وهو قابل للالتقاط منذ PHP 7 ولكنه ليس مخصصاً لذلك.
- الاستثناء `Exception`: يرمى عند فشل متوقع يمكن أن يتعامل معه البرنامج، مثل JSON غير صالح أو منتج نفد من المخزون.

والاثنان الأخيران يطبقان الواجهة [Throwable](https://www.php.net/manual/en/class.throwable.php?ref=arabroot.io)، أما الأول فليس كائناً أصلاً.

![شجرة تبدأ من Throwable وتتفرع إلى Error وتحته TypeError وValueError وArithmeticError ثم DivisionByZeroError، وإلى Exception وتحته RuntimeException وLogicException ثم DomainException ثم OutOfStock، وJsonException وErrorException، وصندوق ثالث للتحذير Warning الذي ليس استثناء، مع مثال file_get_contents التي تعيد false وjson_decode التي تعيد NULL بدون أي تحذير](https://arabroot.io/content/images/2026/10/php-types-memory-05-errors.webp)

التحذير ليس استثناء، والكود يكمل بعده بقيمة false أو null

### التحذير Warning: الكود يكمل بقيمة خاطئة

والقسم الأول من الملف `errors.php` يعيد إنتاج شكوى الطلب الفارغ بالضبط: نقرأ ملفاً غير موجود، ثم نحلل JSON من تطبيق الجوال فيه قوس ناقص:

```php
$body = file_get_contents('/app/missing-order.json');
var_dump($body);
$order = json_decode('{"items": [{"product_id": 1, "qty": 3}', true);   // missing closing brace
var_dump($order, json_last_error_msg());
```

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

```text
Warning: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directory in /app/errors.php on line 8
bool(false)
NULL
string(12) "Syntax error"
```

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

- `file_get_contents` طبعت تحذيراً وأعادت `false`، والكود أكمل إلى السطر التالي، ولو لم نتحقق من `false` لمررناها إلى دالة أخرى تتوقع نصاً.
- `json_decode` أسوأ من ذلك: أعادت `NULL` دون أن تطبع أي تحذير، والطريقة الوحيدة لمعرفة السبب هي أن تسأل `json_last_error_msg()` بعدها. وهذا بالضبط ما حدث في المتجر: الكود قرأ `$order['items'] ?? []` فأصبحت قائمة الأسطر فارغة، وحفظ طلباً فارغاً.

والإصلاح له طريقتان، وكلاهما في القسم الثاني من التجربة. الأولى للدوال التي تدعم ذلك، وهي أن تطلب منها أن ترمي استثناء، مثل الخيار `JSON_THROW_ON_ERROR` الموجود منذ PHP 7.3 كما في [توثيق json\_decode](https://www.php.net/manual/en/function.json-decode.php?ref=arabroot.io). والثانية لكل الدوال الأخرى، وهي معالج أخطاء عام يحول كل تحذير إلى [ErrorException](https://www.php.net/manual/en/class.errorexception.php?ref=arabroot.io) باستخدام [set\_error\_handler](https://www.php.net/manual/en/function.set-error-handler.php?ref=arabroot.io):

```php
try {
    json_decode('{"items": [{"product_id": 1, "qty": 3}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo get_class($e), ': ', $e->getMessage(), ' (code ', $e->getCode(), ")\n";
}
set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
    throw new ErrorException($msg, 0, $no, $file, $line);
});
try {
    file_get_contents('/app/missing-order.json');
} catch (ErrorException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
restore_error_handler();
```

والمخرج:

```text
JsonException: Syntax error (code 4)
ErrorException: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directory
```

وأطر العمل الحديثة مثل Laravel وSymfony تسجل معالجاً مثل هذا تلقائياً، فتتحول التحذيرات إلى استثناءات في بيئة التطوير، لذلك قد لا تلاحظ المشكلة إلا عندما تكتب سكربتاً مستقلاً بدون إطار. والقاعدة: في أي كود جديد خارج إطار عمل، سجل معالج الأخطاء في أول سطر، واستخدم `JSON_THROW_ON_ERROR` في كل استدعاء لـ `json_decode` و`json_encode`.

### الخطأ Error: أخطاء البرمجة ترمى أيضاً

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

```text
== 3. Error: programming mistakes are thrown too
  intdiv by zero    DivisionByZeroError    Division by zero
  modulo by zero    DivisionByZeroError    Modulo by zero
  division by zero  DivisionByZeroError    Division by zero
  negative repeat   ValueError             str_repeat(): Argument #2 ($times) must be greater than or equal to 0
  undefined method  Error                  Call to undefined method ArrayObject::total()
  null method       Error                  Call to a member function total() on null
```

وقبل PHP 8 كانت القسمة `10 / 0` تعطي تحذيراً وتعيد `INF`، و`str_repeat('x', -1)` تعطي تحذيراً وتعيد `null`، أما الآن فكل منها يرمي خطأ يوقف الكود إذا لم يلتقط. و`ValueError` جديد في PHP 8.0، وهو للحالة التي يكون فيها النوع صحيحاً والقيمة غير مقبولة. وهذه الأخطاء لا تلتقطها لتكمل كأن شيئاً لم يحدث، وإنما يلتقطها معالج عام واحد في أعلى التطبيق ليكتبها في السجل ويرد بخطأ 500، ثم تصلح الكود.

### الاستثناءات Exceptions الخاصة بالمتجر: previous وfinally

أما الفشل المتوقع في منطق المتجر، مثل نفاد المخزون، فله استثناء خاص يحمل البيانات التي يحتاجها من يلتقطه، والقسم الخامس من التجربة يبين ثلاث أدوات معاً: الاستثناء الخاص، والاستثناء السابق Previous الذي يحفظ السبب الأصلي، و`finally` الذي ينفذ في كل الحالات:

```php
final class OutOfStock extends DomainException
{
    public function __construct(public readonly int $productId, public readonly int $available, ?Throwable $previous = null)
    {
        parent::__construct("product {$productId}: only {$available} left", 409, $previous);
    }
}
function reserve(int $productId, int $qty): void
{
    try {
        echo "  open transaction\n";
        if ($qty > 2) {
            throw new OutOfStock($productId, 2, new RuntimeException('stock check failed'));
        }
        echo "  commit\n";
    } finally {
        echo "  finally: release lock\n";
    }
}
```

والمخرج:

```text
== 5. domain exceptions, previous, finally
  open transaction
  finally: release lock
  caught OutOfStock code=409 available=2: product 1: only 2 left
  previous: stock check failed
```

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

- `finally` نفذ قبل أن يصل الاستثناء إلى `catch` في الدالة التي استدعت `reserve`، ولذلك هو المكان الصحيح لتحرير القفل أو إغلاق الملف.
- الاستثناء يحمل `available=2` كخاصية `readonly`، فيستطيع من يلتقطه أن يكتب رسالة واضحة للعميل دون أن يحلل نص الرسالة.
- رمز 409 في `code` مجرد رقم نختاره، ولكنه سوف يصبح رد HTTP 409 Conflict في خدمة الطلبات في المقال الرابع.
- الاستثناء السابق `getPrevious()` يحفظ سلسلة الأسباب، وتطبعه أطر العمل كاملاً في السجل، فلا تفقد السبب الأصلي عندما تحول استثناء منخفض المستوى إلى استثناء من منطق المتجر.

### السمة NoDiscard في PHP 8.5: لا تتجاهل النتيجة

بعض الدوال لا ترمي استثناء عند الفشل، وإنما تعيد `false`، ومشكلتها أن من يستدعيها قد يتجاهل النتيجة. والسمة [#\[\\NoDiscard\]](https://www.php.net/manual/en/class.nodiscard.php?ref=arabroot.io) الجديدة في PHP 8.5 تجعل PHP يطبع تحذيراً إذا استدعيت الدالة وتجاهلت ما أعادته:

```php
#[\NoDiscard('the result says whether the stock was reserved')]
function tryReserve(int $qty): bool
{
    return $qty <= 2;
}
tryReserve(5);
$ok = tryReserve(1);
(void) tryReserve(1);
```

والمخرج:

```text
Warning: The return value of function tryReserve() should either be used or intentionally ignored by casting it as (void), the result says whether the stock was reserved in /app/errors.php on line 88
  ok=true
```

والاستدعاء الأول وحده هو الذي أعطى التحذير، أما الثاني فاستخدم النتيجة، والثالث تجاهلها صراحة بالتحويل `(void)` الجديد أيضاً في 8.5، وهو طريقتك لتقول للقارئ «أعرف أن هناك نتيجة ولا أحتاجها».

### ما لا يلتقط Uncaught: رمز الخروج 255

وفي آخر الملف رمينا `OutOfStock` دون أن نلتقطه:

```text
Fatal error: Uncaught OutOfStock: product 2: only 0 left in /app/errors.php:94
Stack trace:
#0 {main}
  thrown in /app/errors.php on line 94
exit=255
```

ورمز الخروج 255 هو ما تراه أنظمة التشغيل وأدوات CI، وهو يختلف عن الصفر الذي رأيناه في المقال الأول عندما أعطى PHP تحذيراً وأكمل. وهناك أخطاء قاتلة لا تمر عبر `Throwable` إطلاقاً، مثل تجاوز `memory_limit` الذي رأيناه، وتجاوز `max_execution_time`، والطريقة الوحيدة لتسجيلها هي دالة تسجل بـ [register\_shutdown\_function](https://www.php.net/manual/en/function.register-shutdown-function.php?ref=arabroot.io) وتقرأ `error_get_last()`، وهذا ما تفعله أطر العمل لتسجل هذه الحالات في السجل.

## إعدادات الأخطاء Error Configuration بين التطوير والإنتاج

بقي سؤال مهم يفسر لماذا لم يظهر الطلب الفارغ في سجلات المتجر: أين تذهب التحذيرات أصلاً؟ والجواب في أربعة إعدادات في `php.ini`، والصورة الرسمية تأتي بنموذجين لهما قيم مختلفة، فقارناهما مباشرة:

```bash
for f in development production; do echo "-- $f"; grep -E "^(display_errors|display_startup_errors|error_reporting|log_errors|zend.exception_ignore_args|zend.assertions|memory_limit)" /usr/local/etc/php/php.ini-$f; done
```

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

```text
-- development
zend.exception_ignore_args = Off
memory_limit = 128M
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
zend.assertions = 1
-- production
zend.exception_ignore_args = On
memory_limit = 128M
error_reporting = E_ALL & ~E_DEPRECATED
display_errors = Off
display_startup_errors = Off
log_errors = On
zend.assertions = -1
```

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

- `display_errors` يعرض الأخطاء في الصفحة نفسها، وهو مفعل في التطوير ومغلق في الإنتاج، والسبب أن رسالة الخطأ تكشف مسارات الملفات وأحياناً أجزاء من الاستعلامات للمستخدم وللمخترق معاً. ولا تفعله في الإنتاج إطلاقاً حتى لدقيقة واحدة لتتبع مشكلة.
- `log_errors` مفعل في الاثنين، وهو الذي يكتب الأخطاء في السجل، ومكانه يحدده `error_log`، وإذا لم تحدده يكتب PHP-FPM في سجل الـ Worker أو سجل السيرفر. وفي الـ Container ضعه على `/dev/stderr` حتى يظهر في `docker logs`، وقد جربنا ذلك فظهر التحذير بهذا الشكل: `PHP Warning: file_get_contents(/nope): Failed to open stream` مع الوقت بتوقيت UTC.
- `error_reporting` في الإنتاج يستثني `E_DEPRECATED`، أي أن تحذيرات الإهمال مثل `Implicit conversion from float 5.5 to int loses precision` التي رأيناها في المقال الأول لن تصل إلى سجل الإنتاج أبداً. وهذه التحذيرات هي إنذارك المبكر بأن الإصدار القادم سوف يغير سلوكاً تعتمد عليه، لذلك يجب أن تراها في التطوير وفي CI، وأن يفشل CI عندما تظهر.
- `zend.exception_ignore_args` مفعل في الإنتاج، فلا يحفظ قيم المعاملات في الـ Stack Trace، والسبب أن هذه القيم قد تكون باسوردات أو توكنات تصل إلى السجل. ولهذا ترى في سجلات الإنتاج أسماء الدوال دون قيمها.
- `zend.assertions = -1` في الإنتاج يحذف استدعاءات `assert()` من الكود عند الترجمة، فلا تكلف شيئاً، لذلك لا تضع في `assert` أي تحقق يجب أن يعمل في الإنتاج مثل التحقق من صلاحيات المستخدم، وإنما فقط افتراضات تريد أن تراها تفشل أثناء التطوير.

وقد يتساءل البعض: لماذا لم تحذرنا PHP عندما أعادت `json_decode` القيمة `null`؟ والإجابة أن هذه الدالة لا تطبع تحذيراً أصلاً، فلو كان المتجر يكتب كل التحذيرات في السجل لما ظهر شيء أيضاً. ولهذا الحل الصحيح ليس رفع مستوى السجلات، وإنما أن يتحول كل فشل إلى استثناء يمر عبر معالج واحد يسجله ويرد على العميل بخطأ واضح، والتفاصيل الكاملة لكل إعداد في [توثيق إعدادات الأخطاء](https://www.php.net/manual/en/errorfunc.configuration.php?ref=arabroot.io).

## كيف تغير هذا كله من PHP 7 إلى 8.5؟

كل ما رأيته في هذا المقال جاء على مراحل، ومعرفة الإصدار الذي أضاف كل شيء تساعدك عندما تقرأ كوداً قديماً، أو عندما يكون مشروعك على إصدار أقدم من 8.5:

![خط زمني بسبعة إصدارات: 7.0 أنواع المعاملات ونوع الإرجاع وstrict_types وتحول الأخطاء القاتلة إلى Error، و7.4 الخصائص ذات النوع والدالة fn، و8.0 الأنواع المتحدة وmixed وmatch وValueError، و8.1 enum وreadonly والتقاطع وnever، و8.2 كلاس readonly وDNF، و8.4 الخطافات والرؤية غير المتماثلة، و8.5 العامل |> والنسخ مع التعديل وNoDiscard](https://arabroot.io/content/images/2026/10/php-types-memory-07-timeline.webp)

من 2015 إلى 2025 أضيفت قطعة من نظام الأنواع في كل إصدار تقريباً

والنتيجة العملية أن كوداً كتب لـ PHP 7.2 قد يكون صحيحاً تماماً ولكنه لا يستفيد من أي شيء في هذا المقال: خصائص بلا أنواع، وثوابت نصية بدل `enum`، و`switch` بدل `match`، وتحذيرات بدل استثناءات. والأداة التي تساعدك في الترقية هي [Rector](https://getrector.com/?ref=arabroot.io)، والتي تعيد كتابة الكود تلقائياً لصيغ الإصدار الأحدث، وأدلة الترقية الرسمية مثل [دليل الترقية إلى 8.5](https://www.php.net/manual/en/migration85.php?ref=arabroot.io) تذكر كل تغيير قد يغير سلوك كودك.

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

المشكلة الأولى هي رسالة `Allowed memory size exhausted` التي رأيتها في قسم حد الذاكرة. ففي الكتابة الأولى للتجربة وضعنا الوضعين، مع الجامع وبدونه، في نفس الملف ونفس العملية، فتوقف البرنامج كله عند الوضع الثاني ولم نحصل على أي رقم منه. والحل كان فصل كل وضع في عملية مستقلة `cycles.php on` و`cycles.php off`، ورفع الحد لوضع الإيقاف فقط من سطر الأوامر، وبقيت رسالة الحد الافتراضي في المخرجات كتجربة مستقلة. والدرس العام: عندما تقيس الذاكرة في PHP فشغل كل قياس في عملية جديدة، لأن العملية لا تعيد الذاكرة التي حجزتها إلى النظام بسهولة، والقياس الثاني يتأثر بالأول.

والمشكلة الثانية أطرف. كتبنا سطر جمع الدورات أول مرة كما يلي:

```php
echo '  gc_collect_cycles() = ', gc_collect_cycles(), "\n";
```

فظهر المخرج مختلطاً بهذا الشكل:

```text
  gc_collect_cycles() =   destruct demo-X
  destruct demo-Y
2
```

والسبب أن `echo` بعدة قيم مفصولة بفواصل يطبع كل قيمة بعد حسابها بالترتيب، فطبع النص الأول، ثم استدعى `gc_collect_cycles()` التي حررت الكائنين فطبع `__destruct` رسائله، ثم طبع الرقم 2\. وهذا يذكرك بأن `__destruct` يعمل في لحظة التحرير مهما كان ما يحدث حوله، فلا تضع فيه شيئاً يعتمد على ترتيب المخرجات أو على حالة خارجية، وفصلنا السطر إلى متغير ثم طباعة.

## ماذا يحدث إذا أوقفت جامع الدورات بالحد الافتراضي؟ What If

بعض المقالات القديمة تنصح بإيقاف جامع الدورات بـ `gc_disable()` أو `zend.enable_gc=0` لتسريع السكربتات الطويلة، وكان لهذا النصح سبب في PHP 5.3 حيث كان الجامع أبطأ بكثير. فماذا يحدث إذا طبقته على نفس التجربة دون أن ترفع حد الذاكرة؟

```bash
php cycles.php off; echo "exit=$?"
```

```text
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}
exit=255
```

والنتيجة أن السكربت الذي كان يكفيه 14 ميجابايت أصبح يحتاج أكثر من 128، ولم يكمل. ولم تظهر في قياسنا أي فائدة واضحة في الزمن تبرر ذلك، لذلك لا توقف الجامع إلا إذا قست بنفسك أن كودك لا يصنع دورات، وأن الزمن تحسن فعلاً على بياناتك.

## أخطاء شائعة Common Mistakes

- حساب المبالغ بـ `float` ثم `round`: رأيت أنه يخطئ في ربع حالات النصف هللة. احسب بالهللة `int`، أو بـ BCMath للنسب الدقيقة.
- التحقق من الأرقام بالتحويل `(int)`: لا يفشل أبداً، فاستخدم `filter_var` مع `FILTER_VALIDATE_INT`.
- `json_decode` بدون `JSON_THROW_ON_ERROR`: تعيد `null` بصمت ويمر الطلب فارغاً.
- تجاهل قيمة `false` من دوال الملفات والشبكة: سجل معالج أخطاء يحول التحذيرات إلى استثناءات.
- التقاط `Throwable` أو `Error` في منتصف الكود للاستمرار: هذا يخفي أخطاء برمجة يجب إصلاحها، والتقط الاستثناءات المحددة فقط.
- كتابة `@` قبل استدعاء دالة لإسكات التحذير: يخفي السبب ولا يصلحه.
- كاش `static` أو Singleton يكبر دون حد في Worker أو في Octane.
- الاعتماد على Generics في PHPDoc دون تشغيل PHPStan في CI: لا أحد يفحصها غيره.
- رفع `memory_limit` إلى `-1` في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر.

## جدول القرار Decision Table: أي أداة لأي حالة؟

| الحالة                      | استخدم                                                 | ولا تستخدم                      |
| --------------------------- | ------------------------------------------------------ | ------------------------------- |
| مبلغ مالي بالريال والهللة   | int بالهللة                                            | float مع round                  |
| نسبة أو سعر صرف بكسور طويلة | BCMath أو BcMath\\Number                               | float                           |
| رقم من مدخلات المستخدم      | filter\_var(..., FILTER\_VALIDATE\_INT)                | (int) $value                    |
| قيمة من مجموعة ثابتة        | enum مع tryFrom للمدخلات                               | ثوابت نصية                      |
| قائمة من نوع واحد           | list<T> في PHPDoc مع PHPStan                           | array بلا وصف                   |
| فشل متوقع في منطق العمل     | استثناء خاص يرث من DomainException أو RuntimeException | إعادة false                     |
| دالة تعيد نتيجة مهمة        | #\[\\NoDiscard\]                                       | تعليق يطلب من القارئ أن ينتبه   |
| كاش لبيانات عن كائنات       | WeakMap                                                | مصفوفة عادية                    |
| Worker طويل                 | حد للذاكرة وعدد المهام مع مشرف يعيد التشغيل            | إعادة تشغيل يدوية حين يشتكي أحد |

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

- عدل `money.php` ليستخدم `round($riyals * 0.15, 2, PHP_ROUND_HALF_EVEN)`، وعد الفروق مرة أخرى، ثم فكر: أي القاعدتين تطلبها الجهة الضريبية التي تتعامل معها؟
- غير `Node` في `cycles.php` ليحفظ المرجع العكسي في `WeakReference` بدل الكائن نفسه، وشغله مع إيقاف الجامع، وقارن الذاكرة.
- أضف إلى `worker.php` حداً لحجم الكاش يحذف أقدم مدخل عندما يتجاوز عشرة آلاف، وارسم الذاكرة كل خمسين ألف مهمة.

## الخلاصة

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

- PHP ديناميكي الأنواع ويفحص الأنواع المكتوبة على الدوال والخصائص في كل استدعاء، أما Generics وأشكال المصفوفات فيفحصها PHPStan فقط.
- الحساب بالريال العشري أخطأ في 1,276 ضريبة من مئة ألف سعر، كلها حالات نصف هللة، والحساب بالهللة الصحيحة لا يخطئ.
- الذاكرة يحررها عداد المراجع فوراً، والدورات يحررها جامع يعمل كل 10,000 جذر، وبدونه احتاجت التجربة 271 ميجابايت بدل 14 وتوقفت عند حد 128.
- الـ Worker لا يبدأ من الصفر مثل طلب الويب، وكل كاش ثابت فيه يكبر حتى يوقفه حد الذاكرة والمشرف.
- التحذير ليس استثناء، و`json_decode` تعيد `null` بصمت، فاستخدم `JSON_THROW_ON_ERROR` ومعالج أخطاء يحول التحذيرات إلى `ErrorException`.
- `Error` لأخطاء الكود و`Exception` للفشل المتوقع، و`finally` لتحرير الموارد، و`#[\NoDiscard]` في 8.5 للنتائج التي لا يجب تجاهلها.

وفي المقال التالي من المرحلة ننتقل إلى التزامن: كيف يخدم PHP طلبات كثيرة في نفس الوقت عبر عمليات PHP-FPM، وماذا تضيف Fibers والمكتبات غير المتزامنة، مع قياس لنفس المهمة بعدة طرق.

[![](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-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/php-idioms-cover-cover.webp)دليلأخطاء شائعة وأسلوب PHP الحديث Modern PHPثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.](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%81%D9%8A-php-%D9%88%D8%A7%D8%B3%D9%84%D9%88%D8%A8-php-%D8%A7%D9%84%D8%AD%D8%AF%D9%8A%D8%AB/)[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)[![](https://arabroot.io/content/images/2026/10/error-handling-cover-cover.webp)دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)

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

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