Khaled Ahmed
الرئيسية المدوّنة الخلفيه والمعماريه
الخلفيه والمعماريه

بناء نظام SaaS متعدد المستأجرين بـ Laravel: قاعدة واحدة أم قاعدة لكل عميل؟

Khaled Ahmed 17 min read

ابدأ بقاعدة بيانات واحدة وعمود tenant_id. أقولها بوضوح بعد سنوات من بناء وإصلاح أنظمة SaaS مبنية على Laravel: أغلب المؤسسين الذين يطلبون مني قاعدة بيانات منفصلة لكل عميل لا يحتاجونها في السنة الأولى، ومن يبنيها رغم ذلك ينفق ميزانيته على تكلفة تشغيلية بدل أن ينفقها على إيجاد سوق لمنتجه. أنا خالد أحمد، مطوّر Full Stack من القاهرة بخبرة تتجاوز خمس سنوات وأكثر من 39 مشروعاً منفّذاً. هذا هو القرار كاملاً، بالأرقام.

1. الخلاصة أولاً ثم التبرير

هناك ثلاث معماريات منطقية فقط لأي نظام SaaS على Laravel، لا رابع لها:

  1. قاعدة مشتركة وschema مشتركة. قاعدة واحدة، جداول واحدة، وكل صف يخص عميلاً يحمل مفتاح tenant_id. العزل تفرضه شيفرة التطبيق.
  2. قاعدة مشتركة وschema لكل عميل. قاعدة PostgreSQL واحدة مع schema مستقلة لكل مستأجر. العزل يفرضه الـ search_path.
  3. قاعدة بيانات لكل عميل. قاعدة فيزيائية منفصلة لكل مشترك. العزل يفرضه الاتصال نفسه.

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

الشروط الخمسة التي تبرّر تجاوز الخيار الافتراضي:

  • عقد ينص على ذلك صراحة. اتفاقية مؤسسية موقّعة أو كراسة شروط في مناقصة حكومية تشترط فصلاً فيزيائياً للبيانات. هذا واقع متكرر في المشتريات الحكومية والصحية في السعودية والإمارات.
  • موقع تخزين البيانات يختلف من عميل لآخر. بيانات عميل يجب أن تبقى داخل السعودية وآخر داخل ألمانيا. لا يمكن تحقيق ذلك في جدول مشترك.
  • استعادة نسخة احتياطية لعميل بعينه. عميل يحتاج إرجاع بياناته إلى حالة أمس الثالثة عصراً دون المساس بأحد. من جدول مشترك هذه عملية جراحية؛ من قاعدة منفصلة هي مجرد restore.
  • تفاوت حاد في الأحجام. عميل واحد يحتجز 60% من صفوف قاعدتك واستعلاماته تخنق الباقين.
  • اختلاف في البنية بين العملاء. عميل مؤسسي يحصل على أعمدة أو جداول خاصة به. نادر، وغالباً خطأ في تصميم المنتج، لكنه أحياناً نموذج العمل نفسه.

إن لم يتحقق أيٌّ من هذه الخمسة اليوم، ابنِ بقاعدة واحدة. الترحيل لاحقاً ممكن — وسأشرح مساره في القسم الحادي عشر — وتكلفته أقل بكثير من ثمانية عشر شهراً من العبء التشغيلي.

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

2. الكلفة الحقيقية لكل معمارية

قاعدة مشتركة وschema مشتركة

كل استعلام يحتاج where tenant_id = ?. في Laravel تفرض هذا عبر global scope حتى لا يعتمد الأمر على ذاكرة المطوّر. الترحيلات تُنفّذ مرة واحدة، والنسخ الاحتياطي مهمة واحدة، وإضافة عميل جديد مجرد INSERT — أي أقل من ثانية، وهذا وحده ما يجعل التسجيل الذاتي ممكناً بلا طابور تجهيز.

الثمن أن scope واحداً منسياً يعني تسرّب بيانات بين العملاء، وأن أكبر جدول لديك هو مجموع كل العملاء.

قاعدة مشتركة وschema لكل عميل

هذا هو الخيار الأوسط الذي يُهمَل في أغلب المقالات العربية والإنجليزية معاً. كل مستأجر يحصل على schema في PostgreSQL، وتبدّل بينها بـ SET search_path. تحصل على فصل حقيقي في الأسماء وعلى pg_dump لكل عميل على حدة دون دفع ثمن اتصال منفصل لكل قاعدة.

الثمن: الحل حصري لـ PostgreSQL، ومشكلة تشعّب الترحيلات فيه مطابقة تماماً لحل القاعدة المنفصلة، كما أن catalog قاعدة البيانات يثقل أسرع مما تتوقع. آلاف الـ schemas بأربعين جدولاً لكل منها تعني مئات آلاف الصفوف في الـ catalog، وتبدأ عمليات مثل النسخ الشامل بالتباطؤ.

قاعدة لكل عميل

أوضح نموذج ذهنياً، وأعلى فاتورة تشغيلياً. الترحيلات تُنفَّذ N مرة، والنسخ الاحتياطي N مهمة، والاستعادة لعميل واحد بسيطة، أما التقارير الشاملة عبر كل العملاء فتتحول من GROUP BY إلى مشروع هندسي قائم بذاته.

