موضع نص الإشعار اتصل بنا تفضل الآن!

كيف بنيت موقع بيع كورسات أونلاين باستخدام Laravel وFilament

George Bahgat

كيف بنيت موقع بيع كورسات أونلاين باستخدام Laravel وFilament

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

الهدف من هذا المشروع لم يكن فقط عرض الكورسات، بل بناء نظام متكامل يدعم الاشتراكات الشهرية، تتبع تقدم الطلاب، إدارة المحتوى التعليمي (فيديو، نص، اختبارات)، وربطه ببوابات دفع حقيقية. كل ذلك مع لوحة تحكم سهلة الاستخدام لا تحتاج إلى خبرة تقنية عالية لإدارتها. وهذا بالضبط ما قدمه لي Laravel كإطار عمل قوي للخلفية، وFilament كواجهة إدارة حديثة وسريعة.

نصيحة: قبل أن تبدأ بكتابة سطر كود واحد، خصص وقتًا لرسم هيكل قاعدة البيانات (Database Schema). هذا سيوفر عليك ساعات من التعديل لاحقًا، خاصة في مشاريع مثل منصات الكورسات التي تعتمد على علاقات معقدة بين الجداول.

التحضير الأولي: التخطيط لهيكل المشروع

قبل أن أفتح محرر الكود، جلست مع ورقة وقلم (أو بالأحرى ملف Notion!) ورسمت الهيكل الأساسي للمشروع. ما هي الكيانات الأساسية؟ ما العلاقات بينها؟ كيف سيتفاعل المستخدم مع النظام؟

الكيانات الرئيسية التي حددتها كانت:

  • المستخدم (User): يمكن أن يكون طالبًا أو مدرسًا أو مديرًا.
  • الكورس (Course): يحتوي على وحدات (Modules) ودروس (Lessons).
  • الوحدة (Module): مجموعة من الدروس داخل كورس معين.
  • الدرس (Lesson): محتوى تعليمي (فيديو، نص، ملف PDF، اختبار).
  • الاشتراك (Subscription): ربط بين المستخدم والكورس مع حالة الدفع.
  • التقدم (Progress): تتبع ما أنجزه الطالب من دروس.

بناءً على هذا، صممت العلاقات في قاعدة البيانات. مثلاً، الكورس ينتمي إلى مدرس (User)، والدرس ينتمي إلى وحدة، والوحدة تنتمي إلى كورس. أما الاشتراك فهو علاقة many-to-many بين المستخدم والكورس، مع حقل إضافي لحالة الدفع (paid/unpaid) وتاريخ الانتهاء.

استخدمت Laravel Migrations لإنشاء هذه الجداول، مع مراعاة الفهارس (Indexes) والعلاقات الخارجية (Foreign Keys) من البداية. هذا جعل الاستعلامات لاحقًا أسرع وأكثر أمانًا.

هيكل قاعدة البيانات: أمثلة عملية

فيما يلي مثال على migration لجدول الكورسات:


Schema::create('courses', function (Blueprint $table) {
    $table->id();
    $table->foreignId('instructor_id')->constrained('users')->onDelete('cascade');
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('description');
    $table->string('thumbnail')->nullable();
    $table->decimal('price', 10, 2)->default(0);
    $table->boolean('is_published')->default(false);
    $table->timestamps();
});

لاحظ أنني أضفت حقل is_published لأن المدرس قد يرغب في حفظ الكورس كمسودة قبل نشره. هذا التفصيل الصغير وفر لي مرونة كبيرة لاحقًا في إدارة المحتوى.

بالنسبة للاشتراكات، استخدمت جدولًا وسيطًا باسم course_user مع حقول إضافية:


Schema::create('course_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->foreignId('course_id')->constrained()->onDelete('cascade');
    $table->boolean('is_paid')->default(false);
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();
});

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

لماذا اخترت Laravel؟

Laravel ليس مجرد إطار عمل، بل بيئة كاملة للتطوير. بالنسبة لي، كان الخيار الطبيعي لعدة أسباب:

  • Eloquent ORM: جعل التعامل مع العلاقات بين الجداول سلسًا جدًا. مثلاً، لجلب جميع الكورسات التي يملكها مدرس معين، كل ما أحتاجه هو $user->courses.
  • Authentication جاهز: نظام تسجيل الدخول والتسجيل مدمج، ويمكن تخصيصه بسهولة ليدعم أدوار متعددة (طالب، مدرس، مدير).
  • Queues وNotifications: مثالي لإرسال إشعارات عند شراء كورس جديد أو انتهاء الاشتراك.
  • Community ضخمة: أي مشكلة أواجهها، غالبًا يوجد لها حل جاهز على Stack Overflow أو GitHub.

