What We're Building
In this part, we'll create:
- Exam Resource - For teachers to create and manage exams
- Question Resource - Nested within exams for question management
- Take Exam Page - For students to take exams
- Result Display - Show exam results to students
Step 1: Create Exam Resource
php artisan make:filament-resource Exam --generate
Update app/Filament/Resources/ExamResource.php:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ExamResource\Pages;
use App\Filament\Resources\ExamResource\RelationManagers;
use App\Models\Exam;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class ExamResource extends Resource
{
protected static ?string $model = Exam::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationGroup = 'Exam Management';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Basic Information')
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\Select::make('subject_id')
->relationship('subject', 'name')
->required()
->searchable()
->preload(),
Forms\Components\Textarea::make('description')
->rows(3),
])->columns(2),
Forms\Components\Section::make('Schedule')
->schema([
Forms\Components\DateTimePicker::make('start_time')
->required()
->native(false),
Forms\Components\DateTimePicker::make('end_time')
->required()
->native(false)
->after('start_time'),
Forms\Components\TextInput::make('duration_minutes')
->numeric()
->default(60)
->suffix('minutes')
->required(),
])->columns(3),
Forms\Components\Section::make('Settings')
->schema([
Forms\Components\TextInput::make('passing_score')
->numeric()
->default(60)
->suffix('%')
->required(),
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'closed' => 'Closed',
])
->default('draft')
->required(),
Forms\Components\Toggle::make('shuffle_questions')
->default(false),
Forms\Components\Toggle::make('show_result')
->default(true),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('subject.name')
->sortable(),
Tables\Columns\TextColumn::make('questions_count')
->counts('questions')
->label('Questions'),
Tables\Columns\TextColumn::make('duration_minutes')
->suffix(' min'),
Tables\Columns\BadgeColumn::make('status')
->colors([
'gray' => 'draft',
'success' => 'published',
'danger' => 'closed',
]),
Tables\Columns\TextColumn::make('start_time')
->dateTime()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'closed' => 'Closed',
]),
Tables\Filters\SelectFilter::make('subject')
->relationship('subject', 'name'),
])
->actions([
Tables\Actions\Action::make('manage_questions')
->label('Questions')
->icon('heroicon-o-question-mark-circle')
->url(fn (Exam $record): string =>
ExamResource::getUrl('questions', ['record' => $record])),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\QuestionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListExams::route('/'),
'create' => Pages\CreateExam::route('/create'),
'edit' => Pages\EditExam::route('/{record}/edit'),
'questions' => Pages\ManageQuestions::route('/{record}/questions'),
];
}
}
Step 2: Create Questions Relation Manager
php artisan make:filament-relation-manager ExamResource questions question_text
<?php
namespace App\Filament\Resources\ExamResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class QuestionsRelationManager extends RelationManager
{
protected static string $relationship = 'questions';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Textarea::make('question_text')
->required()
->rows(3)
->columnSpanFull(),
Forms\Components\Select::make('question_type')
->options([
'multiple_choice' => 'Multiple Choice',
'true_false' => 'True/False',
'essay' => 'Essay',
])
->default('multiple_choice')
->required()
->reactive(),
Forms\Components\Repeater::make('options')
->schema([
Forms\Components\TextInput::make('option')
->required(),
])
->columns(1)
->defaultItems(4)
->visible(fn ($get) => $get('question_type') === 'multiple_choice'),
Forms\Components\TextInput::make('correct_answer')
->required()
->helperText('For multiple choice, enter the correct option exactly as written above'),
Forms\Components\TextInput::make('points')
->numeric()
->default(1)
->required(),
Forms\Components\TextInput::make('order')
->numeric()
->default(0),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('order')
->sortable(),
Tables\Columns\TextColumn::make('question_text')
->limit(50)
->searchable(),
Tables\Columns\BadgeColumn::make('question_type'),
Tables\Columns\TextColumn::make('points'),
])
->defaultSort('order')
->reorderable('order')
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}
}
Step 3: Create Student Exam Page
Create a custom page for students to take exams:
php artisan make:filament-page TakeExam --panel=student
<?php
namespace App\Filament\Student\Pages;
use App\Models\Answer;
use App\Models\Exam;
use App\Models\ExamAttempt;
use App\Models\Question;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
class TakeExam extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static string $view = 'filament.student.pages.take-exam';
protected static bool $shouldRegisterNavigation = false;
public Exam $exam;
public ExamAttempt $attempt;
public array $answers = [];
public int $currentQuestion = 0;
public int $timeRemaining = 0;
public function mount(Exam $exam): void
{
$this->exam = $exam;
// Check if exam is active
if (!$exam->isActive()) {
Notification::make()
->title('Exam is not available')
->danger()
->send();
$this->redirect('/student');
return;
}
// Get or create attempt
$this->attempt = ExamAttempt::firstOrCreate(
[
'exam_id' => $exam->id,
'student_id' => Auth::id(),
],
[
'started_at' => now(),
'status' => 'in_progress',
]
);
if ($this->attempt->status === 'completed') {
$this->redirect(route('filament.student.pages.exam-result', ['attempt' => $this->attempt]));
return;
}
// Calculate remaining time
$elapsedMinutes = now()->diffInMinutes($this->attempt->started_at);
$this->timeRemaining = max(0, ($exam->duration_minutes - $elapsedMinutes) * 60);
// Load existing answers
$existingAnswers = $this->attempt->answers()->pluck('answer', 'question_id')->toArray();
$this->answers = $existingAnswers;
}
public function getQuestions()
{
$questions = $this->exam->questions()->orderBy('order')->get();
if ($this->exam->shuffle_questions) {
$questions = $questions->shuffle();
}
return $questions;
}
public function saveAnswer(int $questionId, string $answer): void
{
Answer::updateOrCreate(
[
'exam_attempt_id' => $this->attempt->id,
'question_id' => $questionId,
],
[
'answer' => $answer,
]
);
$this->answers[$questionId] = $answer;
}
public function submitExam(): void
{
$questions = $this->exam->questions;
$correctCount = 0;
$totalPoints = 0;
$earnedPoints = 0;
foreach ($questions as $question) {
$totalPoints += $question->points;
$userAnswer = $this->answers[$question->id] ?? null;
$isCorrect = strtolower(trim($userAnswer ?? '')) === strtolower(trim($question->correct_answer));
Answer::updateOrCreate(
[
'exam_attempt_id' => $this->attempt->id,
'question_id' => $question->id,
],
[
'answer' => $userAnswer,
'is_correct' => $isCorrect,
'points_earned' => $isCorrect ? $question->points : 0,
]
);
if ($isCorrect) {
$correctCount++;
$earnedPoints += $question->points;
}
}
$score = $totalPoints > 0 ? round(($earnedPoints / $totalPoints) * 100) : 0;
$this->attempt->update([
'completed_at' => now(),
'status' => 'completed',
'score' => $score,
'correct_answers' => $correctCount,
'total_answered' => count($this->answers),
]);
Notification::make()
->title('Exam Submitted!')
->body("Your score: {$score}%")
->success()
->send();
$this->redirect(route('filament.student.pages.exam-result', ['attempt' => $this->attempt]));
}
}
Step 4: Create Exam Result Page
<?php
namespace App\Filament\Student\Pages;
use App\Models\ExamAttempt;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
class ExamResult extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-chart-bar';
protected static string $view = 'filament.student.pages.exam-result';
protected static bool $shouldRegisterNavigation = false;
public ExamAttempt $attempt;
public function mount(ExamAttempt $attempt): void
{
// Verify ownership
if ($attempt->student_id !== Auth::id()) {
abort(403);
}
$this->attempt = $attempt->load(['exam', 'answers.question']);
}
public function getPassedProperty(): bool
{
return $this->attempt->score >= $this->attempt->exam->passing_score;
}
public function getGradeProperty(): string
{
return match (true) {
$this->attempt->score >= 90 => 'A',
$this->attempt->score >= 80 => 'B',
$this->attempt->score >= 70 => 'C',
$this->attempt->score >= 60 => 'D',
default => 'F',
};
}
}
Step 5: Blade Views
Create resources/views/filament/student/pages/take-exam.blade.php:
<x-filament-panels::page>
<div class="space-y-6" x-data="{ timeRemaining: {{ $this->timeRemaining }} }" x-init="
setInterval(() => {
if (timeRemaining > 0) {
timeRemaining--;
} else {
$wire.submitExam();
}
}, 1000)
">
{{-- Timer --}}
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg text-center">
<span class="text-2xl font-bold" x-text="Math.floor(timeRemaining / 60) + ':' + String(timeRemaining % 60).padStart(2, '0')"></span>
<span class="text-sm text-gray-500 ml-2">remaining</span>
</div>
{{-- Questions --}}
@foreach($this->getQuestions() as $index => $question)
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h3 class="font-semibold mb-4">
Question {{ $index + 1 }}: {{ $question->question_text }}
</h3>
@if($question->question_type === 'multiple_choice')
<div class="space-y-2">
@foreach($question->options as $option)
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="question_{{ $question->id }}"
value="{{ $option['option'] }}"
wire:click="saveAnswer({{ $question->id }}, '{{ $option['option'] }}')"
@checked(($answers[$question->id] ?? '') === $option['option'])
class="mr-3"
>
{{ $option['option'] }}
</label>
@endforeach
</div>
@elseif($question->question_type === 'true_false')
<div class="flex gap-4">
<label class="flex items-center">
<input type="radio" name="question_{{ $question->id }}" value="true"
wire:click="saveAnswer({{ $question->id }}, 'true')"
@checked(($answers[$question->id] ?? '') === 'true')>
<span class="ml-2">True</span>
</label>
<label class="flex items-center">
<input type="radio" name="question_{{ $question->id }}" value="false"
wire:click="saveAnswer({{ $question->id }}, 'false')"
@checked(($answers[$question->id] ?? '') === 'false')>
<span class="ml-2">False</span>
</label>
</div>
@else
<textarea
wire:blur="saveAnswer({{ $question->id }}, $event.target.value)"
class="w-full border rounded-lg p-3"
rows="4"
>{{ $answers[$question->id] ?? '' }}</textarea>
@endif
</div>
@endforeach
{{-- Submit Button --}}
<div class="flex justify-center">
<x-filament::button
wire:click="submitExam"
wire:confirm="Are you sure you want to submit? You cannot change your answers after submission."
size="lg"
>
Submit Exam
</x-filament::button>
</div>
</div>
</x-filament-panels::page>
Summary
We've built a complete exam system:
- ✅ Exam CRUD with Filament resources
- ✅ Question management with multiple types
- ✅ Student exam-taking interface
- ✅ Auto-save answers
- ✅ Countdown timer with auto-submit
- ✅ Automatic grading
- ✅ Result display
What's Next
In Part 6, we'll add real-time features:
- Live exam monitoring for teachers
- Real-time answer updates
- Notifications with Laravel Reverb
Continue to Part 6: Real-Time Features →