المعيارقاعدة مشتركةschema لكل عميلقاعدة لكل عميل
من يفرض العزلشيفرة التطبيقsearch_pathالاتصال وبيانات الدخول
حجم الضرر عند خطأ واحدبعض الصفوفجداول عميل واحدقاعدة عميل كاملة
زمن تجهيز عميل جديدأجزاء من الثانية1–10 ثوانٍ5–60 ثانية، غالباً في طابور
الترحيلات عند 1000 عميلتنفيذ واحد1000 تنفيذ1000 تنفيذ
استعادة نسخة لعميل بعينهصعبة وتحتاج أدوات خاصةمباشرةسهلة جداً
تقارير شاملة عبر العملاءاستعلام SQL بسيطصعبةصعبة وتحتاج data warehouse
الضغط على الاتصالاتمنخفضمنخفضمرتفع — راجع القسم 6
تحديد موقع البيانات لكل عميلغير ممكنغير ممكنممكن
ساعات DevOps شهرياً (تقديري)2–56–1210–30
الأنسب لـB2B بتسجيل ذاتي، أقل من 5000 عميلفرق تعمل على PostgreSQLالمؤسسات والقطاعات المنظَّمة

3. قاعدة واحدة أم قاعدة لكل عميل؟ إطار القرار

أجب عن الأسئلة الستة التالية بصدق، واحسب نقطة عن كل مرة يكون فيها الوصف في العمود الأيسر هو المنطبق على حالتك فعلاً.

السؤاليرجّح القاعدة المشتركةيرجّح قاعدة لكل عميل
كيف يشترك العملاء؟تسجيل ذاتي وبطاقة ائتمان فوراًعبر فريق مبيعات وعقد وجلسة تهيئة
كم عميلاً خلال 24 شهراً؟مئات إلى آلافعشرات إلى مئات قليلة
متوسط الإيراد لكل عميل؟أقل من 750 SAR أو 10,000 EGP شهرياًأكثر من 7,500 SAR شهرياً
هل يذكر أي عقد موقّع فصل البيانات؟لانعم
هل تحتاج تقارير تجمع كل العملاء داخل المنتج؟نعملا
هل لديك مسؤول DevOps أو ميزانية له؟لانعم

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

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

4. كيف أضمن عزل بيانات المستأجرين في Laravel؟

لا تضمنه بـ trait واحد. تضمنه بمبدأ «المنع افتراضياً» مع مجموعة اختبارات تحاول كسره عمداً. هذه قائمة مسارات التسريب كاملة، بالترتيب الذي أجدها به في الأكواد الحقيقية.

4.1 الـ global scope هو الأرضية لا السقف

في Laravel الحالي — وخاصية ScopedBy هي الأسلوب الموصى به منذ Laravel 10 ولا تزال كذلك في Laravel 13 الصادر في 17 مارس 2026 — يبدو الإعداد الأساسي هكذا:

<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('currentTenant')) {
            // امنع افتراضياً. لا تُرجع صفوفاً غير مقيّدة أبداً.
            $builder->whereRaw('1 = 0');
            return;
        }

        $builder->where(
            $model->qualifyColumn('tenant_id'),
            app('currentTenant')->id
        );
    }
}

انتبه إلى whereRaw('1 = 0'). أغلب الشروحات تكتب if (tenant()) { ... } فتُرجع كل شيء بصمت عند غياب المستأجر. هذا أكثر سبب مباشر لتسرّب البيانات بين العملاء أُستدعى لإصلاحه. امنع افتراضياً، ثم استثنِ صراحةً في الاستعلامات الإدارية المركزية القليلة التي تحتاج كل الصفوف فعلاً.

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([TenantScope::class])]
class Invoice extends Model
{
    protected static function booted(): void
    {
        static::creating(function (Invoice $invoice) {
            $invoice->tenant_id ??= app('currentTenant')->id;
        });
    }
}