بالإضافة إلى ذلك، Laravel يدعم الـ caching، والـ localization، والـ testing out of the box، وكلها ميزات مهمة في مشروع مثل منصة كورسات.

لماذا Filament؟ وما الفرق عن Laravel Nova أو Backpack؟

في البداية، فكرت في استخدام Laravel Nova، لكن السعر كان عائقًا (خاصة إذا أردت توزيع المشروع لاحقًا). جربت Backpack أيضًا، لكن واجهته لم ترق لي من ناحية التصميم والسرعة.

ثم اكتشفت Filament. كان لا يزال في مراحله المبكرة وقتها، لكنه وعد بواجهة حديثة، مبنية على Tailwind CSS، وتدعم الـ CRUD بشكل فائق السرعة. الأهم أنه مفتوح المصدر، مما يعني حرية كاملة في التخصيص دون قيود ترخيص.

ما لاحظته بعد استخدامه لأسابيع قليلة هو أن Filament لا يقتصر على إدارة البيانات فقط، بل يسمح بإنشاء صفحات مخصصة (Custom Pages)، ويدعم الـ Widgets، والـ Charts، وحتى الـ Forms المعقدة بسهولة مذهلة.

تجربتي: في أول مشروع استخدمت فيه Filament، استغرقني إنشاء لوحة تحكم كاملة لإدارة الكورسات والمستخدمين أقل من يومين! بينما كنت أتوقع أن أقضي أسبوعًا على الأقل.

الخطوة الأولى: إعداد المشروع الأساسي

بدأت بإنشاء مشروع Laravel جديد:


composer create-project laravel/laravel online-courses-platform

ثم ثبّت Filament:


composer require filament/filament:"^3.0"
php artisan filament:install --panels

بعد ذلك، أنشأت النموذج الأساسي للمستخدمين مع دعم الأدوار (Roles). استخدمت Laravel Breeze للـ authentication، ثم أضفت حقل role في جدول المستخدمين (يمكنك استخدام Spatie Laravel Permission أيضًا، لكنني فضّلت البساطة في البداية).


// في migration المستخدمين
$table->enum('role', ['student', 'instructor', 'admin'])->default('student');

ثم عدّلت سياسات الوصول (Policies) لضمان أن المدرس لا يستطيع تعديل كورسات غيره، وأن الطالب لا يستطيع الدخول إلى لوحة التحكم أصلًا.

بناء نموذج الكورسات وإدارته عبر Filament

أنشأت Resource في Filament لإدارة الكورسات:


php artisan make:filament-resource Course

في ملف CourseResource.php، عدّلت الحقول لتناسب احتياجاتي:


public static function form(Form $form): Form
{
    return $form
        ->schema([
            TextInput::make('title')
                ->required()
                ->maxLength(255),
            TextInput::make('slug')
                ->required()
                ->unique(ignoreRecord: true)
                ->maxLength(255),
            Textarea::make('description')->required(),
            FileUpload::make('thumbnail')
                ->image()
                ->directory('course-thumbnails'),
            Toggle::make('is_published'),
            TextInput::make('price')
                ->numeric()
                ->prefix('USD')
                ->default(0),
            Select::make('instructor_id')
                ->relationship('instructor', 'name')
                ->searchable()
                ->preload()
                ->required(),
        ]);
}

الجميل في Filament أنه يتعامل مع العلاقات تلقائيًا. مثلاً، حقل instructor_id سيعرض قائمة منسدلة بأسماء المدرسين، بفضل العلاقة instructor() المعرفة في نموذج الكورس.

أيضًا، استخدمت FileUpload لرفع الصور، مع تحديد مجلد مخصص (course-thumbnails)، مما يحافظ على تنظيم الملفات.

إدارة الوحدات والدروس داخل الكورس

لإدارة الوحدات والدروس بشكل هرمي، استخدمت ميزة Relation Managers في Filament. هذه الميزة تسمح لك بإضافة جداول فرعية داخل صفحة تحرير الكورس.

أنشأت Resource منفصلة لكل من Module وLesson، ثم أضفت Relation Manager في CourseResource:


public static function getRelations(): array
{
    return [
        Relations\CourseModulesRelationManager::class,
    ];
}

وفي ملف CourseModulesRelationManager.php:


