Rebuild Laravel E-Learning

1

Part 1: Pengenalan & Mengapa Saya Upgrade

2

Part 2: Setup Laravel 12 + Filament

3

Part 3: Desain Skema Database

4

Part 4: Autentikasi & Role User

5

Part 5: Membangun Sistem Ujian

6

Part 6: Fitur Real-Time

7

Part 7: Strategi Testing

8

Part 8: Deployment

Artikel ini tersedia dalam Bahasa Inggris

šŸ‡¬šŸ‡§ Read in English

10 Maret 2026

šŸ‡®šŸ‡© Bahasa Indonesia

Laravel E-Learning Part 6: Fitur Real-Time dengan Livewire

Menambahkan fungsionalitas real-time ke platform e-learning menggunakan Livewire untuk monitoring ujian langsung, notifikasi instan, dan update dinamis.

5 min read


Apa yang Akan Kita Bangun

Fitur real-time membuat platform e-learning kita terasa hidup:

  1. Monitoring Ujian Langsung - Guru melihat siapa yang sedang mengerjakan ujian secara real-time
  2. Tracking Progress - Pantau siswa menjawab soal secara langsung
  3. Hasil Instan - Skor muncul langsung setelah submit
  4. Update Dashboard - Statistik refresh otomatis

Mengapa Livewire, Bukan Pusher?

PendekatanKelebihanKekurangan
Livewire PollingSimpel, tidak perlu setup tambahanBeban server lebih tinggi
Laravel ReverbWebSocket asli, efisienButuh setup server
PusherSetup mudahBiaya mahal saat scale

Untuk kasus kita (lingkungan sekolah, traffic sedang), Livewire polling sudah cukup dan menjaga simplisitas.

Step 1: Widget Monitor Ujian Langsung

Buat widget Filament untuk guru memantau ujian aktif:

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 setiap 5 detik
    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('Siswa')
                    ->searchable(),
                Tables\Columns\TextColumn::make('exam.title')
                    ->label('Ujian')
                    ->limit(30),
                Tables\Columns\TextColumn::make('started_at')
                    ->since()
                    ->label('Mulai'),
                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('Sisa Waktu')
                    ->getStateUsing(function (ExamAttempt $record): string {
                        $elapsed = now()->diffInMinutes($record->started_at);
                        $remaining = max(0, $record->exam->duration_minutes - $elapsed);
                        return "{$remaining} menit";
                    })
                    ->color(fn (string $state): string => 
                        intval($state) < 10 ? 'danger' : 'success'),
            ])
            ->emptyStateHeading('Tidak ada ujian aktif')
            ->emptyStateDescription('Siswa akan muncul di sini ketika mereka memulai ujian');
    }
}

Step 2: Komponen Progress Siswa

Buat komponen Livewire untuk monitoring detail siswa:

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(),
        ]);
    }
}

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'] }} soal</span>
                    @if($attempt['status'] === 'completed')
                        <span class="font-bold text-lg {{ $attempt['score'] >= 60 ? 'text-green-600' : 'text-red-600' }}">
                            Skor: {{ $attempt['score'] }}%
                        </span>
                    @else
                        <span>Dimulai {{ $attempt['started_at'] }}</span>
                    @endif
                </div>
            </div>
        @empty
            <div class="text-center text-gray-500 py-8">
                Belum ada siswa yang mengerjakan ujian ini
            </div>
        @endforelse
    </div>
</div>

Step 3: Widget Statistik Dashboard

Buat widget dashboard real-time:

<?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('Ujian Aktif', $activeExams)
                ->description('Sedang berjalan')
                ->descriptionIcon('heroicon-m-clipboard-document-check')
                ->color('success'),
                
            Stat::make('Siswa Sedang Mengerjakan', $studentsInProgress)
                ->description('Mengerjakan ujian sekarang')
                ->descriptionIcon('heroicon-m-user-group')
                ->color('warning'),
                
            Stat::make('Selesai Hari Ini', $todayCompleted)
                ->description('Ujian selesai')
                ->descriptionIcon('heroicon-m-check-circle')
                ->color('info'),
                
            Stat::make('Skor Rata-rata', round($averageScore) . '%')
                ->description('Rata-rata hari ini')
                ->descriptionIcon('heroicon-m-chart-bar')
                ->color($averageScore >= 60 ? 'success' : 'danger'),
        ];
    }
}

Step 4: Auto-Save dengan Debouncing

Update halaman pengerjaan ujian untuk auto-save jawaban secara efisien:

// Di komponen TakeExam
public function saveAnswer(int $questionId, string $answer): void
{
    // Debounce ditangani oleh wire:model.debounce Livewire
    Answer::updateOrCreate(
        [
            'exam_attempt_id' => $this->attempt->id,
            'question_id' => $questionId,
        ],
        [
            'answer' => $answer,
        ]
    );

    $this->answers[$questionId] = $answer;
    
    // Update jumlah jawaban di attempt
    $this->attempt->update([
        'total_answered' => count(array_filter($this->answers)),
    ]);
}

Di template Blade:

{{-- Untuk soal essay dengan 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: Notifikasi saat Ujian Dikumpulkan

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

// Ketika ujian dikumpulkan
public function submitExam(): void
{
    // ... logika penilaian ...

    // Kirim notifikasi ke guru
    $teacher = $this->exam->subject->teacher;
    
    Notification::make()
        ->title('Siswa Menyelesaikan Ujian')
        ->body("{$this->attempt->student->name} mendapat skor {$score}% di {$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: Aktifkan Database Notifications

php artisan notifications:table
php artisan migrate

Update model User:

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

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

Aktifkan di panel provider:

->databaseNotifications()
->databaseNotificationsPolling('30s')

Ringkasan

Kita telah menambahkan fitur real-time:

  • āœ… Widget monitoring ujian langsung
  • āœ… Tracking progress real-time
  • āœ… Statistik dashboard auto-update
  • āœ… Auto-save dengan debouncing
  • āœ… Database notifications untuk guru

Selanjutnya

Di Part 7, kita akan menambahkan testing komprehensif:

  • Feature tests untuk alur ujian
  • Unit tests untuk logika penilaian
  • Browser tests dengan Laravel Dusk

Lanjut ke Part 7: Strategi Testing →

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Laravel E-Learning Part 5: Building the Exam System

Artikel Selanjutnya →

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

Next article thumbnail