4.2 المواضع السبعة التي لا يصلها الـ scope

  • الاستعلامات المباشرة. DB::table('invoices') وDB::select(...) تتجاوز Eloquent كلياً. وتوثيق حزمة stancl/tenancy يقول ذلك صراحةً عن وضع القاعدة الواحدة لديها: الحزمة تستطيع تقييد طبقة Eloquent فقط، ولا تستطيع فعل شيء مع الاستعلامات منخفضة المستوى. امنع الاستعلامات المباشرة خارج namespace واحد مخصص للتقارير وخاضع للمراجعة.
  • قواعد التحقق. Rule::unique('posts', 'slug') تفحص الجدول كله. فيحصل عميل يحاول استخدام رابط مختصر (slug) مستخدَم بالفعل عند عميل آخر على رسالة خطأ تكشف وجود ذلك العميل. اكتبها دائماً هكذا: Rule::unique('posts', 'slug')->where('tenant_id', tenant('id'))، ونفس الشيء مع exists.
  • الفهارس الفريدة. $table->unique('slug') قيد فريد على مستوى النظام كله. الصحيح $table->unique(['tenant_id', 'slug']). هذا قرار في البنية يصعب التراجع عنه لاحقاً، وهو أحد أسباب اعتباري تصميم قاعدة البيانات أعلى ساعة قيمةً في بناء أي SaaS.
  • ربط النماذج بالمسارات. Route::get('/invoices/{invoice}') يمرّ عبر النموذج فينطبق الـ scope — جيد. لكن بمجرد أن يكتب أحدهم Invoice::withoutGlobalScopes()->findOrFail($id) «لتصحيح شيء مؤقتاً» تكون قد فتحت ثغرة IDOR. ابحث عن withoutGlobalScope في كل مراجعة كود، بلا استثناء.
  • المهام في الطابور. المهمة التي تستخدم SerializesModels تخزّن مفتاح النموذج وتعيد جلبه في الـ worker، حيث لا يوجد طلب HTTP ولا سياق مستأجر. إما أن تمرّر معرّف المستأجر صراحةً داخل المهمة وتعيد ربطه في handle()، أو تستخدم حزمة تحقنه في حمولة المهمة نيابةً عنك.
  • مفاتيح الـ cache وRedis. Cache::remember('dashboard_stats', ...) بلا بادئة للمستأجر تُظهر أرقام عميل لعميل آخر. ضع بادئة لكل مفتاح.
  • الملفات والتصدير وملفات PDF. رفع الملفات إلى مجلد مشترك بأسماء يمكن تخمينها تسريب حتى لو كانت قاعدة البيانات مثالية. افصل مسار التخزين لكل عميل ولا تقدّم أي ملف عبر مسار قابل للتخمين. وهذا يتقاطع مباشرة مع قائمة فحص أمان الموقع.

4.3 الاختبار الذي يكشف المشكلة فعلاً

اكتب اختباراً واحداً، شغّله على كل نموذج يخص المستأجرين، واجعله جزءاً من CI:

it('لا يُرجع أبداً صفوف مستأجر آخر', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    app()->instance('currentTenant', $a);
    $mine = Invoice::factory()->count(3)->create();

    app()->instance('currentTenant', $b);
    expect(Invoice::count())->toBe(0);
    expect(Invoice::find($mine->first()->id))->toBeNull();
});

لاحظ أن الاختبار يربط المفتاح نفسه الذي يقرأه الـ scope في 4.1. شغّل الاختبار عبر الآلية التي تضبط سياق المستأجر في تطبيقك فعلاً: إن كنت تستخدم stancl/tenancy بدل الـ scope اليدوي أعلاه، استبدل السطرين بـ tenancy()->initialize($a) وtenancy()->initialize($b). أما اختبار يهيّئ المستأجر بطريقة ويقرأه الـ scope بطريقة أخرى فهو اختبار ينجح دون أن يثبت شيئاً، وهذا أسوأ من غياب الاختبار.

ثم أضف اختباراً سريعاً يطلب كل مسار في التطبيق بحساب العميل الثاني مستخدماً معرّفات العميل الأول، ويتأكد أن الرد 403 أو 404 ولا يكون 200 أبداً. اختبار ممل، يستغرق يوماً واحداً، وهو الفارق بين نظام يمكنك بيعه لبنك ونظام لا يمكنك.

4.4 إن أردت عزلاً تفرضه قاعدة البيانات نفسها

خاصية Row Level Security في PostgreSQL تمنحك طبقة حماية ثانية فوق الـ schema المشتركة. لكن هناك تحذيران ينص عليهما التوثيق الرسمي ويغفلهما أغلب من يكتب عن الموضوع: المستخدم superuser وأي دور يحمل صفة BYPASSRLS يتجاوز القيد دائماً، ومالك الجدول يتجاوزه أيضاً ما لم تنفّذ ALTER TABLE ... FORCE ROW LEVEL SECURITY. أي أن اتصال Laravel يجب أن يستخدم دوراً ليس مالكاً وليس superuser، وإلا فالحماية شكلية.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.tenant_id')::bigint);

ثم تنفّذ SET LOCAL app.tenant_id = '...' في بداية transaction كل طلب. وتحذير عملي مهم، وأغلب المقالات تقلبه رأساً على عقب: SET LOCAL مرتبط بالـ transaction وينتهي بانتهائها، ولهذا تحديداً فهو الآمن مع PgBouncer في وضع transaction pooling. الخطر الحقيقي هو SET العادية في وضع transaction pooling: يعود الاتصال إلى المجمّع وقيمة app.tenant_id ما زالت مضبوطة عليه، فيرثها أول مستأجر تُسلَّم له تلك المعاملة بعد ذلك. وPgBouncer لا ينفّذ DISCARD ALL بين المعاملات إلا إذا فعّلت server_reset_query_always صراحةً، فلا تفترض أن التنظيف يحدث نيابةً عنك. اختبر هذا تحت حِمل حقيقي لا على جهازك.

5. مقارنة الحزم: stancl/tenancy وspatie وبناء الحل يدوياً

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

