Loading
Please wait...

Laravel E-Learning Rebuild

1

Part 1: Introduction & Why I'm Upgrading

2

Part 2: Setting Up Laravel 11 + 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

8 Januari 2026

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 Livewire Instead of Pusher?

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

For our use case (school environment, moderate traffic), Livewire polling is sufficient and keeps things simple.

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;
    
    // Refresh every 5 seconds
    protected static string $pollingInterval = '5s';

    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;

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

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

    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 wire:poll.3s>
    <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
{
    protected static ?string $pollingInterval = '10s';

    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()
->databaseNotificationsPolling('30s')

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 →

Laravel E-Learning Part 7: Testing Strategy with Pest PHP

Next article