public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('title'),
            TextColumn::make('order'),
        ])
        ->headerActions([
            CreateAction::make(),
        ])
        ->actions([
            EditAction::make(),
            DeleteAction::make(),
        ]);
}

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

ملاحظة عملية: استخدم حقل order (عدد صحيح) لترتيب الوحدات والدروس. هذا أفضل بكثير من الاعتماد على تاريخ الإنشاء، لأن المدرس قد يرغب في تغيير الترتيب لاحقًا.

نظام الدفع والاشتراكات

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

ثبّت Laravel Cashier:


composer require laravel/cashier

ثم أضفت الحقول المطلوبة لجدول المستخدمين:


$table->string('stripe_id')->nullable();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();

أنشأت صفحة شراء كورس في الواجهة الأمامية، حيث يختار المستخدم كورسًا، ثم يُوجّه إلى صفحة دفع Stripe. عند اكتمال الدفع، أنشئ سجلًا في جدول course_user مع تعيين is_paid = true وexpires_at حسب نوع الاشتراك (مرة واحدة أو شهري).

في الكود:


// في Controller الخاص بالدفع
$payment = $user->charge($course->price * 100, $paymentMethodId);

if ($payment->status === 'succeeded') {
    $user->courses()->attach($course->id, [
        'is_paid' => true,
        'expires_at' => now()->addYear() // أو addMonth() للاشتراكات
    ]);
}

بالنسبة للاشتراكات الشهرية، استخدمت Webhooks من Stripe لتحديث حالة الاشتراك تلقائيًا عند التجديد أو الإلغاء.

عرض الكورسات في الواجهة الأمامية

في الواجهة الأمامية، أنشأت صفحة عرض الكورسات مع فلترة حسب الفئة (إذا أضفت فئات لاحقًا)، والسعر، والمدرس. استخدمت Laravel Pagination وEloquent Scopes لتبسيط الاستعلامات.

مثال على Scope في نموذج الكورس:


public function scopePublished($query)
{
    return $query->where('is_published', true);
}

ثم في الـ Controller:


$courses = Course::published()->latest()->paginate(12);

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


$isEnrolled = $course->students()->where('user_id', auth()->id())->where('is_paid', true)->exists();

إذا لم يكن مشتركًا، أعرض زر "اشتراك الآن". إذا كان مشتركًا، أعرض محتوى الكورس.

تتبع تقدم الطالب

من الميزات المهمة في أي منصة تعليمية هي تتبع التقدم. أنشأت جدولًا باسم lesson_progress لتسجيل الدروس التي أكملها الطالب:


Schema::create('lesson_progress', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->foreignId('lesson_id')->constrained()->onDelete('cascade');
    $table->boolean('completed')->default(false);
    $table->timestamps();
});

في الواجهة، عند فتح درس، أعرض زر "تم الانتهاء" يُرسل طلب POST لتحديث الحقل completed إلى true.

ثم في صفحة الكورس، أحسب نسبة التقدم كالتالي:


$totalLessons = $course->lessons()->count();
$completedLessons = auth()->user()->completedLessons()
    ->whereIn('lesson_id', $course->lessons()->pluck('id'))
    ->count();

$progressPercentage = $totalLessons ? ($completedLessons / $totalLessons) * 100 : 0;

عرضت هذه النسبة في شريط تقدم بسيط باستخدام Tailwind CSS.

لوحة التحكم المتقدمة مع Filament Widgets

Filament لا يقتصر على إدارة الجداول. يمكنك إنشاء Widgets لعرض إحصائيات أو ملخصات في لوحة التحكم.

أنشأت Widget يعرض:

  • إجمالي عدد الكورسات المنشورة
  • عدد الطلاب المسجلين
  • إجمالي الإيرادات الشهرية
  • آخر الكورسات المضافة

لإنشاء Widget:


php artisan make:filament-widget StatsOverview --stats-overview

ثم عدّلت المحتوى:


public function getStats(): array
{
    return [
        Stat::make('الكورسات', Course::published()->count()),
        Stat::make('الطلاب', User::where('role', 'student')->count()),
        Stat::make('الإيرادات', '$' . number_format(Payment::whereMonth('created_at', now()->month)->sum('amount') / 100, 2)),
        Stat::make('الاشتراكات الجديدة', Subscription::where('created_at', '>=', now()->subWeek())->count()),
    ];
}

النتيجة؟ لوحة تحكم احترافية تعرض أهم المؤشرات في لمحة، مما يساعد المدير على اتخاذ قرارات سريعة.