stancl/tenancyspatie/laravel-multitenancyحل يدوي
آخر إصدار مستقرv3.10.1 في 5 أغسطس 2026v4.2.0 في 7 أغسطس 2026
PHP / LaravelPHP ^8.0؛ Laravel 10–13PHP ^8.2؛ Laravel 11–13حسب مشروعك
الفلسفةأوتوماتيكية وشاملةمحايدة عن قصدصريحة بالكامل
قاعدة لكل عميلدعم أساسي مع تجهيز تلقائيمدعوم، تكتب المهمة بنفسكتبنيه بنفسك
قاعدة واحدةعبر BelongsToTenantمدعوم، تكتب الـ scope بنفسكتبنيه بنفسك
وعي الـ cache والطابور والملفاتbootstrappers جاهزةtasks جاهزةتبنيه بنفسك
الأنسب حينتريد قاعدة لكل عميل تعمل بلا عناءتريد فهم كل جزء متحركقاعدة واحدة ونماذج قليلة

حزمة stancl/tenancy تقدّم خمسة bootstrappers تحل معظم ما ورد في القسم 4.2 نيابةً عنك: أحدها يبدّل الاتصال الافتراضي لقاعدة البيانات — وانتبه، الاتصال الافتراضي فقط، أما الاتصالات المسمّاة صراحةً فتبقى كما هي وهذا يفاجئ كثيرين؛ وثانٍ يضيف وسم المستأجر إلى مدخلات الـ cache؛ وثالث يجعل مسارات التخزين وواجهة Storage واعية بالمستأجر؛ ورابع يضمّن معرّف المستأجر في حمولة المهمة ويعيد تهيئة السياق عند تنفيذها؛ وخامس يغيّر بادئة Redis لكل مستأجر — وهذا الأخير يتطلب phpredis وليس Predis.

الإعداد قصير فعلاً:

composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate
# ثم سجّل TenancyServiceProvider في bootstrap/providers.php
# وانقل جداول المستأجرين إلى database/migrations/tenant/

أما spatie/laravel-multitenancy فتتبنى الموقف المعاكس: تحدد المستأجر الحالي وتترك لك تعريف ما يحدث عند تفعيله عبر «tasks». لن تفاجئك، ولن تؤدي عنك واجبك أيضاً. ألجأ إليها حين يكون فريق العميل هو من سيصون الكود وأريدهم أن يفهموا كل سطر.

والبناء اليدوي خيار وجيه تماماً لتعدد المستأجرين على قاعدة واحدة بأقل من خمسة عشر نموذجاً تقريباً: فئة scope، وtrait، وmiddleware يربط currentTenant، والاختبار الوارد في 4.3. نحو مئتي سطر بلا التزام بترقيات حزمة خارجية. نفّذت هذا أكثر مما نفّذت أياً من الحزمتين، وهو ما أوصي به عادةً داخل أول نسخة MVP لمنتج SaaS بـ Laravel وReact.

ملاحظة عن الإصدارات: Laravel 12 توقّف عن تلقّي إصلاحات الأخطاء في 13 أغسطس 2026، ولن يتلقى سوى الإصلاحات الأمنية حتى 24 فبراير 2027، وLaravel 13 الصادر في 17 مارس 2026 يتطلب PHP 8.3 كحد أدنى. إن كنت تبدأ مشروعاً متعدد المستأجرين الآن، ابدأ على 13 مباشرة — إضافة طبقة التعدد أثناء ترقية إطار العمل هي أسوأ توليفة ممكنة.

6. عند كم مستأجر تنهار القاعدة الواحدة؟

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

الحدود التقريبية التي أعمل عليها، على MySQL 8 أو PostgreSQL 16 فما فوق، مفهرسة جيداً وبذاكرة كافية:

حجم الجدول النشطما يحدثما تفعله
أقل من 10 مليون صفلا شيء. فهرس مركّب على (tenant_id, created_at) يكفي.تجاهل المشكلة.
10–100 مليونبطء في التجميعات وCOUNT(*) وتضخم الفهارس.فهارس مركّبة، عدّادات مخزّنة، نسخة قراءة للتقارير.
100–500 مليوننوافذ الصيانة تؤلم. إضافة عمود تستغرق ساعات أو تقفل الجدول.تقسيم الجدول حسب tenant_id، وأرشفة العملاء الخاملين.
أكثر من 500 مليونالنسخ والاستعادة تصبح القيد، لا الاستعلامات.توزيع العملاء على مجموعات خوادم، أو نقل الكبار إلى قواعد خاصة.

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

وأين تنهار «قاعدة لكل عميل»؟ هذا جدار صلب

الاتصالات. كل اسم قاعدة مختلف يعني اتصالاً منفصلاً يفتحه Laravel، ولا يوجد في PHP ولا في Laravel أي connection pool يوزّع هذه الاتصالات ويخفّف عبأها — وهذا بالضبط سبب حدّة المشكلة. ومع PHP-FPM فإن كل worker يلمس قاعدة مستأجر يحتجز اتصالاً مفتوحاً بها طوال عمره.

