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 Laravel Reverb?

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

Untuk platform e-learning kita, kami memutuskan menggunakan Laravel Reverb. Ini menyediakan server WebSocket pihak pertama untuk aplikasi Laravel, memungkinkan fitur real-time dengan efisien tanpa biaya scaling seperti Pusher.

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;
    
    // Dengarkan event WebSocket sebagai pengganti 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('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;
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()
    {
        // Komponen akan otomatis ter-render ulang
    }

    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>
    <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
{
    // Dengarkan event WebSocket sebagai pengganti 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('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()
->broadcastNotifications()

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