Laravel E-Learning Rebuild

1

Part 1: Introduction & Why I'm Upgrading

2

Part 2: Setting Up Laravel 12 + Filament

3

Part 3: Database Schema Design

4

Part 4: Authentication & User Roles

5

Part 5: Building the Exam System

6

Part 6: Real-Time Features

7

Part 7: Testing Strategy

8

Part 8: Deployment

This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia

March 10, 2026

🇬🇧 English

Laravel E-Learning Part 6: Real-Time Features with Livewire

Adding real-time functionality to our e-learning platform using Livewire for live exam monitoring, instant notifications, and dynamic updates.

5 min read


What We're Building

Real-time features make our e-learning platform feel alive:

  1. Live Exam Monitoring - Teachers see who's taking exams in real-time
  2. Progress Tracking - Watch students answer questions live
  3. Instant Results - Scores appear immediately after submission
  4. Dashboard Updates - Statistics refresh automatically

Why Laravel Reverb?

ApproachProsCons
Livewire PollingSimple, no extra setupHigher server load
Laravel ReverbTrue WebSocket, efficient, freeRequires server setup
PusherEasy setupCosts money at scale

For our e-learning platform, we decided to use Laravel Reverb. It provides a first-party WebSocket server for Laravel applications, enabling real-time features efficiently without the scaling costs associated with Pusher.

Step 1: Live Exam Monitor Widget

Create a Filament widget for teachers to monitor active exams:

php artisan make:filament-widget LiveExamMonitor --panel=teacher
<?php

namespace App\Filament\Teacher\Widgets;

use App\Models\Exam;
use App\Models\ExamAttempt;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;

class LiveExamMonitor extends BaseWidget
{
    protected int | string | array $columnSpan = 'full';
    protected static ?int $sort = 1;
    
    // Listen for WebSocket events instead of polling
    protected $listeners = [
        'echo:exams,ExamAttemptUpdated' => '$refresh',
    ];

    public function table(Table $table): Table
    {
        return $table
            ->query(
                ExamAttempt::query()
                    ->where('status', 'in_progress')
                    ->with(['student', 'exam'])
            )
            ->columns([
                Tables\Columns\TextColumn::make('student.name')
                    ->label('Student')
                    ->searchable(),
                Tables\Columns\TextColumn::make('exam.title')
                    ->label('Exam')
                    ->limit(30),
                Tables\Columns\TextColumn::make('started_at')
                    ->since()
                    ->label('Started'),
                Tables\Columns\TextColumn::make('progress')
                    ->label('Progress')
                    ->getStateUsing(function (ExamAttempt $record): string {
                        $answered = $record->answers()->count();
                        $total = $record->exam->questions()->count();
                        return "{$answered}/{$total}";
                    }),
                Tables\Columns\TextColumn::make('time_remaining')
                    ->label('Time Left')
                    ->getStateUsing(function (ExamAttempt $record): string {
                        $elapsed = now()->diffInMinutes($record->started_at);
                        $remaining = max(0, $record->exam->duration_minutes - $elapsed);
                        return "{$remaining} min";
                    })
                    ->color(fn (string $state): string => 
                        intval($state) < 10 ? 'danger' : 'success'),
            ])
            ->emptyStateHeading('No active exams')
            ->emptyStateDescription('Students will appear here when they start an exam');
    }
}

Step 2: Student Progress Component

Create a Livewire component for detailed student monitoring:

php artisan make:livewire ExamProgressTracker
<?php

namespace App\Livewire;

use App\Models\Exam;
use App\Models\ExamAttempt;
use Livewire\Component;
use Livewire\Attributes\On;

class ExamProgressTracker extends Component
{
    public Exam $exam;
    public $attempts = [];

    public function mount(Exam $exam): void
    {
        $this->exam = $exam;
    }

    #[On('echo:exams,ExamAttemptUpdated')]
    public function refreshData()
    {
        // Component will re-render automatically
    }

    public function getAttempts()
    {
        return ExamAttempt::where('exam_id', $this->exam->id)
            ->with(['student', 'answers'])
            ->orderByDesc('started_at')
            ->get()
            ->map(function ($attempt) {
                return [
                    'id' => $attempt->id,
                    'student_name' => $attempt->student->name,
                    'status' => $attempt->status,
                    'answered_count' => $attempt->answers->count(),
                    'total_questions' => $this->exam->questions->count(),
                    'progress_percent' => $this->exam->questions->count() > 0
                        ? round(($attempt->answers->count() / $this->exam->questions->count()) * 100)
                        : 0,
                    'score' => $attempt->score,
                    'started_at' => $attempt->started_at->diffForHumans(),
                ];
            });
    }

    public function render()
    {
        return view('livewire.exam-progress-tracker', [
            'attempts' => $this->getAttempts(),
        ]);
    }
}

The view resources/views/livewire/exam-progress-tracker.blade.php:

<div>
    <div class="space-y-4">
        @forelse($attempts as $attempt)
            <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
                <div class="flex items-center justify-between mb-2">
                    <span class="font-semibold">{{ $attempt['student_name'] }}</span>
                    <span @class([
                        'px-2 py-1 rounded text-sm',
                        'bg-yellow-100 text-yellow-800' => $attempt['status'] === 'in_progress',
                        'bg-green-100 text-green-800' => $attempt['status'] === 'completed',
                        'bg-red-100 text-red-800' => $attempt['status'] === 'timed_out',
                    ])>
                        {{ ucfirst(str_replace('_', ' ', $attempt['status'])) }}
                    </span>
                </div>

                {{-- Progress Bar --}}
                <div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
                    <div 
                        class="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
                        style="width: {{ $attempt['progress_percent'] }}%"
                    ></div>
                </div>

                <div class="flex justify-between text-sm text-gray-500">
                    <span>{{ $attempt['answered_count'] }}/{{ $attempt['total_questions'] }} questions</span>
                    @if($attempt['status'] === 'completed')
                        <span class="font-bold text-lg {{ $attempt['score'] >= 60 ? 'text-green-600' : 'text-red-600' }}">
                            Score: {{ $attempt['score'] }}%
                        </span>
                    @else
                        <span>Started {{ $attempt['started_at'] }}</span>
                    @endif
                </div>
            </div>
        @empty
            <div class="text-center text-gray-500 py-8">
                No students have taken this exam yet
            </div>
        @endforelse
    </div>
</div>

Step 3: Dashboard Statistics Widget

Create a real-time dashboard widget:

<?php

namespace App\Filament\Teacher\Widgets;

use App\Models\Exam;
use App\Models\ExamAttempt;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class ExamStatsWidget extends BaseWidget
{
    // Listen for WebSocket events instead of polling
    protected $listeners = [
        'echo:exams,ExamAttemptUpdated' => '$refresh',
        'echo:exams,ExamCompleted' => '$refresh',
    ];

    protected function getStats(): array
    {
        $activeExams = Exam::where('status', 'published')
            ->where('start_time', '<=', now())
            ->where('end_time', '>=', now())
            ->count();

        $studentsInProgress = ExamAttempt::where('status', 'in_progress')->count();

        $todayCompleted = ExamAttempt::where('status', 'completed')
            ->whereDate('completed_at', today())
            ->count();

        $averageScore = ExamAttempt::where('status', 'completed')
            ->whereDate('completed_at', today())
            ->avg('score') ?? 0;

        return [
            Stat::make('Active Exams', $activeExams)
                ->description('Currently running')
                ->descriptionIcon('heroicon-m-clipboard-document-check')
                ->color('success'),
                
            Stat::make('Students In Progress', $studentsInProgress)
                ->description('Taking exams now')
                ->descriptionIcon('heroicon-m-user-group')
                ->color('warning'),
                
            Stat::make('Completed Today', $todayCompleted)
                ->description('Exams finished')
                ->descriptionIcon('heroicon-m-check-circle')
                ->color('info'),
                
            Stat::make('Average Score', round($averageScore) . '%')
                ->description('Today\'s average')
                ->descriptionIcon('heroicon-m-chart-bar')
                ->color($averageScore >= 60 ? 'success' : 'danger'),
        ];
    }
}

Step 4: Auto-Save with Debouncing

Update the exam taking page to auto-save answers efficiently:

// In the TakeExam component
public function saveAnswer(int $questionId, string $answer): void
{
    // Debounce is handled by Livewire's wire:model.debounce
    Answer::updateOrCreate(
        [
            'exam_attempt_id' => $this->attempt->id,
            'question_id' => $questionId,
        ],
        [
            'answer' => $answer,
        ]
    );

    $this->answers[$questionId] = $answer;
    
    // Update attempt's total answered count
    $this->attempt->update([
        'total_answered' => count(array_filter($this->answers)),
    ]);
}

In the Blade template:

{{-- For essay questions with debounce --}}
<textarea 
    wire:model.debounce.500ms="answers.{{ $question->id }}"
    wire:change="saveAnswer({{ $question->id }}, $event.target.value)"
    class="w-full border rounded-lg p-3"
    rows="4"
></textarea>

Step 5: Notification on Exam Submission

use Filament\Notifications\Notification;
use Filament\Notifications\Actions\Action;

// When exam is submitted
public function submitExam(): void
{
    // ... grading logic ...

    // Send notification to teacher
    $teacher = $this->exam->subject->teacher;
    
    Notification::make()
        ->title('Student Completed Exam')
        ->body("{$this->attempt->student->name} scored {$score}% on {$this->exam->title}")
        ->icon('heroicon-o-academic-cap')
        ->iconColor($score >= $this->exam->passing_score ? 'success' : 'danger')
        ->actions([
            Action::make('view')
                ->button()
                ->url(route('filament.teacher.pages.exam-progress', ['exam' => $this->exam])),
        ])
        ->sendToDatabase($teacher);
}

Step 6: Enable Database Notifications

php artisan notifications:table
php artisan migrate

Update User model:

use Illuminate\Notifications\Notifiable;
use Filament\Models\Contracts\HasNotifications;
use Filament\Notifications\HasDatabaseNotifications;

class User extends Authenticatable implements FilamentUser, HasNotifications
{
    use HasDatabaseNotifications;
    // ...
}

Enable in panel provider:

->databaseNotifications()
->broadcastNotifications()

Summary

We've added real-time features:

  • ✅ Live exam monitoring widget
  • ✅ Real-time progress tracking
  • ✅ Auto-updating dashboard statistics
  • ✅ Auto-save with debouncing
  • ✅ Database notifications for teachers

What's Next

In Part 7, we'll add comprehensive testing:

  • Feature tests for exam flow
  • Unit tests for grading logic
  • Browser tests with Laravel Dusk

Continue to Part 7: Testing Strategy →

Continue Reading

Previous article

← Previous Article

Laravel E-Learning Part 5: Building the Exam System

Next Article →

Why I Deploy Next.js on a VPS Without Docker (And You Should Consider It Too)

Next article