خدمة Amazon RDS تضبط max_connections الافتراضية لـ MySQL على {DBInstanceClassMemory/12582880} — أي تقريباً الذاكرة بالميغابايت مقسومة على 12 — ولـ PostgreSQL على LEAST({DBInstanceClassMemory/9531392}, 5000). عملياً، نسخة MySQL على db.t3.micro تحصل على نحو 60 اتصالاً فقط، وحتى فئة بذاكرة 8 GiB تقف عند 630 تقريباً. شغّل 40 عامل PHP-FPM على خادمَي تطبيق يخدمان مستأجرين مختلفين، وستستنفد ميزانية اتصالات خادم صغير بأقل من مئة عميل نشط. عندها تحتاج RDS Proxy أو PgBouncer، وهذا جزء متحرك إضافي ونقطة فشل إضافية وفاتورة إضافية.

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

الجدار الثالث: تشعّب الترحيلات. ترحيل يستغرق ثانيتين يستغرق 33 دقيقة عبر ألف عميل إن نُفّذ تسلسلياً، وعليك التعامل مع العملاء الذين يفشل عندهم في المنتصف. كل نشر يتحول إلى معاملة موزّعة تديرها يدوياً.

أرقامي الصريحة: نموذج «قاعدة لكل عميل» مريح حتى 200 إلى 500 مستأجر على خادم واحد. بعدها تبني مجموعات خوادم لكل شريحة عملاء، وتكون قد تحولت إلى شركة بنية تحتية. أما القاعدة المشتركة على عتاد جيد فتستوعب عشرات الآلاف من المستأجرين قبل أن تصبح أعداد الصفوف أعلاه هي القيد الفعلي.

7. الفاتورة التشغيلية التي لا يذكرها أحد في عرض السعر

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

  • التجهيز. إنشاء القاعدة وتشغيل الترحيلات وبذر البيانات الأولية ومعالجة الفشل في المنتصف. يجب أن يكون هذا مهمة في طابور بمحاولات إعادة وحالة ظاهرة في لوحة الإدارة، لا طلب HTTP مباشراً.
  • إلغاء التجهيز. حذف قاعدة العميل عند الإلغاء بعد فترة احتفاظ، مع تصدير نهائي. الخطأ هنا مشكلة قانونية بموجب GDPR ونظام حماية البيانات السعودي، لا مجرد ترتيب داخلي.
  • تنسيق الترحيلات. تشغيل الترحيلات عبر N قاعدة على دفعات متوازية مع تتبّع نجاح كل عميل وإمكانية الاستئناف.
  • النسخ الاحتياطي. N مهمة نسخ وN اختبار استعادة. النسخة التي لم تُختبر استعادتها ليست نسخة احتياطية.
  • المراقبة. رؤية الاستعلامات البطيئة لكل عميل، ومساحة القرص لكل عميل، وتنبيهات قبل الامتلاء.
  • التقارير الشاملة. مؤشراتك أنت — الإيراد المتكرر ونسبة التسرب واستخدام المزايا — صارت تحتاج ETL إلى مستودع بيانات.
  • أدوات الدعم. انتحال هوية العميل للدعم، ووصول للقراءة فقط، وسجل تدقيق لمن اطّلع على ماذا.

هذه القائمة وحدها 60 إلى 120 ساعة عمل قبل كتابة أول ميزة في المنتج. وهي أيضاً سبب حرصي على اختيار الاستضافة بعناية في هذه المشاريع — خطة مشتركة بـ 12 دولاراً شهرياً لا تستضيف هذه المعمارية، واكتشاف ذلك في الشهر الرابع مكلف.

8. كيف أتعامل مع الأدوار والصلاحيات المقيّدة بكل مستأجر؟

هذا هو السؤال الذي يوقع الفرق التي أتقنت طبقة البيانات.

إن كنت على قاعدة لكل عميل، فالأمر شبه مجاني: حزمة spatie/laravel-permission تعمل داخل قاعدة كل مستأجر وتكون الأدوار معزولة طبيعياً. انتبه فقط لذاكرة الصلاحيات المؤقتة، إذ يجب فصل مفاتيحها لكل مستأجر وإلا قدّمت خريطة صلاحيات عميل لعميل آخر.

أما على قاعدة مشتركة فاستخدم خاصية teams في الحزمة، واقرأ هذه التحذيرات قبل أي ترحيل:

  • يجب ضبط 'teams' => true في config/permission.php قبل الترحيل الأول. تفعيلها لاحقاً يعني تعديل بنية يدوياً وتعبئة بيانات رجعية. احسم هذا في اليوم الأول.
  • تحدد الفريق النشط بـ setPermissionsTeamId($tenantId) داخل middleware، ويجب أن يعمل هذا الـ middleware قبل SubstituteBindings في Laravel، وإلا فشل ربط النموذج أولاً وحصل المستخدم على 404 في موضع كان الصحيح فيه 403.
  • عند تبديل سياق الفريق داخل الطلب الواحد — مدير يستعرض عميلين، أو مهمة تمر على كل العملاء — عليك مسح العلاقات المخزّنة بنفسك: $user->unsetRelation('roles')->unsetRelation('permissions'). إن أهملت ذلك أعاد الفحص الثاني إجابة العميل الأول بصمت.
  • الأدوار المنشأة بـ team_id => null أدوار عامة. استخدمها لفريقك الداخلي لا لأدوار العملاء.
  • إن كنت تستخدم Livewire فسجّل الـ middleware كـ persistent وإلا فُقد السياق عند تحديث المكوّن.

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

