> ## 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: Laravel مع PostgreSQL والاختبارات
- URL: https://arabroot.io/articles/خدمة-حقيقية-ب-php-laravel-و-postgresql/
- Published: 2026-10-31T09:47:18.000Z
- Updated: 2026-10-31T11:09:56.000Z
- Description: بنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

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

وهذا المقال الرابع في مرحلة [لغة PHP للمطورين](https://arabroot.io/start/#stage-lang-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 13 مع PostgreSQL 17، ثم نكتب لها اختبارات وحدة وتكامل واختبار تزامن، ونبني لها Docker Image بمرحلتين، ونقيس حجمها وزمن بدئها وذاكرتها وأداءها. وكل الكود كامل في مجلد التجربة، وهنا نعرض الأجزاء المهمة ونشرح لماذا كتبت بهذا الشكل.

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

- مواصفات الخدمة المشتركة، ولماذا اخترنا Laravel، وكيف ننشئ المشروع.
- المخطط Schema كما هو في كل اللغات، والنماذج Models في Eloquent وما يحتاج تعديلاً فيها.
- التحقق من المدخلات Validation بـ Form Request، ولماذا غيرنا 422 إلى 400.
- المعاملة Transaction التي تقفل الصفوف وتتحقق من المخزون وتحسب الضريبة بالهللة، ولماذا نرتب الأقفال.
- الأخطاء والردود: استثناء يصبح 409، وردود JSON بلا غلاف وبنص عربي مقروء.
- الاختبارات: وحدة وتكامل على PostgreSQL حقيقي، واختبار يرسل 20 طلباً متزامناً على منتج مخزونه 10.
- تجربة «ماذا يحدث إذا» حذفنا القفل.
- الـ Dockerfile بمرحلتين مع FrankenPHP، ثم القياس، ومشكلة الاتصال الجديد بقاعدة البيانات في كل طلب.
- المشكلات التي ظهرت، وقائمة تحقق قبل الإنتاج.

## مواصفات الخدمة Spec: نفس العقد في كل لغة

الخدمة لها ثلاثة مسارات، وقواعدها محددة بحيث لا يختلف تنفيذها بين لغة وأخرى إلا في الأسلوب:

| المسار           | ما يفعله                                                                                                                                                                   | الردود                                           |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| GET /products    | قائمة المنتجات بأسمائها العربية وأسعارها بالهللة والمخزون                                                                                                                  | 200                                              |
| POST /orders     | جسم بالشكل {"items":\[{"product\_id":1,"qty":3}\]}، وفي معاملة واحدة: قفل الصفوف، والتحقق من المخزون، والإنقاص، والإدخال، والإجمالي مع ضريبة 15% بالهللة مقرباً نصف للأعلى | 201، و409 عند نقص المخزون، و400 عند مدخلات خاطئة |
| GET /orders/{id} | الطلب بأسطره                                                                                                                                                               | 200 أو 404                                       |

ومثال المسار الذي يجب أن يعطي نفس الرقم في كل لغة: ثلاث عبوات قهوة سعر الواحدة 9.90 ريال، أي 2970 هللة، وضريبتها الدقيقة 445.5 هللة تقرب إلى 446، والإجمالي 3416 هللة أي 34.16 ريالاً. وقد رأينا في المقال الثاني لماذا لا نحسب هذا بالأرقام العشرية: الحساب بالريال كـ `float` أخطأ في 1,276 سعراً من مئة ألف.

## لماذا Laravel؟ وكيف ننشئ المشروع

في PHP ثلاثة خيارات رئيسية لخدمة كهذه: Laravel وهو الإطار الأكثر استخداماً ويعطيك كل شيء جاهزاً بأسلوب «الاتفاق بدل الإعداد» Convention over Configuration، و[Symfony](https://symfony.com/?ref=arabroot.io) وهو الأقدم والأكثر صرامة في التصميم، وتبني عليه Laravel نفسها كثيراً من مكوناتها، وأطر صغيرة مثل Slim لمن يريد أن يختار كل قطعة بنفسه. واخترنا Laravel لأن احتمال أن تقابله في عملك هو الأكبر، ولأن المسار يطلب الإطار الأشهر في كل لغة. وأنشأنا المشروع بـ Composer داخل Container، ثم ثبتنا الاعتماديات في Volume منفصل للسبب الذي شرحناه في المقال الأول عن بطء المجلدات المربوطة على Windows:

```bash
composer create-project laravel/laravel /tmp/orders --no-install --no-scripts -q
composer install --no-progress
php artisan --version
```

```text
Laravel Framework 13.39.0
```

ولاحظ أن مشروع Laravel الجديد يطلب في `composer.json` القيد `"phpunit/phpunit": "^12.5.12"`، فثبت Composer الإصدار 12.5.42، بينما في المقال الأول ثبتنا PHPUnit مباشرة فحصلنا على 13.4.5\. والسبب أن الإطار يحدد الإصدارات التي اختبرها مع أدواته، ولا ينتقل إلى الإصدار الرئيسي الجديد من PHPUnit إلا بعد أن يتأكد أن أدوات الاختبار فيه تعمل معه، فلا ترفع هذا القيد بنفسك في مشروع Laravel قبل أن يرفعه الإطار. وLaravel نفسه يصدر إصداراً رئيسياً كل سنة تقريباً، كما في [سياسة الإصدارات](https://laravel.com/docs/13.x/releases?ref=arabroot.io)، وكل إصدار رئيسي قد يحمل تغييرات تحتاج تعديلاً في الكود، لذلك استخدم القيد `^13.0` ولا تثبت إصداراً فرعياً بعينه.

والمشروع الجديد فيه جداول للمستخدمين والكاش والطوابير لا نحتاجها، فحذفنا ملفات الترحيل Migrations الثلاثة الافتراضية، وجعلنا الجلسات والكاش في الذاكرة، والطوابير متزامنة، لأن الخدمة API بلا جلسات. والتنظيم الكامل للمجلدات موضح في [دليل هيكل المشروع](https://laravel.com/docs/13.x/structure?ref=arabroot.io)، والملفات التي كتبناها أو عدلناها هي فقط ما تراه في الشكل التالي:

![العميل يرسل الطلب إلى FrankenPHP الذي يشغل Caddy مع 4 خيوط PHP، ثم Laravel 13 بطبقاته: المسارات في routes/api.php، والتحقق في StoreOrderRequest الذي يرد 400، والمتحكم OrderController، والمعاملة في PlaceOrder التي ترد 409، والنماذج Eloquent، ثم PostgreSQL 17 بثلاثة جداول وقيود CHECK، وتحتها المسارات الثلاثة GET /products وPOST /orders وGET /orders/{id}](https://arabroot.io/content/images/2026/10/php-real-service-01-architecture.webp)

الخدمة كلها: خمسة ملفات في Laravel فوق قاعدة بيانات بثلاثة جداول

### كيف يمر الطلب داخل Laravel؟ دورة حياة الطلب Request Lifecycle

قبل أن نكتب الكود من المفيد أن تعرف ما يحدث بين وصول الطلب ووصوله إلى المتحكم، لأن هذا هو الجزء الذي لا تراه، وهو الذي يدفع ثمنه كل طلب في نموذج Share-Nothing. فكل طلب يدخل من ملف واحد هو `public/index.php`، والذي يحمل `vendor/autoload.php` ثم يبني التطبيق من `bootstrap/app.php`، فيسجل مزودي الخدمات Service Providers وكل ما يسجلونه في حاوية الخدمات Service Container، ثم يمر الطلب على سلسلة من الطبقات الوسيطة Middleware، مثل التحقق من حجم الجسم وتحويل النصوص الفارغة إلى `null`، ثم يبحث الموجه Router عن المسار المطابق، ثم يبني المتحكم ويعطيه ما يطلبه في معاملاته.

وهذه النقطة الأخيرة هي حقن الاعتماديات Dependency Injection في Laravel: الدالة `store` تطلب `StoreOrderRequest $request` و`PlaceOrder $placeOrder`، والإطار يقرأ هذه الأنواع بالـ Reflection، فيبني Form Request ويشغل التحقق عليه، ويبني `PlaceOrder` بكل ما يحتاجه، دون أن تكتب أي سطر لذلك. والأنواع التي تعلمناها في المقال الثاني ليست هنا للتوثيق فقط، بل هي ما يستخدمه الإطار ليعرف ماذا يبني. وبعد أن يعيد المتحكم الرد يمر الرد على نفس الطبقات بالعكس، ثم يرسل، ثم ينتهي كل شيء. ولهذا يفيد `php artisan optimize` كثيراً: فبدونه يقرأ Laravel في كل طلب عشرات ملفات الإعداد ويبني جدول المسارات من جديد، ومعه يقرأ ملفات PHP جاهزة يحفظها OPcache في الذاكرة.

### لماذا وضعنا المنطق في كلاس خدمة Service Class؟

في مشاريع Laravel ستجد ثلاثة أماكن للمنطق: المتحكم نفسه، أو النموذج، أو كلاس مستقل يسمى خدمة أو Action. والمتحكم الذي يحمل المعاملة والتحقق والحساب يعمل في البداية، ولكنه لا يمكن استدعاؤه من مكان آخر، مثل أمر Artisan يستورد طلبات من ملف، أو Job في طابور، ولا يمكن اختباره إلا عبر HTTP. والنموذج السمين Fat Model يضع المنطق في `Order` نفسه، وهذا مقبول للمنطق الذي يخص صفاً واحداً، ولكن إنشاء الطلب يلمس ثلاثة جداول ويقفل صفوفاً، فلا ينتمي إلى نموذج واحد. لذلك وضعناه في `PlaceOrder`، كلاس بدالة واحدة `handle` تستقبل بيانات تم التحقق منها وتعيد طلباً، ولا تعرف شيئاً عن HTTP. وهذا ما جعل اختبار التزامن ممكناً بسهولة: الأبناء استدعوا `PlaceOrder` مباشرة دون سيرفر، ونفس الكود عمل عبر HTTP في القياس. والقاعدة العملية: المتحكم يترجم بين HTTP والتطبيق فقط، والمنطق الذي يغير أكثر من صف يذهب إلى كلاس مستقل.

## المخطط Schema وملف الترحيل Migration

المخطط واحد في كل لغات المسار، لذلك كتبناه في ملف الترحيل كنص SQL كما هو، بدل أن نترجمه إلى دوال `Schema::create`، والسبب أن هدفنا أن تكون قاعدة البيانات متطابقة حرفاً بحرف في كل اللغات، بما فيها قيود `CHECK`:

```php
return new class extends Migration
{
    public function up(): void
    {
        DB::unprepared(<<<'SQL'
            CREATE TABLE products (
                id            serial PRIMARY KEY,
                name_ar       text NOT NULL,
                price_halalas int  NOT NULL CHECK (price_halalas >= 0),
                stock         int  NOT NULL CHECK (stock >= 0)
            );
            CREATE TABLE orders (
                id            serial PRIMARY KEY,
                created_at    timestamptz DEFAULT now(),
                total_halalas int NOT NULL
            );
            CREATE TABLE order_items (
                order_id      int REFERENCES orders (id),
                product_id    int REFERENCES products (id),
                qty           int CHECK (qty > 0),
                price_halalas int
            );
            SQL);
    }
};
```

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

- القيد `CHECK (stock >= 0)` هو خط الدفاع الأخير، فحتى لو أخطأ الكود في التحقق من المخزون لن تقبل قاعدة البيانات مخزوناً سالباً، وسوف نرى أثره بالضبط في تجربة «ماذا يحدث إذا». والتفاصيل في [توثيق القيود في PostgreSQL 17](https://www.postgresql.org/docs/17/ddl-constraints.html?ref=arabroot.io).
- `order_items` تحفظ `price_halalas` وقت الشراء، لأن سعر المنتج قد يتغير غداً، والفاتورة يجب أن تبقى كما كانت.
- الصيغة `<<<'SQL'` اسمها Nowdoc، وهي نص متعدد الأسطر لا يستبدل فيه PHP أي متغير، والمسافات قبل السطر الأخير تحذف من كل الأسطر تلقائياً منذ PHP 7.3.

والبيانات الأولية Seeder ثلاثة منتجات بأسماء عربية، والهيل مخزونه 10 لأننا سوف نستخدمه في اختبار التزامن:

```php
Product::insert([
    ['name_ar' => 'قهوة خولانية', 'price_halalas' => 990, 'stock' => 50],
    ['name_ar' => 'تمر سكري', 'price_halalas' => 4500, 'stock' => 30],
    ['name_ar' => 'هيل', 'price_halalas' => 1250, 'stock' => 10],
]);
```

## النماذج Models في Eloquent: ما الذي يفترضه Laravel؟

Eloquent هو الـ ORM في Laravel، ونمطه Active Record: كل كلاس يمثل جدولاً، وكل كائن يمثل صفاً ويستطيع أن يحفظ نفسه. وهو يفترض أشياء عن الجداول، مثل أن لكل جدول عمودين `created_at` و`updated_at`، وأن لكل جدول مفتاحاً أساسياً اسمه `id`، كما يشرح [توثيق Eloquent](https://laravel.com/docs/13.x/eloquent?ref=arabroot.io). ومخططنا المشترك لا يطابق هذه الافتراضات تماماً، لذلك نخبره بالفروق صراحة:

```php
class Order extends Model
{
    public const UPDATED_AT = null;     // the table has created_at only, filled by the database default

    protected $fillable = ['total_halalas'];

    protected function casts(): array
    {
        return ['total_halalas' => 'integer', 'created_at' => 'datetime'];
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}

class OrderItem extends Model
{
    public $timestamps = false;

    public $incrementing = false;

    protected $primaryKey = null;      // order_items has no id column in the shared schema
}
```

وأضفنا في `AppServiceProvider` سطراً يستحق أن يكون في كل مشروع Laravel جديد:

```php
// Fail loudly in development on lazy loading (N+1 queries) and on unknown attributes
Model::shouldBeStrict(! $this->app->isProduction());
```

والوضع الصارم في Eloquent يرمي استثناء عندما تحمل علاقة دون أن تطلبها مسبقاً، أي مشكلة N+1 الشهيرة التي تحول صفحة فيها مئة طلب إلى مئة استعلام، وعندما تكتب خاصية غير موجودة في `$fillable` فتضيع بصمت. وهذا هو نفس مبدأ المقال الثاني: الفشل الصامت أخطر من الفشل الصاخب، والتفاصيل في [صرامة Eloquent](https://laravel.com/docs/13.x/eloquent?ref=arabroot.io#configuring-eloquent-strictness).

## التحقق Validation بـ Form Request: لماذا 400 وليس 422؟

في Laravel تكتب قواعد التحقق في كلاس مستقل يرث `FormRequest`، ويستدعيه الإطار تلقائياً قبل أن يصل الطلب إلى المتحكم، فإذا فشل التحقق لا يصل الطلب إلى الكود ولا إلى قاعدة البيانات، كما في [توثيق Form Request](https://laravel.com/docs/13.x/validation?ref=arabroot.io#form-request-validation):

```php
class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'items' => ['required', 'array', 'list', 'min:1', 'max:50'],
            'items.*' => ['required', 'array:product_id,qty'],
            'items.*.product_id' => ['required', 'integer', 'distinct', 'exists:products,id'],
            'items.*.qty' => ['required', 'integer', 'min:1', 'max:1000'],
        ];
    }
}
```

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

- `list` ترفض كائناً JSON بمفاتيح بدل القائمة، وهي الحالة التي رأيناها في المقال الأول حيث تتحول المصفوفة في PHP إلى كائن أو قائمة حسب مفاتيحها.
- `array:product_id,qty` ترفض أي حقل غير هذين، فلا يستطيع العميل أن يرسل `price_halalas` ويأمل أن يستخدمه الكود.
- `distinct` ترفض نفس المنتج مرتين في طلب واحد، لأن السطرين سيتنافسان على نفس المخزون، والأبسط أن يجمعهما العميل.
- `exists:products,id` تتحقق من وجود المنتج، وهي تعمل استعلاماً على قاعدة البيانات خارج المعاملة، لذلك هي تحقق من المدخلات وليست بديلاً عن القفل.
- `integer` هنا أذكى من التحويل `(int)` الذي رأيناه في المقال الثاني، فالنص `"three"` يفشل بدل أن يصبح صفراً.

وLaravel يرد افتراضياً على فشل التحقق برمز 422 Unprocessable Content، ومواصفات المسار تطلب 400، فغيرنا ذلك في مكان واحد في `bootstrap/app.php`، وجعلنا كل الأخطاء ترجع JSON لأن الخدمة API فقط، وأزلنا البادئة `/api` من المسارات:

```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        apiPrefix: '',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(fn (Request $request) => true);   // an API only: every error is JSON
        // The shared spec answers bad input with 400 (Laravel's default for validation is 422)
        $exceptions->render(fn (ValidationException $e) => response()->json([
            'error' => 'invalid_input',
            'details' => $e->errors(),
        ], 400));
    })->create();
```

وقد يتساءل البعض: أيهما الصحيح، 400 أم 422؟ والإجابة أن كليهما مقبول في HTTP، و422 أدق عندما يكون JSON صالحاً ومحتواه خاطئاً، ولكن الاتساق مع العقد أهم من الدقة، والعقد هنا مشترك بين عشر لغات. والمسار `/up` يأتي مع Laravel كفحص صحة Health Check جاهز، وسوف نستخدمه في القياس.

## المعاملة Transaction: قفل ثم تحقق ثم كتابة

الآن نصل إلى قلب الخدمة. في المقال الثالث رأينا كيف باع المتجر أكثر مما في المخزون عندما قرأت عشرون عملية نفس القيمة ثم كتبت كل واحدة نتيجتها، والإصلاح هناك كان قفلاً على ملف. وفي قاعدة البيانات نفس الفكرة لها أداة جاهزة: `SELECT ... FOR UPDATE` داخل معاملة، والتي تقفل الصفوف التي قرأتها حتى نهاية المعاملة، فأي معاملة أخرى تريد نفس الصفوف تنتظر، كما يشرح [توثيق الأقفال في PostgreSQL 17](https://www.postgresql.org/docs/17/explicit-locking.html?ref=arabroot.io). وفي Laravel تكتبها `lockForUpdate()` كما في [القفل المتشائم Pessimistic Locking](https://laravel.com/docs/13.x/queries?ref=arabroot.io#pessimistic-locking):

```php
final class PlaceOrder
{
    /**
     * @param  list<array{product_id:int, qty:int}>  $items  validated, product ids distinct
     */
    public function handle(array $items): Order
    {
        return DB::transaction(function () use ($items): Order {
            $wanted = collect($items)->mapWithKeys(fn (array $i) => [(int) $i['product_id'] => (int) $i['qty']]);

            // Lock the rows in a fixed order (by id): two orders for the same products never wait on each other in a cycle.
            $products = Product::query()
                ->whereIn('id', $wanted->keys())
                ->orderBy('id')
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            $net = 0;
            foreach ($wanted as $productId => $qty) {
                $product = $products[$productId];
                if ($product->stock < $qty) {
                    throw new InsufficientStock($productId, $product->stock, $qty);
                }
                $net += $product->price_halalas * $qty;
            }

            $order = Order::create(['total_halalas' => Vat::total($net)]);

            foreach ($wanted as $productId => $qty) {
                $product = $products[$productId];
                $product->decrement('stock', $qty);
                OrderItem::create([
                    'order_id' => $order->id,
                    'product_id' => $productId,
                    'qty' => $qty,
                    'price_halalas' => $product->price_halalas,
                ]);
            }

            return $order->refresh()->load('items');
        });
    }
}
```

![ست خطوات: التحقق من الجسم قبل قاعدة البيانات، ثم BEGIN وSELECT مرتب حسب id مع FOR UPDATE، ثم التحقق من المخزون والتراجع عند النقص، ثم حساب المجموع بالهللة والضريبة 15%، ثم إدخال الطلب وإنقاص المخزون وإدخال الأسطر، ثم COMMIT، وتحتها الردود الثلاثة: 400 لا يصل إلى قاعدة البيانات، و409 لا يغير أي صف، و201 بمثال 2970 هللة وضريبة 446 وإجمالي 3416](https://arabroot.io/content/images/2026/10/php-real-service-02-transaction.webp)

معاملة واحدة إما أن تنجح كلها أو لا يتغير شيء

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

- `DB::transaction` يبدأ المعاملة ويعمل `COMMIT` إذا انتهت الدالة بشكل طبيعي، و`ROLLBACK` إذا خرج منها أي استثناء، ثم يرمي الاستثناء نفسه، كما في [توثيق المعاملات](https://laravel.com/docs/13.x/database?ref=arabroot.io#database-transactions). لذلك لا تحتاج `try` هنا، فرمي `InsufficientStock` كاف ليتراجع كل شيء.
- الترتيب `orderBy('id')` قبل القفل ليس للعرض. فلو طلب عميل القهوة ثم التمر، وطلب آخر التمر ثم القهوة، وقفل كل منهما الصف الأول ثم انتظر الثاني، لانتظر كل منهما الآخر إلى الأبد، وهذا هو الجمود Deadlock، وPostgreSQL يكتشفه ويلغي إحدى المعاملتين بخطأ. أما إذا قفلت كل المعاملات بنفس الترتيب فلا يمكن أن تتكون هذه الدائرة.
- كل الحسابات بالهللة كأرقام صحيحة، والضريبة من الدالة `Vat::total` نفسها التي كتبناها في المقال الأول، بالقسمة الصحيحة `intdiv($halalas * 150 + 500, 1000)`.
- `decrement('stock', $qty)` يكتب `UPDATE products SET stock = stock - ?`، أي أن الإنقاص نفسه يحدث في قاعدة البيانات وليس بقيمة قرأها PHP، وهذا مهم في تجربة «ماذا يحدث إذا».
- `refresh()` يقرأ الطلب من جديد ليأخذ `created_at` الذي وضعته قاعدة البيانات، لأن Eloquent لا يعرفه بعد الإدخال.

### بدائل القفل: التحديث الذري Atomic Update والقفل المتفائل Optimistic Locking

القفل المتشائم الذي استخدمناه ليس الطريقة الوحيدة، ومن المفيد أن تعرف البديلين قبل أن تختار. البديل الأول هو التحديث الذري المشروط: بدل أن تقرأ المخزون ثم تقرر، تكتب `UPDATE products SET stock = stock - 3 WHERE id = 1 AND stock >= 3`، ثم تنظر إلى عدد الصفوف التي تغيرت، فإذا كان صفراً فالمخزون لا يكفي. وهذه الطريقة أسرع لأنها استعلام واحد بلا انتظار طويل، ولكنها تصبح معقدة عندما يكون في الطلب عدة منتجات، لأنك تحتاج أن تتراجع عن الأسطر السابقة إذا فشل سطر لاحق، وتحتاج أن تقرأ السعر في نفس اللحظة. والبديل الثاني هو القفل المتفائل: تضيف عموداً للإصدار Version، وتقرأ الصف دون قفل، ثم تكتب بشرط أن الإصدار لم يتغير، وإذا تغير تعيد المحاولة. وهذا مناسب عندما يكون التنافس على نفس الصف نادراً، أما في موسم التخفيضات حيث يطلب مئات العملاء نفس المنتج في نفس الدقيقة فتكثر إعادة المحاولة. ولخدمة طلبات صغيرة بعدة أسطر، القفل المتشائم بترتيب ثابت هو الأوضح والأسهل في المراجعة، وهو ما اخترناه.

## الأخطاء والردود: من استثناء إلى 409

عندما لا يكفي المخزون نرمي استثناء خاصاً بالمتجر يحمل البيانات التي يحتاجها العميل، كما رأينا في المقال الثاني. وLaravel يتعامل مع أي استثناء فيه دالة `render` بأن يستدعيها ويرسل ما تعيده، كما في [توثيق الأخطاء](https://laravel.com/docs/13.x/errors?ref=arabroot.io):

```php
final class InsufficientStock extends RuntimeException
{
    public function __construct(
        public readonly int $productId,
        public readonly int $available,
        public readonly int $requested,
    ) {
        parent::__construct("product {$productId}: requested {$requested}, available {$available}");
    }

    /** Laravel calls render() on exceptions that define it: this one becomes a 409. */
    public function render(): JsonResponse
    {
        return response()->json([
            'error' => 'insufficient_stock',
            'product_id' => $this->productId,
            'available' => $this->available,
            'requested' => $this->requested,
        ], 409);
    }
}
```

والمتحكم بعد ذلك قصير جداً، لأن كل شيء آخر في مكانه: التحقق في Form Request، والمنطق في `PlaceOrder`، والأخطاء في الاستثناءات، والطلب غير الموجود في `GET /orders/{order}` يتحول إلى 404 تلقائياً لأن Laravel يربط المعامل بالنموذج Route Model Binding:

```php
class OrderController extends Controller
{
    public function products(): JsonResponse
    {
        return response()->json(
            Product::query()->orderBy('id')->get(['id', 'name_ar', 'price_halalas', 'stock']),
            options: JSON_UNESCAPED_UNICODE,      // Arabic names as text, not \u escapes
        );
    }

    public function store(StoreOrderRequest $request, PlaceOrder $placeOrder): JsonResponse
    {
        $order = $placeOrder->handle($request->validated('items'));

        return (new OrderResource($order))->response()->setStatusCode(201);
    }

    public function show(Order $order): OrderResource
    {
        return new OrderResource($order->load('items'));
    }
}
```

و`OrderResource` كلاس من نوع API Resource يحدد شكل JSON للطلب، فلا يخرج أي عمود لم تختره، والتفاصيل في [توثيق API Resources](https://laravel.com/docs/13.x/eloquent-resources?ref=arabroot.io). وهذه هي الردود من الخدمة وهي تعمل في Container:

```text
{"id":1,"created_at":"2026-10-31T10:01:44+00:00","total_halalas":3416,"items":[{"product_id":1,"qty":3,"price_halalas":990}]}
{"id":1,"created_at":"2026-10-31T10:01:44+00:00","total_halalas":3416,"items":[{"product_id":1,"qty":3,"price_halalas":990}]}
bad json -> 400
{"error":"insufficient_stock","product_id":3,"available":10,"requested":99}
```

والسطر الأول رد `POST /orders` بثلاث عبوات قهوة، والإجمالي 3416 كما حسبنا، والثاني نفس الطلب من `GET /orders/1`، والثالث جسم JSON مكسور، والرابع طلب 99 علبة هيل والمخزون 10.

## الاختبارات Tests: وحدة وتكامل وتزامن

كتبنا ثلاث طبقات من الاختبارات، وكلها تعمل بـ PHPUnit 12.5 الذي يأتي مع مشروع Laravel الجديد، وتشغلها بـ `php artisan test`:

![هرم من ثلاث طبقات: في الأسفل 6 اختبارات وحدة للضريبة بالهللة منها 2970 إلى 446 إلى 3416 ونصف الهللة 150 إلى 23، وفي الوسط 12 اختبار تكامل على PostgreSQL 17 منها 7 حالات 400 وJSON مكسور و404 و409 بلا تغيير، وفي الأعلى اختبار تزامن واحد بعشرين عملية نتيجته 10 نجاح و10 رد 409، وتحته شرح RefreshDatabase وDatabaseMigrations](https://arabroot.io/content/images/2026/10/php-real-service-04-tests.webp)

19 اختباراً في ثلاث طبقات، والطبقة العليا هي التي تكشف حالات السباق

### اختبار الوحدة Unit Test للضريبة

اختبار الوحدة لا يحتاج Laravel ولا قاعدة بيانات، ولذلك يرث `PHPUnit\Framework\TestCase` مباشرة، ويعمل في جزء من الثانية:

```php
public static function cases(): array
{
    return [
        '9.90 SAR x 3: VAT 4.455 -> 4.46, total 34.16' => [2970, 446, 3416],
        'zero' => [0, 0, 0],
        'one halala: 0.15 rounds down' => [1, 0, 1],
        'exact half rounds up: 1.50 SAR' => [150, 23, 173],
        'no rounding needed' => [1000, 150, 1150],
    ];
}

#[DataProvider('cases')]
public function test_vat_and_total_in_halalas(int $net, int $vat, int $total): void
{
    $this->assertSame($vat, Vat::of($net));
    $this->assertSame($total, Vat::total($net));
}
```

والحالة `1.50 SAR` هي نفس علبة الهيل التي أخطأ فيها الحساب بالأرقام العشرية في المقال الثاني، فضريبتها الدقيقة 22.5 هللة يجب أن تصبح 23.

### اختبارات التكامل Integration Tests على PostgreSQL حقيقي

اختبارات Laravel الافتراضية تعمل على SQLite في الذاكرة، وهذا سريع ولكنه لا يختبر ما نحتاجه: SQLite لا يدعم `FOR UPDATE`، وقيود `CHECK` وأنواع البيانات فيه تختلف. لذلك غيرنا `phpunit.xml` ليشير إلى قاعدة بيانات اختبار في PostgreSQL 17 يعمل في Container على نفس الشبكة:

```xml
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_HOST" value="pg"/>
<env name="DB_DATABASE" value="store_test"/>
<env name="DB_USERNAME" value="store"/>
<env name="DB_PASSWORD" value="store"/>
```

والاختبار المهم هنا هو حالة نقص المخزون، لأنه يتحقق من أن المعاملة تراجعت كلها، وليس فقط أن الرد 409:

```php
public function test_insufficient_stock_is_409_and_changes_nothing(): void
{
    $this->postJson('/orders', ['items' => [
        ['product_id' => 1, 'qty' => 1],
        ['product_id' => 3, 'qty' => 11],
    ]])->assertStatus(409)
        ->assertJson(['error' => 'insufficient_stock', 'product_id' => 3, 'available' => 10]);

    $this->assertSame(50, Product::find(1)->stock);      // the first line was rolled back too
    $this->assertSame(10, Product::find(3)->stock);
    $this->assertDatabaseCount('orders', 0);
}
```

والسطر الأول من الطلب، عبوة قهوة واحدة، صحيح تماماً، ولكن الطلب كله فشل بسبب السطر الثاني، والاختبار يتأكد أن مخزون القهوة لم ينقص. وهذه الاختبارات تستخدم `RefreshDatabase`، والتي تشغل الترحيل مرة واحدة ثم تلف كل اختبار بمعاملة تتراجع عنها في نهايته، كما في [توثيق اختبار قواعد البيانات](https://laravel.com/docs/13.x/database-testing?ref=arabroot.io)، فيبدأ كل اختبار من نفس البيانات.

### اختبار التزامن Concurrency Test: 20 طلباً على 10 علب

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

```php
DB::disconnect();        // children must not share the parent's socket
$pids = [];
for ($i = 0; $i < 20; $i++) {
    $pid = pcntl_fork();
    if ($pid === 0) {
        $result = 'error';
        try {
            DB::reconnect();
            app(PlaceOrder::class)->handle([['product_id' => $productId, 'qty' => 1]]);
            $result = 'ok';
        } catch (InsufficientStock) {
            $result = '409';
        } catch (\Throwable $e) {
            $result = 'error: '.$e->getMessage();
        }
        file_put_contents("{$dir}/{$i}", $result);
        posix_kill(getmypid(), SIGKILL);   // leave without running PHPUnit's shutdown code
    }
    $pids[] = $pid;
}
```

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

- `DB::disconnect()` قبل التفرع ضروري، لأن الابن يرث كل ما في ذاكرة الأب بما فيه الـ Socket المفتوح إلى PostgreSQL، وعشرون عملية تكتب على نفس الـ Socket تخلط البروتوكول، وهذا التحذير نفسه ذكرناه في المقال الثالث.
- الاختبار يستخدم `DatabaseMigrations` وليس `RefreshDatabase`، لأن المعاملة المفتوحة في الأب لا يراها الأبناء، فهم يتصلون بقاعدة البيانات باتصالات جديدة ولا يرون إلا ما تم `COMMIT` له.
- الابن يقتل نفسه بـ `SIGKILL` بدل `exit`، لأن `exit` يشغل دوال الإغلاق في PHPUnit وLaravel داخل الابن، فيطبع تقارير مكررة أو يغلق أشياء يملكها الأب. والنتيجة تعود عبر ملف لكل ابن.

وتشغيل الاختبارات كلها أعطى:

```text
results: {"409":10,"ok":10}

   PASS  Tests\Feature\ConcurrentOrdersTest
  ✓ twenty parallel orders for ten items sell exactly ten               21.65s

   PASS  Tests\Feature\OrdersApiTest
  ✓ products are listed in order                                         3.78s
  ✓ an order is placed with vat and stock is decremented                 6.50s
  ✓ insufficient stock is 409 and changes nothing                        3.23s
  ✓ bad input is 400 with data set "no items"                            1.27s
  ✓ bad input is 400 with data set "items missing"                       0.67s
  ✓ bad input is 400 with data set "qty zero"                            1.76s
  ✓ bad input is 400 with data set "qty as text"                         0.38s
  ✓ bad input is 400 with data set "unknown product"                     0.31s
  ✓ bad input is 400 with data set "same product twice"                  0.28s
  ✓ bad input is 400 with data set "extra field"                         0.93s
  ✓ malformed json is 400                                                0.50s
  ✓ missing order is 404                                                 0.81s

  Tests:    19 passed (47 assertions)
  Duration: 48.80s
```

والأزمنة الطويلة نسبياً، مثل 21 ثانية لاختبار التزامن، أغلبها ترحيل قاعدة البيانات من جديد وبدء عشرين عملية على جهاز كان مشغولاً، وفي تشغيل سابق على نفس الكود أخذ نفس الاختبار 9.45 ثانية. والرقم المهم هو السطر الأول: 10 نجحت و10 رفضت. وأرسلنا بعد ذلك نفس العشرين طلباً عبر HTTP حقيقي إلى الخدمة وهي تعمل في Container، مرتين من بيانات جديدة:

```text
20 parallel POST /orders in 2422 ms: {"201":10,"409":10}
stock left: 0
orders: 10
20 parallel POST /orders in 2209 ms: {"201":10,"409":10}
stock left: 0
orders: 10
```

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

## ماذا يحدث إذا حذفنا القفل lockForUpdate؟ What If

حذفنا السطر `->lockForUpdate()` وحده من `PlaceOrder`، وشغلنا اختبار التزامن مرة أخرى. وتوقعنا أن نرى بيعاً زائداً كما في تجربة الملف في المقال الثالث، ولكن النتيجة كانت مختلفة:

```text
results: {"error: SQLSTATE[23514]: Check violation: 7 ERROR:  new row for relation \"products\" violates check constraint \"products_stock_check\"\nDETAIL:  Failing row contains (3, هيل, 1250, -1). (Connection: pgsql, Host: pg, Port: 5432, Database: store_test, SQL: update \"products\" set \"stock\" = \"stock\" - 1 where \"id\" = 3)":10,"ok":10}
   FAIL  Tests\Feature\ConcurrentOrdersTest
  ⨯ twenty parallel orders for ten items sell exactly ten               10.34s
```

![مقارنة بين حالتين لعشرين طلباً على منتج مخزونه 10: مع lockForUpdate نجح 10 طلبات ورفض 10 برد 409 والمخزون صفر، في اختبار PHPUnit وفي عشرين طلب HTTP مرتين، وبدون القفل نجح 10 وفشل 10 بخطأ قاعدة بيانات لأن الإنقاص الحادي عشر أوصل المخزون إلى -1 فرفضه القيد CHECK، فلم يحدث بيع زائد ولكن العميل رأى خطأ خادم](https://arabroot.io/content/images/2026/10/php-real-service-03-locks.webp)

القيد CHECK منع البيع الزائد، والقفل هو الذي يجعل الرد صحيحاً

وهذا المخرج يستحق القراءة بعناية. بدون القفل قرأت المعاملات العشرون المخزون 10 في نفس الوقت تقريباً، ونجح تحقق كل واحدة منها، ولكن الإنقاص نفسه `stock = stock - 1` يحدث في قاعدة البيانات على القيمة الحالية، وليس على القيمة التي قرأها PHP، فنقص المخزون عشر مرات إلى الصفر، والإنقاص الحادي عشر حاول أن يجعله -1، فرفضه القيد `CHECK (stock >= 0)` بالخطأ `23514`، وتراجعت تلك المعاملة. أي أن قاعدة البيانات منعت البيع الزائد وحدها، وفي المقابل رأى عشرة عملاء خطأ خادم 500 بدل رد 409 مفهوم يقول إن المنتج نفد. فالقيد هنا شبكة الأمان الأخيرة، والقفل هو الذي يجعل منطق الكود صحيحاً. ولو كتبنا الإنقاص بالطريقة الخاطئة، أي `$product->stock = $product->stock - $qty; $product->save();`، لكتبت كل معاملة القيمة 9، ولضاعت تحديثات كما في تجربة الملف، ولما أوقف القيد شيئاً لأن 9 قيمة مقبولة.

وقد يخطر لك حل مختصر: نترك الكود بلا قفل، ونلتقط `QueryException` برمز `23514` ونحوله إلى 409\. وهذا يعمل في هذه الحالة البسيطة، ولكنه يربط منطق المتجر برقم خطأ في قاعدة بيانات بعينها، ويفشل عندما يكون في الطلب عدة أسطر لأنك لا تعرف أي منتج نفد إلا بتحليل نص الرسالة، ولا يمنع الحالات التي لا يغطيها قيد، مثل حد أقصى للكمية لكل عميل. لذلك اجعل القيد شبكة أمان لا يصل إليها الكود في الحالة العادية، واجعل القفل والتحقق في الكود هما ما يقرر الرد، فإذا ظهر خطأ القيد في السجلات يوماً فهو إنذار بأن في الكود خللاً يجب إصلاحه.

## الـ Dockerfile بمرحلتين Multi-stage مع FrankenPHP

لتشغيل الخدمة في الإنتاج عندك طريقتان شائعتان في PHP: Nginx مع PHP-FPM في Containerين كما في المقال الثالث، أو سيرفر واحد يجمعهما. واخترنا [FrankenPHP](https://frankenphp.dev/docs/laravel/?ref=arabroot.io)، وهو سيرفر Caddy مع PHP مدمج، لأنه يعمل في Container واحد، ويذكره [دليل النشر في Laravel](https://laravel.com/docs/13.x/deployment?ref=arabroot.io) كطريقة رسمية. والـ Dockerfile بمرحلتين، الأولى تثبت الاعتماديات، والثانية تحمل ما يحتاجه التشغيل فقط:

```dockerfile
# Stage 1: install the production dependencies with the same PHP version as the runtime
FROM php:8.5.12-cli-trixie AS vendor
RUN apt-get update && apt-get install -y --no-install-recommends git unzip && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative

# Stage 2: FrankenPHP (Caddy + PHP 8.5.12) with only what the app needs
FROM dunglas/frankenphp:1.13.4-php8.5.12-trixie
RUN install-php-extensions pdo_pgsql opcache \
 && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
WORKDIR /app
COPY --from=vendor /app /app
RUN chown -R www-data:www-data storage bootstrap/cache
ENV APP_ENV=production APP_DEBUG=false LOG_CHANNEL=stderr
EXPOSE 8000
CMD ["sh", "-c", "php artisan optimize && exec frankenphp php-server --root public --listen :8000"]
```

![مرحلتان: الأولى من php:8.5.12-cli-trixie فيها Composer وgit وunzip وتثبيت الاعتماديات بدون أدوات التطوير وخريطة كلاسات كاملة، ولا يصل منها إلى الإنتاج إلا الكود مع vendor، والثانية من dunglas/frankenphp فيها pdo_pgsql وOPcache وphp.ini-production وعند البدء php artisan optimize ثم frankenphp php-server، وتحتها شرح .dockerignore](https://arabroot.io/content/images/2026/10/php-real-service-06-dockerfile.webp)

ما تحتاجه للبناء يبقى في المرحلة الأولى، وما يصل إلى الإنتاج هو PHP والتطبيق فقط

في الـ Dockerfile أعلاه لاحظ التالي:

- نسخ `composer.json` و`composer.lock` وحدهما قبل الكود يعني أن طبقة `composer install` تبقى في كاش Docker ما دامت الاعتماديات لم تتغير، فتغيير سطر في متحكم لا يعيد تنزيل كل الحزم.
- المرحلة الأولى بنفس إصدار PHP في المرحلة الثانية، لأن Composer يتحقق من متطلبات الحزم مقابل PHP الذي يشغله، ولو استخدمنا صورة `composer:2` مباشرة لكان PHP فيها إصداراً آخر.
- `--classmap-authoritative` يجعل الـ Autoloader لا يبحث في الملفات أبداً، فالكلاس غير الموجود في الخريطة غير موجود، وهذا ما شرحناه في المقال الأول عن تحسين الـ Autoloader.
- `php.ini-production` يغلق عرض الأخطاء ويغلق `assert`، كما قارنا في المقال الثاني، وتذكر ما فعلته التأكيدات المفعلة بـ AMPHP في المقال الثالث.
- `php artisan optimize` يحفظ الإعدادات والمسارات والأحداث في ملفات كاش داخل `bootstrap/cache`، فلا يقرأ Laravel عشرات ملفات الإعداد في كل طلب. وشغلناه عند البدء وليس عند البناء لأنه يقرأ متغيرات البيئة مثل باسورد قاعدة البيانات، ولا نريدها داخل الـ Image.

## النشر Deployment: الترحيل والأسرار والسجلات

الـ Image وحدها لا تكفي لتشغيل الخدمة في الإنتاج، وهناك ثلاثة قرارات تحتاج أن تأخذها خارج الكود. الأول هو ترحيل قاعدة البيانات: في القياس شغلنا `php artisan migrate:fresh --seed --force` في Container مؤقت قبل تشغيل الخدمة، وهذا هو النمط الصحيح أيضاً في الإنتاج بدون `fresh` طبعاً: خطوة مستقلة في النشر تعمل مرة واحدة، وليس جزءاً من بدء كل Container، لأنك عندما تشغل ثلاث نسخ من الخدمة لا تريد ثلاثة ترحيلات تعمل في نفس الوقت على نفس الجداول. والخيار `--force` مطلوب لأن Laravel يرفض الترحيل في بيئة `production` دون تأكيد.

والثاني هو الأسرار: `APP_KEY` وباسورد قاعدة البيانات وصلت إلى الـ Container كمتغيرات بيئة في الأمر `docker run`، ولم تدخل الـ Image، والملف `.dockerignore` يمنع `.env` المحلي من الدخول إلى سياق البناء أصلاً. وفي الإنتاج تأتي هذه المتغيرات من مدير الأسرار في منصتك، ولا تكتب في الـ Dockerfile ولا في Git. والمفتاح `APP_KEY` يستخدمه Laravel لتشفير الكوكيز والبيانات المشفرة، فتغييره يجعل كل ما شفر قبله غير مقروء.

والثالث هو السجلات: جعلنا `LOG_CHANNEL=stderr`، فيكتب Laravel كل خطأ إلى المخرج القياسي للأخطاء، ويقرؤه `docker logs` وأي نظام سجلات يجمع مخرجات الـ Containers، بدل ملف داخل `storage/logs` يكبر داخل الـ Container ويختفي معه. وتذكر من المقال الثاني أن `php.ini-production` لا يسجل تحذيرات `E_DEPRECATED`، فهذه تراها فقط في CI وفي التطوير.

## القياس Measurements: الحجم والبدء والذاكرة والأداء

قسنا الـ Image وهي تعمل في Container محدود بمعالجين و512 ميجابايت، كما تطلب مواصفات المسار في كل اللغات، بسكربت `bench/measure.sh` في مجلد التجربة. وقبل الأرقام تحذير مهم: الجهاز كان يشغل في نفس الوقت تجارب أخرى في Containers كثيرة، وأثر ذلك ظاهر في كل رقم، فالأرقام للمقارنة داخل هذه التجربة وليست أداء Laravel على سيرفر مخصص. والمخرج المختصر من آخر تشغيل كان كما يلي:

```text
== image
slp-php-orders:1 929MB
PHP 8.5.12 (cli) (built: Oct 28 2026 01:23:40) (ZTS)
FrankenPHP v1.13.4 PHP 8.5.12 Caddy v2.11.7
Laravel Framework 13.39.0
== cold start: docker run until the first 200 on GET /products, 3 times
cold start 1: 20108 ms
cold start 2: 16415 ms
cold start 3: 10821 ms
== idle memory (10 s after start)
idle 58.31MiB / 512MiB cpu 0.00%
== single requests: /up (no database) and /products
up 0.277072s
up 0.177714s
up 0.153529s
products 0.265606s
products 0.206644s
products 0.332320s
new PDO connection to PostgreSQL, average of 5: 89.7 ms
== wrk -t2 -c50 -d15s GET /products, two runs
    Latency     1.77s   268.41ms   2.00s    91.44%
  Socket errors: connect 0, read 0, write 0, timeout 137
Requests/sec:     23.66
    Latency     1.51s   479.02ms   1.99s    82.93%
  Socket errors: connect 0, read 0, write 0, timeout 226
Requests/sec:     20.40
after load 67.04MiB / 512MiB
```

![أربع بطاقات: حجم الـ Image 929 ميجابايت منها 216 مضغوطة، والبدء من docker run إلى أول رد 200 بين 6.8 و20.1 ثانية بينما تشغيل Container فارغ وحده أخذ بين 10.4 و16.6 ثانية، والذاكرة في الخمول بين 54 و58 ميجابايت، وفتح اتصال PDO جديد بين 41 و90 مللي ثانية والاستعلام نفسه 0.8 مللي ثانية، وتحتها ثلاثة أشرطة لـ wrk: بدون اتصال دائم 47.0 و45.5 طلباً في الثانية في الجولة الأولى و23.7 و20.4 في الثانية، ومع الاتصال الدائم 56.0 و65.8](https://arabroot.io/content/images/2026/10/php-real-service-05-measure.webp)

الأرقام من جهاز مشغول، والفرق الواضح هو ثمن فتح اتصال جديد بقاعدة البيانات في كل طلب

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

- حجم الـ Image 929 ميجابايت على القرص، و216 ميجابايت هي الطبقات المضغوطة التي تنتقل عبر الشبكة. وأغلب الحجم من صورة FrankenPHP الأساسية بنظام Debian مع أدوات بناء الإضافات، وليس من التطبيق.
- زمن البدء بين 10.8 و20.1 ثانية في هذا التشغيل، وبين 6.8 و9.0 في تشغيل سابق محفوظ في `measure-output-earlier.txt`. ولكن تشغيل نفس الـ Image بالأمر `true` فقط، أي بلا أي عمل، أخذ بين 10.4 و16.6 ثانية على نفس الجهاز في نفس الوقت، كما في `docker-start-output.txt`. أي أن أغلب زمن البدء هنا هو Docker نفسه على جهاز مشغول، والجزء الخاص بالتطبيق هو `php artisan optimize` الذي طبعت مراحله أزمنة بين 13 و82 مللي ثانية لكل مرحلة.
- الذاكرة في الخمول 58 ميجابايت، و67 بعد الضغط، أي أقل بكثير من حد 512.
- والرقم الذي لفت انتباهنا هو الأداء: بين 20 و24 طلباً في الثانية فقط، وزمن الطلب الواحد على `/products` بين 200 و330 مللي ثانية، ومهلات Timeouts في wrk. وفي الجولة الأولى قبل أن يزيد انشغال الجهاز كان 47 و45.5 طلباً في الثانية، وهو أيضاً رقم منخفض لاستعلام بسيط على ثلاثة صفوف.

### كيف تقرأ مخرج wrk؟

الأداة [wrk](https://github.com/wg/wrk?ref=arabroot.io) تفتح عدداً ثابتاً من الاتصالات، 50 في قياسنا، موزعة على خيطين، وكل اتصال يرسل طلباً وينتظر رده ثم يرسل التالي فوراً طوال 15 ثانية. وهذا يعني أن الأداة لا ترسل عدداً ثابتاً من الطلبات في الثانية، وإنما تبقي 50 طلباً في الطريق دائماً، فإذا كان السيرفر يخدم 4 طلبات في نفس اللحظة لأن عنده 4 خيوط PHP، فالباقي ينتظر في الطابور، وزمن الانتظار يدخل في عمود `Latency`. ولهذا كان متوسط الزمن أكثر من ثانية بينما الطلب الواحد وحده بين 200 و330 مللي ثانية: الطلب يقضي أغلب وقته منتظراً دوره، كما رأينا في طابور FPM في المقال الثالث. والسطر `Socket errors: ... timeout 137` يعني أن 137 طلباً لم يصلها رد خلال المهلة الافتراضية لـ wrk وهي ثانيتان، فلم تحسب في `Requests/sec`، وهذا علامة على أن السيرفر تجاوز ما يستطيع. والقاعدة عند قراءة أي قياس من هذا النوع: انظر إلى الأخطاء والمهلات أولاً، ثم إلى الزمن، ثم إلى عدد الطلبات في الثانية، لأن رقماً جميلاً مع مهلات كثيرة لا معنى له.

ولاحظ أيضاً أن 50 اتصالاً على 4 خيوط تعني أن رفع عدد الخيوط كان سيرفع الأداء لو كان المعالج متاحاً، ولكن الـ Container محدود بمعالجين، وأغلب وقت كل طلب كان حساب المصادقة عند فتح الاتصال، وهو عمل على المعالج وليس انتظاراً، فخيوط أكثر لم تكن لتغير كثيراً. وهذا هو الفرق الذي رأيناه في المقال الثالث بين المهمة المقيدة بالشبكة والمهمة المقيدة بالمعالج، ظاهراً هذه المرة داخل خدمة حقيقية.

### أين يذهب الوقت؟ اتصال جديد بقاعدة البيانات في كل طلب

عندما رأينا هذا الرقم أول مرة قسنا أجزاء الطلب وحدها. المسار `/up` الذي لا يلمس قاعدة البيانات أخذ بين 36 و42 مللي ثانية في تلك الجولة، والاستعلام نفسه على اتصال مفتوح أخذ 0.8 مللي ثانية في المتوسط، أما فتح اتصال PDO جديد بـ PostgreSQL فأخذ 40.9 مللي ثانية في المتوسط، ثم 89.7 في الجولة الأخيرة. أي أن فتح الاتصال يأخذ وقتاً أكثر من الطلب كله بدونه. والسبب نموذج Share-Nothing الذي شرحناه في المقال الأول: كل طلب يبدأ من الصفر، فيفتح اتصالاً جديداً بقاعدة البيانات، ويمر بالمصادقة. وPostgreSQL 17 يستخدم افتراضياً المصادقة `scram-sha-256`، والتي تحسب دالة اشتقاق مفتاح بآلاف الدورات عن قصد حتى تكون مكلفة على المخترق، كما في [توثيق المصادقة بالباسورد](https://www.postgresql.org/docs/17/auth-password.html?ref=arabroot.io)، وهذا الحساب يحدث مع كل اتصال جديد، على معالجين مشغولين.

والحلول المعروفة لهذه المشكلة ثلاثة: الاتصال الدائم Persistent Connection في PDO، حيث يبقى الاتصال مفتوحاً في العملية أو الخيط بعد نهاية الطلب ويعاد استخدامه في الطلب التالي، كما في [توثيق اتصالات PDO](https://www.php.net/manual/en/pdo.connections.php?ref=arabroot.io). ومجمع الاتصالات Connection Pooler مثل PgBouncer بين التطبيق وقاعدة البيانات. ووضع Worker في FrankenPHP أو Octane الذي يبقي التطبيق كله في الذاكرة. وجربنا الأول لأنه سطر واحد، فأضفناه في `config/database.php` خلف متغير بيئة:

```php
'options' => extension_loaded('pdo_pgsql') ? [PDO::ATTR_PERSISTENT => (bool) env('DB_PERSISTENT', false)] : [],
```

```text
== what if: persistent connections (DB_PERSISTENT=true)
products 0.128695s
products 0.164818s
products 0.115598s
    Latency   866.89ms  172.41ms   1.46s    72.68%
Requests/sec:     56.03
    Latency   732.44ms  239.26ms   1.26s    63.54%
Requests/sec:     65.84
after load 68.08MiB / 512MiB
connections from the app: 5
```

وفي نفس الدقائق على نفس الجهاز المشغول ارتفع الأداء من حوالي 22 طلباً في الثانية إلى 56 و66، واختفت المهلات، والذاكرة تقريباً لم تتغير، وفي قاعدة البيانات 5 اتصالات مفتوحة من التطبيق، أي اتصال لكل خيط من خيوط FrankenPHP الأربعة تقريباً، مع اتصال أداة القياس. والاتصال الدائم ليس مفعلاً افتراضياً لسبب: الاتصال الذي يبقى بين الطلبات يحمل معه حالته، فلو انتهى طلب بخطأ في منتصف معاملة دون `ROLLBACK`، أو غير إعداد الجلسة مثل `search_path`، لورث الطلب التالي ذلك. وعدد الاتصالات المفتوحة يساوي عدد العمليات أو الخيوط في كل السيرفرات، فمع عشرة سيرفرات بخمسين ابناً لكل منها تحتاج قاعدة البيانات أن تتحمل 500 اتصال دائم. لذلك في نظام كبير يكون PgBouncer هو الحل الأنظف، وفي نظام صغير بسيرفر واحد الاتصال الدائم مقبول إذا كنت تعرف ثمنه.

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

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

المشكلة الأولى كانت في أول تشغيل للاختبارات: ثلاثة اختبارات فشلت بهذين الخطأين بعد أن انتظرنا PostgreSQL بالأمر `pg_isready`:

```text
SQLSTATE[08006] [7] connection to server at "pg" (10.253.53.2), port 5432 failed: Connection refused
SQLSTATE[08006] [7] connection to server at "pg" (10.253.53.2), port 5432 failed: FATAL:  the database system is starting up
```

والسبب أن صورة PostgreSQL الرسمية تبدأ سيرفراً مؤقتاً يستمع على Socket داخلي فقط لتشغيل سكربتات التهيئة، مثل السكربت الذي ينشئ قاعدة `store_test`، ثم توقفه وتبدأ السيرفر الحقيقي. و`pg_isready` أجاب بنعم من السيرفر المؤقت. والحل في `run-tests.sh` أن ننتظر حتى ينجح استعلام فعلي على قاعدة الاختبار نفسها عبر الشبكة، وليس مجرد رد من السيرفر.

المشكلة الثانية أن اختبار إنشاء الطلب فشل بالرسالة `Failed asserting that null is identical to 3416`، لأن API Resources في Laravel تغلف الرد افتراضياً داخل `{"data": {...}}`، والمواصفات المشتركة تطلب الطلب نفسه. والحل سطر واحد `JsonResource::withoutWrapping()` في `AppServiceProvider`. والدرس أن اختبار التكامل هو الذي يكشف هذا النوع من الفروق بين الإطار والعقد، وليس اختبار الوحدة.

والمشكلة الثالثة ظهرت في أول تشغيل للخدمة في Container، حيث رجعت أسماء المنتجات بهذا الشكل:

```text
[{"id":1,"name_ar":"قهوة خولانية","price_halalas":990,"stock":50},...]
```

وهذا JSON صحيح تماماً، وأي عميل يقرأه يحصل على «قهوة خولانية»، ولكنه أكبر حجماً بثلاث مرات تقريباً للنص العربي، وغير مقروء في السجلات وأدوات التصحيح. والسبب أن `json_encode` في PHP يحول كل حرف خارج ASCII إلى `\uXXXX` افتراضياً، والحل الخيار `JSON_UNESCAPED_UNICODE` الذي أضفناه في المتحكم، فأصبح الرد `"name_ar":"قهوة خولانية"` كما رأيت في القياس. وأي خدمة PHP تخدم عملاء عرباً تحتاج هذا الخيار في كل مكان تنتج فيه JSON.

والرابعة هي ثمن الاتصال بقاعدة البيانات الذي شرحناه في قسم القياس، وهي الأهم لأنها كانت ستمر دون أن نلاحظها لو لم نقس.

## قائمة تحقق Checklist قبل الإنتاج

- هل الاختبارات تعمل على نفس قاعدة البيانات التي في الإنتاج، وليس SQLite؟
- هل يوجد اختبار تزامن لكل عملية تعدل مورداً مشتركاً مثل المخزون أو الرصيد؟
- هل الأقفال تؤخذ بترتيب ثابت، وهل في قاعدة البيانات قيود CHECK تمنع الحالات المستحيلة؟
- هل `APP_DEBUG=false` و`php.ini-production` و`php artisan optimize` جزء من النشر؟
- هل `.env` وأي مفتاح خارج الـ Image، ويصل عبر متغيرات البيئة أو مدير الأسرار؟
- هل قست زمن فتح الاتصال بقاعدة البيانات، وقررت بين الاتصال الدائم وPgBouncer؟
- هل السجلات تذهب إلى `stderr` ليقرأها نظام السجلات؟
- هل يوجد فحص صحة مثل `/up` في إعداد الـ Container أو موزع الطلبات؟
- هل الترحيل يعمل كخطوة مستقلة في النشر، وليس عند بدء كل Container؟
- هل ردود JSON بالعربية تستخدم `JSON_UNESCAPED_UNICODE`؟

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

- شغل الخدمة بوضع Worker في FrankenPHP عبر Laravel Octane، وأعد قياس wrk، وابحث عن أي حالة مشتركة بين الطلبات في الكود.
- أضف PgBouncer بين الخدمة وPostgreSQL بوضع Transaction Pooling، وقارنه بالاتصال الدائم.
- غير `PlaceOrder` ليستخدم تحديثاً ذرياً `UPDATE ... WHERE stock >= ?` بدل القفل، وتأكد أن اختبار التزامن ما زال ينجح.

## الخلاصة

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

- الخدمة المشتركة في Laravel 13 خمسة ملفات أساسية: مسارات، وForm Request للتحقق، ومتحكم قصير، وخدمة `PlaceOrder` فيها المعاملة، واستثناء يصبح 409.
- المعاملة تقفل الصفوف بـ `lockForUpdate` مرتبة حسب `id`، وتحسب الضريبة بالهللة، فأعطت 3416 للمثال المشترك.
- 19 اختباراً نجحت، منها اختبار تكامل على PostgreSQL 17 حقيقي، واختبار تزامن بعشرين عملية نتيجته 10 نجاح و10 رد 409، وتكرر نفس الشيء عبر HTTP.
- بدون القفل منع القيد CHECK البيع الزائد، ولكن العملاء رأوا خطأ 500 بدل 409.
- الـ Image بمرحلتين مع FrankenPHP حجمها 929 ميجابايت، وذاكرتها 54 إلى 58 ميجابايت في الخمول، وزمن بدئها على الجهاز المشغول كان أغلبه Docker نفسه.
- فتح اتصال جديد بـ PostgreSQL في كل طلب كان أغلى من الطلب كله، والاتصال الدائم رفع الأداء من حوالي 22 إلى 56 و66 طلباً في الثانية على نفس الجهاز.

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

[![](https://arabroot.io/content/images/2026/10/php-for-devs-cover-cover.webp)دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.](https://arabroot.io/articles/php-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%B5%D9%8A%D8%BA%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/php-types-memory-cover-cover.webp)دليلالأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسةنحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%81%D9%8A-php-8/)[![](https://arabroot.io/content/images/2026/10/php-concurrency-cover-cover.webp)دليلالتزامن Concurrency في PHP: عمليات FPM والألياف Fibersقسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بعمليات PHP-FPM وFibers وAMPHP والتفرع والخيوط، ثم صنعنا حالة سباق على المخزون بين عمليات FPM وأصلحناها بالقفل.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-php-fpm-%D9%88-fibers-%D9%88-parallel/)[![](https://arabroot.io/content/images/2026/10/php-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/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/testing-types-cover-cover.webp)دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.](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/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بـ PHP 8.5.12 وLaravel 13.39.0 وPHPUnit 12.5.42 وFrankenPHP 1.13.4 وPostgreSQL 17.11 داخل Docker.