What We're Building
Real-time features make our e-learning platform feel alive:
- Live Exam Monitoring - Teachers see who's taking exams in real-time
- Progress Tracking - Watch students answer questions live
- Instant Results - Scores appear immediately after submission
- Dashboard Updates - Statistics refresh automatically
Why Livewire Instead of Pusher?
| Approach | Pros | Cons |
|---|---|---|
| Livewire Polling | Simple, no extra setup | Higher server load |
| Laravel Reverb | True WebSocket, efficient | Requires server setup |
| Pusher | Easy setup | Costs 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 →