9. تحديد المستأجر: نطاق فرعي أم نطاق مخصص أم مسار؟

ثلاثة خيارات، وللاختيار أثر أمني مباشر.

الطريقةمثالالميزةالانتباه إلى
نطاق فرعيacme.yourapp.comنظيف، وعزل الـ cookies ممكنيحتاج DNS وشهادة TLS بنمط wildcard
نطاق مخصصportal.acme.comعلامة بيضاء ومناسب للمؤسساتأتمتة شهادة لكل نطاق، عمل تشغيلي حقيقي
بادئة مسارyourapp.com/acmeبلا أي إعداد DNS، الأبسطنطاق cookies مشترك — خطر تثبيت الجلسة بين العملاء
ترويسة أو مطالبة داخل الـ tokenواجهات API فقطالخيار الصحيح لواجهة برمجية بحتةيجب التحقق من المستأجر مقابل الـ token، لا الوثوق بالترويسة

أكثر خطأ أراه: أخذ معرّف المستأجر من ترويسة أو حقل مخفي والوثوق به. يجب اشتقاق المستأجر من الهوية الموثّقة — من مطالبة الـ token أو من عضوية الجلسة — والتحقق المتقاطع. إن استطاع العميل أن يرسل لك معرّف مستأجر فتصدّقه، فأنت لا تملك تعدد مستأجرين، بل تملك مجرد query parameter. هذه أساسيات في تصميم واجهات API ويجري تجاوزها باستمرار.

ونقطة أخيرة: مع النطاقات الفرعية اضبط نطاق cookie الجلسة صراحةً. أي cookie محدد بـ .yourapp.com يُشارَك بين كل نطاقات العملاء الفرعية، وهو بالضبط ما كنت تحاول تجنّبه.

10. كم يضيف تعدد المستأجرين إلى تكلفة بناء المنتج؟

هذه نطاقات من تسعيري الشخصي، لا قائمة أسعار ثابتة، وتفترض تنفيذاً بمطوّر واحد كفء أو فريق صغير مع تسعير مزايا المنتج نفسها بشكل منفصل. ما أسعّره هنا هو طبقة تعدد المستأجرين فقط: التسجيل، التجهيز، العزل، لوحة الإدارة، الاختبارات. الأرقام مقرّبة، والدولار هو المرجع، والعملات المحلية تقريبية بأسعار أغسطس 2026. عمود الريال محسوب على سعر الربط 3.75، وإن كنت تسعّر بالدرهم الإماراتي فالأرقام نفسها أعلى بنحو 2% لأن الدرهم مربوط عند 3.6725 تقريباً.

النطاقالجهدUSDEGP تقريباًSAR تقريباً
قاعدة واحدة، تنفيذ يدوي، حتى 15 نموذجاً25–45 ساعة1,000 – 2,20050 – 110 ألف3.8 – 8.3 ألف
قاعدة واحدة + أدوار teams + مجموعة اختبارات عزل50–80 ساعة2,000 – 4,000100 – 200 ألف7.5 – 15 ألف
قاعدة لكل عميل بـ stancl/tenancy مع تجهيز في طابور وتنسيق ترحيلات90–150 ساعة3,600 – 7,500180 – 375 ألف13.5 – 28 ألف
إضافة نطاقات مخصصة وشهادات TLS آلية لكل عميل+20–40 ساعة+800 – 2,000+40 – 100 ألف+3 – 7.5 ألف
إضافة الاشتراكات وحدود الباقات وقياس الاستهلاك+40–70 ساعة+1,600 – 3,500+80 – 175 ألف+6 – 13 ألف
تحويل تطبيق أحادي المستأجر قائم إلى متعدد المستأجرين80–200+ ساعة3,200 – 10,000+160 – 500 ألف+12 – 37 ألف+

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

وإن كنت لا تزال تشكّل صورة الميزانية الكاملة، فمقالي عن تكلفة المواقع في مصر والخليج يغطي بقية البنود، وإن كانت المدفوعات ضمن النطاق لإطلاق خليجي فإن ربط بوابات الدفع الخليجية موضوع قائم بذاته.

11. أن تبدأ بقاعدة واحدة وتنتقل لاحقاً: المسار العملي

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