نصيحة: لا تبالغ في عدد الـ Widgets. ركّز على المؤشرات التي تهمك فعليًا، وإلا ستشتت انتباهك.

التعامل مع الفيديوهات: التخزين والاستضافة

أحد أكبر التحديات في منصات الكورسات هو استضافة الفيديوهات. رفعها مباشرة على السيرفر سيستهلك مساحة هائلة ويزيد التكاليف.

لذلك، استخدمت Cloudflare Stream (يمكنك استخدام Vimeo أو Mux أيضًا). الفكرة بسيطة:

  1. المدرس يرفع الفيديو من لوحة التحكم.
  2. الفيديو يُرسل مباشرة إلى Cloudflare عبر API.
  3. يُخزّن معرّف الفيديو (UID) في قاعدة البيانات.
  4. في الواجهة، يُعرض الفيديو عبر iframe باستخدام هذا المعرّف.

في Filament، أنشأت حقل مخصص (Custom Field) لرفع الفيديو:


// في LessonResource
FileUpload::make('video_file')
    ->acceptedFileTypes(['video/mp4', 'video/quicktime'])
    ->storeFiles(false) // لأننا لن نخزّنه على السيرفر
    ->afterStateUpdated(function ($state, callable $set) {
        if ($state) {
            $videoUid = $this->uploadToCloudflare($state->getRealPath());
            $set('video_uid', $videoUid);
        }
    }),
Hidden::make('video_uid'),

الدالة uploadToCloudflare تستخدم Guzzle لإرسال الفيديو إلى API Cloudflare، ثم تُرجع المعرّف.

بهذا الشكل، لا أخزن أي فيديو على سيرفري، مما يقلل التكاليف ويحسن الأداء.

الأمان: ما الذي يجب أن تنتبه له؟

في مشاريع مثل هذه، الأمان ليس خيارًا. إليك بعض النقاط التي ركّزت عليها:

1. حماية محتوى الكورسات

لا يمكن السماح لأي مستخدم بعرض درس ما لم يكن مشتركًا في الكورس. لذلك، في كل Controller يعرض درسًا، أتحقق من الاشتراك:


public function show(Lesson $lesson)
{
    $course = $lesson->module->course;
    
    if (!auth()->user()->subscribedTo($course)) {
        abort(403, 'ليس لديك صلاحية الوصول لهذا المحتوى.');
    }
    
    return view('lessons.show', compact('lesson'));
}

الدالة subscribedTo معرفة في نموذج المستخدم:


public function subscribedTo(Course $course)
{
    return $this->courses()
        ->where('course_id', $course->id)
        ->where('is_paid', true)
        ->where(function ($query) {
            $query->whereNull('expires_at')
                  ->orWhere('expires_at', '>=', now());
        })
        ->exists();
}

2. حماية لوحة التحكم

في Filament، يمكنك تحديد من يستطيع الدخول إلى لوحة التحكم عبر ملف PanelProvider:


public function boot()
{
    Filament::registerRenderHook(
        'panels::body.end',
        fn (): string => ''
    );

    Filament::authGuard('web');
    Filament::loginRoute(fn (): string => route('login'));
}

public function panel(Panel $panel): Panel
{
    return $panel
        ->authGuard('web')
        ->loginUrl('/login')
        ->userMenuItems([
            'profile' => MenuItem::make()->url(fn (): string => '/user/profile'),
        ])
        ->middleware([
            Authorize::class,
        ]);
}

وأنشأت Middleware خاص باسم Authorize يتحقق من أن المستخدم لديه دور "admin" أو "instructor":


public function handle($request, Closure $next)
{
    if (!auth()->check() || !in_array(auth()->user()->role, ['admin', 'instructor'])) {
        return redirect('/');
    }
    return $next($request);
}

الاختبارات (Testing): لا تتجاهلها!

في البداية، كنت أهمل كتابة الاختبارات، لكن بعد أن كسر تحديث بسيط وظيفة الدفع، قررت أن أغير نهجي.

كتبت Feature Tests لحالات مهمة مثل:

  • الاشتراك في كورس مجاني
  • الاشتراك في كورس مدفوع عبر Stripe
  • محاولة الوصول إلى درس دون اشتراك
  • إنشاء كورس جديد من قبل مدرس

مثال على اختبار اشتراك ناجح:


public function test_user_can_subscribe_to_paid_course()
{
    $user = User::factory()->create();
    $course = Course::factory()->create(['price' => 50]);

    $this->actingAs($user)
         ->post(route('courses.subscribe', $course), [
             'payment_method' => 'pm_test_123'
         ]);

    $this->assertDatabaseHas('course_user', [
        'user_id' => $user->id,
        'course_id' => $course->id,
        'is_paid' => true
    ]);
}

هذه الاختبارات أنقذتني مرات عديدة من أخطاء غير متوقعة.

التحسين والأداء: كيف تجعل الموقع سريعًا؟

منصة الكورسات قد تحتوي على آلاف المستخدمين والدروس، لذا الأداء أمر حاسم.

1. التخزين المؤقت (Caching)

استخدمت Laravel Cache لتخزين بيانات الكورسات الشائعة:


$courses = Cache::remember('published_courses', 3600, function () {
    return Course::published()->with('instructor')->get();
});

2. التحميل البطيء (Lazy Loading)

في العلاقات، تجنبت N+1 problem باستخدام with():


$course = Course::with('modules.lessons')->findOrFail($id);

3. CDN للصور والفيديوهات

كما ذكرت، الفيديوهات على Cloudflare Stream، والصور على Cloudflare Images، مما يضمن تحميلًا سريعًا من أي مكان في العالم.

التوسع المستقبلي: ماذا لو نجح المشروع؟

من اليوم الأول، صممت المشروع ليكون قابلًا للتوسع. مثلاً:

  • استخدمت Queue لمعالجة المهام الثقيلة (مثل إرسال إيميلات جماعية).
  • فصلت المنطق في Service Classes بدلًا من وضع كل شيء في الـ Controllers.
  • استخدمت Repository Pattern لإدارة استعلامات قاعدة البيانات (اختياري، لكنه مفيد في المشاريع الكبيرة).

إذا قررت لاحقًا دعم الفئات (Categories)، أو الشهادات (Certificates)، أو المنتديات، فكل ما عليّ هو إضافة جداول جديدة وربطها بالهيكل الحالي دون كسر أي شيء.

رأيي الشخصي: لا تحاول بناء كل شيء من البداية. ابدأ بالحد الأدنى القابل للتطبيق (MVP)، ثم أضف الميزات بناءً على ملاحظات المستخدمين الحقيقيين.

الخلاصة: لماذا هذا المزيج ناجح؟

بعد أشهر من التطوير والاختبار، يمكنني القول إن الجمع بين Laravel وFilament كان قرارًا ذكيًا. Laravel أعطاني القوة والهيكل، وFilament وفر لي واجهة إدارة سريعة ومرنة دون الحاجة إلى كتابة آلاف الأسطر من JavaScript.

المشروع النهائي يحتوي على:

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

الأهم من كل ذلك، أن المشروع قابل للصيانة والتوسع، ويمكن لأي مطور Laravel أن يفهم هيكله بسهولة.

خطوتك التالية

إذا كنت تفكر في بناء منصة كورسات خاصة بك، فلا تنتظر الكمال. ابدأ بمشروع بسيط: كورس واحد، مستخدم واحد، دفع بسيط. استخدم Laravel وFilament كما شرحت، وجرب بنفسك.

الكود الذي شاركته هنا ليس نظريًا — هو جزء من مشروع حقيقي طورته ويعمل حاليًا. الفرق بين الفكرة والتطبيق هو أن تبدأ.

ابدأ اليوم. حتى لو كان موقعك الأول بسيطًا، فسيكون حجر الأساس لمشروعك القادم. وثق أن الأدوات التي بين يديك (Laravel وFilament) كافية لبناء شيء مذهل.

وإذا واجهتك مشكلة، تذكّر: كل مطور مر بها. الحل دائمًا أقرب مما تظن.

إرسال تعليق

الموافقة على ملفات تعريف الارتباط
”نحن نقدم ملفات تعريف الارتباط على هذا الموقع لتحليل حركة المرور وتذكر تفضيلاتك وتحسين تجربتك.“
لا يتوفر اتصال بالإنترنت!
”يبدو أن هناك خطأ ما في اتصالك بالإنترنت ، يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.“
تم الكشف عن مانع الإعلانات!
”لقد اكتشفنا أنك تستخدم مكونًا إضافيًا لحظر الإعلانات في متصفحك.
تُستخدم العائدات التي نحققها من الإعلانات لإدارة موقع الويب هذا ، ونطلب منك إدراج موقعنا في القائمة البيضاء في المكون الإضافي لحظر الإعلانات.“
Site is Blocked
Sorry! This site is not available in your country.