صمّم للانتقال من اليوم الأول، بتكلفة تكاد تكون صفراً:

  1. امنح المستأجرين مفتاح UUID أو ULID لا رقماً تسلسلياً. عند استخراج مستأجر إلى قاعدته الخاصة، توفّر عليك المفاتيح غير المتصادمة مشروع إعادة ترقيم كامل.
  2. ضع tenant_id في كل جدول يخص المستأجرين، حتى حيث تكفي علاقة الأب. نعم هذا تكرار مقصود، لكنه يعني استخراج صفوف عميل بشرط واحد لكل جدول بدل شبكة من الـ joins.
  3. لا تُشِر أبداً بمفتاح خارجي إلى صف مستأجر آخر. بديهي، ويُنتهك باستمرار عبر جداول مرجعية مشتركة تنمو لاحقاً بصفوف خاصة بعملاء.
  4. افصل البيانات المركزية — المستأجرون والمستخدمون والاشتراكات والباقات — عن بيانات المستأجرين بوضوح. ارسم الخط في البنية قبل أن تحتاجه.
  5. افصل مسارات التخزين ومفاتيح الـ cache لكل مستأجر من أول commit. إضافة ذلك لاحقاً تعني ترحيل ملفات، وهو أسوأ بكثير من ترحيل صفوف.

بوجود هذه الخمسة، يصبح استخراج مستأجر كالتالي: أنشئ القاعدة الجديدة، شغّل الترحيلات، انسخ صفوف ذلك العميل جدولاً جدولاً بشرط WHERE tenant_id = ?، احذف العمود، بدّل حقل database_name في سجل المستأجر، تحقق، ثم احذف الصفوف الأصلية بعد فترة احتفاظ. لعميل متوسط الحجم هذه مهمة مكتوبة بـ script تُقاس بالدقائق مع نافذة صيانة قصيرة لذلك العميل وحده. لن يلاحظ أحد غيره.

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

12. قائمة التنفيذ التي أتبعها في أي SaaS متعدد المستأجرين

  • نموذج Tenant بمفتاح ULID، وحقل slug، وحقل status، وحقل database_name فارغ محجوز للاستخراج المستقبلي.
  • جدول عضوية يربط المستخدم بالمستأجر بالدور. ولا يوجد tenant_id في جدول المستخدمين إطلاقاً.
  • middleware يحدد المستأجر من النطاق الفرعي، ويتحقق من عضوية المستخدم الموثّق، ويربط currentTenant، ويعمل قبل SubstituteBindings.
  • trait باسم BelongsToTenant يطبّق global scope يمنع افتراضياً ويملأ tenant_id تلقائياً عند الإنشاء.
  • فهارس فريدة مركّبة في كل مكان، وفهرس (tenant_id, created_at) على كل جدول تُرتّبه أو تُصفّحه.
  • اختبار في CI يمرّ على كل نماذج المستأجرين ويتأكد من انعدام الرؤية المتقاطعة، مع اختبار IDOR على مستوى المسارات.
  • فحص ثابت — ولو مجرد grep داخل CI — يُفشل البناء عند وجود DB::table( أو withoutGlobalScope خارج namespace مسموح به.
  • مفاتيح الـ cache وبادئات Redis وأقراص التخزين ومهام الطابور كلها واعية بالمستأجر، مع اختبارات تثبت ذلك.
  • انتحال هوية العميل خلف صلاحية محددة، مسجَّل في جدول تدقيق غير قابل للتعديل مع سبب الدخول.
  • حذف ناعم وتصدير كامل لكل مستأجر، لأن طلبات الحذف ستأتي حتماً.
  • لوحة إدارة مركزية على نطاق منفصل بمصادقة خاصة، حتى لا يصل اختراق نطاق فرعي لعميل إليها.

13. خمسة أخطاء أُستدعى لإصلاحها باستمرار

  1. الـ scope المتساهل. يُرجع كل شيء عند غياب المستأجر. يمر في الاختبارات، ويتسرّب أول مرة تلمس فيها مهمة في الطابور أو أمر artisan ذلك النموذج.
  2. وضع tenant_id في جدول المستخدمين. يعمل حتى يحتاج شخص واحد الوصول إلى مستأجرين — مستشار، محاسب، وكالة، شركة أم. عندها تعيد كتابة طبقة المصادقة كلها.
  3. اختيار قاعدة لكل عميل لمنتج بتسجيل ذاتي. التسجيل يستغرق 40 ثانية، والتجهيز يفشل بصمت في نسبة صغيرة من الحالات ولا يلاحظ أحد، وخط النشر يحتوي خطوة بلا سقف زمني.
  4. غياب الفهارس الفريدة المركّبة. يُكتشف حين يعجز العميل الثاني عن تسمية مشروعه الأول باسم استخدمه العميل الأول.
  5. تقارير شاملة مضافة فوق نموذج القاعدة لكل عميل. يكتب أحدهم حلقة تفتح 400 اتصال لبناء لوحة إدارة، فتسقط قاعدة البيانات في التاسعة صباح الإثنين.

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

14. خلاصتي

ابنِ تعدد مستأجرين على قاعدة واحدة وschema مشتركة، مع global scope يمنع افتراضياً، وفهارس فريدة مركّبة، وجدول عضوية، ومجموعة اختبارات عزل. صمّم مسار الاستخراج من اليوم الأول. وانقل العملاء فرادى إلى قواعد خاصة حين يفرض ذلك عقد أو قاعدة تخزين بيانات محلية — واحسب ثمنه على العميل، فالعزل المؤسسي ميزة تُباع لا خدمة تُدعم من جيبك.

واختر إطار العمل بالمنطق نفسه الذي تختار به أي قرار في الواجهة الخلفية؛ مقارنتي بين Laravel وNode.js في 2026 تغطي ذلك، ولمنتجات B2B متعددة المستأجرين ذات بيانات علائقية كثيفة وشاشات إدارية، فإن Eloquent وأدوات الترحيل ونظام الطوابير في Laravel تجعله المسار الأقصر.

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

أسئله شائعه

هل أستخدم قاعدة بيانات واحدة أم قاعدة لكل مستأجر في Laravel؟
ابدأ بقاعدة واحدة وعمود tenant_id إلا إذا فرض العكس عقد موقّع، أو اشتراط تخزين بيانات كل عميل في دولة محددة، أو حاجة لاستعادة نسخة لعميل بعينه، أو تفاوت حاد في الأحجام، أو اختلاف في بنية الجداول. القاعدة الواحدة تُجهّز في أجزاء من الثانية وتُرحَّل مرة واحدة، ويمكن استخراج عميل منها لاحقاً في أيام لا شهور.
كيف أضمن عزل بيانات المستأجرين في Laravel؟
استخدم global scope يمنع افتراضياً ويُرجع صفراً من الصفوف عند غياب المستأجر، لا واحداً يُرجع كل شيء. ثم أغلق الثغرات السبع التي لا يصلها: الاستعلامات المباشرة، وقواعد unique وexists، والفهارس الفريدة، واستدعاءات withoutGlobalScope، والمهام في الطابور، ومفاتيح الـ cache، ومسارات الملفات. وأضف اختباراً في CI يثبت انعدام الرؤية المتقاطعة.
عند كم مستأجر ينهار نموذج القاعدة الواحدة؟
لا ينهار عند عدد مستأجرين بل عند عدد الصفوف وتفاوت الأحجام. تحت عشرة ملايين صف في جدولك الأنشط لا يحدث شيء. بين مئة وخمسمئة مليون تبدأ تعديلات البنية ونوافذ الصيانة في الإيلام. القاعدة المشتركة تستوعب عشرات الآلاف من المستأجرين، بينما نموذج القاعدة لكل عميل يصطدم بحدود الاتصالات وواصفات الملفات عند 200 إلى 500 عميل لكل خادم.
كم يضيف تعدد المستأجرين إلى تكلفة بناء المنتج؟
في تسعيري، تعدد المستأجرين على قاعدة واحدة بتنفيذ يدوي يضيف نحو 25 إلى 45 ساعة، أي 1,000 إلى 2,200 دولار. أما نموذج القاعدة لكل عميل بتجهيز في طابور وتنسيق ترحيلات فيتراوح بين 90 و150 ساعة، أي 3,600 إلى 7,500 دولار، إضافة إلى 80 إلى 400 دولار شهرياً في البنية التحتية قبل أول عميل يدفع.
كيف أتعامل مع الأدوار والصلاحيات المقيّدة بكل مستأجر؟
على قاعدة مشتركة استخدم spatie/laravel-permission مع تفعيل teams، لكن فعّلها قبل الترحيل الأول لأن إضافتها لاحقاً تتطلب تعديل بنية يدوياً. استدعِ setPermissionsTeamId داخل middleware يعمل قبل SubstituteBindings، وعند تبديل المستأجر داخل الطلب نفسه استدعِ unsetRelation على roles وpermissions وإلا حصلت على إجابة المستأجر السابق.
أيهما أفضل: stancl/tenancy أم spatie/laravel-multitenancy؟
حتى أغسطس 2026، حزمة stancl/tenancy بإصدار v3.10.1 تدعم Laravel من 10 إلى 13 وهي الخيار الأقوى لنموذج القاعدة لكل عميل، إذ تقدّم bootstrappers جاهزة للـ cache والطابور والملفات وRedis. أما spatie/laravel-multitenancy بإصدار v4.2.0 فتتطلب PHP 8.2 وLaravel من 11 إلى 13 وهي محايدة عن قصد، واخترها حين يتولى فريق العميل صيانة الكود.
هل يمكنني الانتقال من قاعدة واحدة إلى قاعدة لكل عميل لاحقاً؟
نعم، والأمر مباشر إن خططت له. استخدم مفاتيح ULID للمستأجرين، وضع tenant_id في كل جدول يخصهم، ولا تربط مفتاحاً خارجياً بين مستأجرين، وافصل الجداول المركزية، وافصل مسارات التخزين ومفاتيح الـ cache لكل مستأجر من أول commit. عندها يصبح الاستخراج نسخاً مؤتمتاً مع نافذة صيانة قصيرة لذلك العميل وحده.
كلمات مفتاحية: LaravelSaaSMulti-TenancyDatabase DesignArchitecturePostgreSQLMySQLScalability

مستعد لتطبيق ما قرأته؟

استشاره مجانية 30 دقيقة، رد خلال 24 ساعة، وعرض سعر مكتوب.

تواصل واتساب