App Documentation - Test2Go

Test2Go - Application Documentation

Complete technical documentation for developers working with Test2Go source code.

Table of Contents

  1. Introduction
  2. Project Structure
  3. Architecture Overview
  4. Database Schema
  5. Service Layer
  6. Controllers
  7. Models & Relationships
  8. Frontend Architecture
  9. Authentication & Authorization
  10. API Endpoints
  11. Real-Time Updates
  12. Configuration
  13. Testing
  14. Extending Test2Go

Introduction

Test2Go is built on Laravel 13 with a clean MVC architecture enhanced by a service layer. This document provides detailed technical information for developers who need to understand, maintain, or extend the codebase.

Design Principles

Project Structure

Directory Layout

test2go/
+-- app/
|   +-- Http/
|   |   +-- Controllers/
|   |   |   +-- Admin/               # Admin panel controllers
|   |   |   |   +-- DashboardController.php
|   |   |   |   +-- TestController.php
|   |   |   |   +-- QuestionController.php
|   |   |   |   +-- LiveSessionController.php
|   |   |   |   +-- UserController.php
|   |   |   |   +-- AnalyticsController.php
|   |   |   +-- Participant/         # Participant controllers
|   |   |   |   +-- JoinController.php
|   |   |   |   +-- LiveQuizController.php
|   |   |   |   +-- AsyncTestController.php
|   |   |   +-- PublicController.php
|   |   +-- Middleware/
|   |   |   +-- ActiveAdmin.php      # Check admin is active
|   |   |   +-- ValidateParticipantToken.php
|   |   +-- Requests/                # Form requests
|   |       +-- StoreTestRequest.php
|   |       +-- StoreQuestionRequest.php
|   |       +-- SubmitAnswerRequest.php
|   +-- Models/
|   |   +-- User.php                 # Admin users
|   |   +-- Test.php
|   |   +-- Question.php
|   |   +-- LiveSession.php
|   |   +-- Participant.php
|   |   +-- Attempt.php
|   |   +-- Answer.php
|   +-- Services/
|   |   +-- JoinCodeService.php
|   |   +-- ScoringService.php
|   |   +-- LeaderboardService.php
|   |   +-- LiveQuizService.php
|   |   +-- AnalyticsService.php
|   +-- Providers/
|       +-- AppServiceProvider.php
+-- database/
|   +-- migrations/
|   |   +-- 2024_01_01_000000_create_users_table.php
|   |   +-- 2024_01_01_000001_create_tests_table.php
|   |   +-- 2024_01_01_000002_create_questions_table.php
|   |   +-- 2024_01_01_000003_create_live_sessions_table.php
|   |   +-- 2024_01_01_000004_create_participants_table.php
|   |   +-- 2024_01_01_000005_create_attempts_table.php
|   |   +-- 2024_01_01_000006_create_answers_table.php
|   +-- seeders/
|   |   +-- DatabaseSeeder.php
|   |   +-- AdminUserSeeder.php
|   +-- factories/
+-- resources/
|   +-- views/
|   |   +-- admin/                   # Admin panel views
|   |   |   +-- dashboard.blade.php
|   |   |   +-- tests/
|   |   |   +-- questions/
|   |   |   +-- live-sessions/
|   |   |   +-- users/
|   |   |   +-- analytics/
|   |   +-- participant/             # Participant views
|   |   |   +-- join.blade.php
|   |   |   +-- lobby.blade.php
|   |   |   +-- live-quiz.blade.php
|   |   |   +-- async-test.blade.php
|   |   +-- layouts/
|   |   |   +-- admin.blade.php
|   |   |   +-- participant.blade.php
|   |   +-- auth/                    # Authentication views
|   +-- js/
|   |   +-- admin/
|   |   |   +-- live-session.js
|   |   +-- participant/
|   |       +-- live-quiz.js         # Polling logic
|   |       +-- async-test.js        # Timer logic
|   +-- css/
|       +-- app.css
+-- routes/
|   +-- web.php                      # All web routes
|   +-- api.php                      # API routes for polling
+-- config/
|   +-- test2go.php                  # Custom config
+-- public/
|   +-- index.php
|   +-- css/
|   +-- js/
|   +-- images/
+-- tests/                           # PHPUnit tests
    +-- Feature/
    +-- Unit/

Key Directories

Architecture Overview

MVC + Service Layer

Test2Go follows a clean MVC architecture with an additional service layer:

+-----------------+
|     Routes      |
+-----------------+
         |
+-----------------+
|   Controllers   |  → Validation, Authorization, Response
+-----------------+
         |
+-----------------+
|    Services     |  → Business Logic
+-----------------+
         |
+-----------------+
|     Models      |  → Data Access, Relationships
+-----------------+
         |
+-----------------+
|    Database     |
+-----------------+

Layer Responsibilities

Controllers

Services

Models

Database Schema

Entity Relationship Diagram

Database Schema

Tables Overview

1. users

Admin users table.

CREATE TABLE users (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Purpose: Store admin user accounts
Key Fields: - is_active: Prevent admin from logging in without deletion - email: Unique login identifier

2. tests

Test/quiz definitions.

CREATE TABLE tests (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    created_by BIGINT UNSIGNED NOT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    mode ENUM('live', 'async') NOT NULL,
    duration_seconds INT UNSIGNED,
    status ENUM('draft', 'published', 'archived') DEFAULT 'draft',
    join_code VARCHAR(10) UNIQUE NOT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (created_by) REFERENCES users(id)
);

Purpose: Store test configurations
Key Fields: - mode: live or async determines behavior - join_code: Unique code for participants to join - status: Control test availability - duration_seconds: Total time limit (for async mode)

3. questions

Test questions with options and answers.

CREATE TABLE questions (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    test_id BIGINT UNSIGNED NOT NULL,
    question_text TEXT NOT NULL,
    question_type ENUM('single_choice') DEFAULT 'single_choice',
    options JSON NOT NULL,
    correct_option_key VARCHAR(10) NOT NULL,
    score INT UNSIGNED DEFAULT 10,
    time_limit_seconds INT UNSIGNED,
    order_no INT UNSIGNED NOT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (test_id) REFERENCES tests(id) ON DELETE CASCADE
);

Purpose: Store questions for each test
Key Fields: - options: JSON array of answer options {"A": "Option A", "B": "Option B"} - correct_option_key: Key of the correct answer (e.g., “A”) - score: Points awarded for correct answer - time_limit_seconds: Optional per-question time limit (live mode) - order_no: Question sequence

Options JSON Format:

{
    "A": "Laravel",
    "B": "Django",
    "C": "Express.js",
    "D": "Spring Boot"
}

4. live_sessions

Live quiz session state.

CREATE TABLE live_sessions (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    test_id BIGINT UNSIGNED NOT NULL,
    status ENUM('waiting', 'active', 'finished') DEFAULT 'waiting',
    current_question_id BIGINT UNSIGNED NULL,
    current_question_order INT UNSIGNED DEFAULT 0,
    started_at TIMESTAMP NULL,
    ended_at TIMESTAMP NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (test_id) REFERENCES tests(id) ON DELETE CASCADE,
    FOREIGN KEY (current_question_id) REFERENCES questions(id)
);

Purpose: Track live quiz progression
Key Fields: - status: Session state - current_question_id: Active question being displayed - current_question_order: For navigation

5. participants

Participant records (guest users).

CREATE TABLE participants (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    test_id BIGINT UNSIGNED NOT NULL,
    live_session_id BIGINT UNSIGNED NULL,
    name VARCHAR(255) NOT NULL,
    participant_token VARCHAR(64) UNIQUE NOT NULL,
    score INT DEFAULT 0,
    joined_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (test_id) REFERENCES tests(id) ON DELETE CASCADE,
    FOREIGN KEY (live_session_id) REFERENCES live_sessions(id) ON DELETE SET NULL
);

Purpose: Store participant information
Key Fields: - participant_token: Secure token for authentication - live_session_id: NULL for async mode - score: Cached total score

6. attempts

Test attempt records.

CREATE TABLE attempts (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    test_id BIGINT UNSIGNED NOT NULL,
    participant_id BIGINT UNSIGNED NOT NULL,
    live_session_id BIGINT UNSIGNED NULL,
    mode ENUM('live', 'async') NOT NULL,
    status ENUM('in_progress', 'submitted', 'auto_submitted') DEFAULT 'in_progress',
    total_score INT DEFAULT 0,
    correct_count INT DEFAULT 0,
    wrong_count INT DEFAULT 0,
    started_at TIMESTAMP NOT NULL,
    submitted_at TIMESTAMP NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    FOREIGN KEY (test_id) REFERENCES tests(id) ON DELETE CASCADE,
    FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE CASCADE,
    FOREIGN KEY (live_session_id) REFERENCES live_sessions(id) ON DELETE SET NULL
);

Purpose: Track individual attempts
Key Fields: - status: Attempt state - total_score: Cached score for leaderboard - submitted_at: Completion timestamp

7. answers

Individual answer submissions.

CREATE TABLE answers (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    attempt_id BIGINT UNSIGNED NOT NULL,
    participant_id BIGINT UNSIGNED NOT NULL,
    question_id BIGINT UNSIGNED NOT NULL,
    selected_option_key VARCHAR(10) NOT NULL,
    is_correct BOOLEAN DEFAULT FALSE,
    score_awarded INT DEFAULT 0,
    time_taken_seconds INT NULL,
    answered_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    
    UNIQUE KEY unique_attempt_question (attempt_id, question_id),
    FOREIGN KEY (attempt_id) REFERENCES attempts(id) ON DELETE CASCADE,
    FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE CASCADE,
    FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
);

Purpose: Store individual answers
Key Fields: - selected_option_key: Participant’s answer - is_correct: Pre-calculated correctness - score_awarded: Points earned - time_taken_seconds: Response time

Unique Constraint: One answer per question per attempt

Service Layer

Services contain the business logic of Test2Go. Controllers delegate to services to keep code clean and testable.

JoinCodeService

File: app/Services/JoinCodeService.php

Responsibilities: - Generate unique join codes - Validate join codes - Check code availability

Key Methods:

class JoinCodeService
{
    /**
     * Generate a unique join code
     */
    public function generate(): string
    {
        do {
            $code = strtoupper(Str::random(6));
        } while (Test::where('join_code', $code)->exists());
        
        return $code;
    }
    
    /**
     * Validate and retrieve test by join code
     */
    public function getTestByCode(string $code): ?Test
    {
        return Test::where('join_code', $code)
            ->where('status', 'published')
            ->first();
    }
}

ScoringService

File: app/Services/ScoringService.php

Responsibilities: - Calculate answer correctness - Award points - Update attempt totals - Update participant score

Key Methods:

class ScoringService
{
    /**
     * Score a single answer
     */
    public function scoreAnswer(
        Attempt $attempt,
        Question $question,
        string $selectedOption
    ): Answer {
        $isCorrect = $selectedOption === $question->correct_option_key;
        $scoreAwarded = $isCorrect ? $question->score : 0;
        
        $answer = Answer::create([
            'attempt_id' => $attempt->id,
            'participant_id' => $attempt->participant_id,
            'question_id' => $question->id,
            'selected_option_key' => $selectedOption,
            'is_correct' => $isCorrect,
            'score_awarded' => $scoreAwarded,
            'answered_at' => now(),
        ]);
        
        $this->updateAttemptScore($attempt);
        
        return $answer;
    }
    
    /**
     * Recalculate attempt totals
     */
    public function updateAttemptScore(Attempt $attempt): void
    {
        $attempt->total_score = $attempt->answers()->sum('score_awarded');
        $attempt->correct_count = $attempt->answers()->where('is_correct', true)->count();
        $attempt->wrong_count = $attempt->answers()->where('is_correct', false)->count();
        $attempt->save();
        
        // Update participant cached score
        $attempt->participant->update([
            'score' => $attempt->total_score
        ]);
    }
}

LeaderboardService

File: app/Services/LeaderboardService.php

Responsibilities: - Generate leaderboards - Rank participants - Provide summary statistics

Key Methods:

class LeaderboardService
{
    /**
     * Get leaderboard for a test/session
     */
    public function getLeaderboard(Test $test, ?LiveSession $session = null): Collection
    {
        $query = Attempt::with('participant')
            ->where('test_id', $test->id)
            ->where('status', '!=', 'in_progress');
        
        if ($session) {
            $query->where('live_session_id', $session->id);
        }
        
        return $query->orderBy('total_score', 'desc')
            ->orderBy('submitted_at', 'asc')
            ->get();
    }
    
    /**
     * Get live leaderboard (updates in real-time)
     */
    public function getLiveLeaderboard(LiveSession $session): Collection
    {
        return Participant::where('live_session_id', $session->id)
            ->orderBy('score', 'desc')
            ->orderBy('joined_at', 'asc')
            ->get();
    }
}

LiveQuizService

File: app/Services/LiveQuizService.php

Responsibilities: - Manage live session state - Control question progression - Handle participant joining - Provide polling updates

Key Methods:

class LiveQuizService
{
    /**
     * Start a live session
     */
    public function startSession(Test $test): LiveSession
    {
        return LiveSession::create([
            'test_id' => $test->id,
            'status' => 'waiting',
            'started_at' => now(),
        ]);
    }
    
    /**
     * Start a question
     */
    public function startQuestion(LiveSession $session, int $questionOrder): void
    {
        $question = $session->test->questions()
            ->where('order_no', $questionOrder)
            ->firstOrFail();
        
        $session->update([
            'status' => 'active',
            'current_question_id' => $question->id,
            'current_question_order' => $questionOrder,
        ]);
    }
    
    /**
     * Get live state for polling
     */
    public function getLiveState(LiveSession $session): array
    {
        return [
            'status' => $session->status,
            'current_question' => $session->currentQuestion,
            'participant_count' => $session->participants()->count(),
            'time_remaining' => $this->calculateTimeRemaining($session),
        ];
    }
    
    /**
     * End session
     */
    public function endSession(LiveSession $session): void
    {
        $session->update([
            'status' => 'finished',
            'ended_at' => now(),
        ]);
        
        // Auto-submit all in-progress attempts
        Attempt::where('live_session_id', $session->id)
            ->where('status', 'in_progress')
            ->update([
                'status' => 'auto_submitted',
                'submitted_at' => now(),
            ]);
    }
}

AnalyticsService

File: app/Services/AnalyticsService.php

Responsibilities: - Generate statistics - Provide insights - Calculate metrics

Key Methods:

class AnalyticsService
{
    /**
     * Get test statistics
     */
    public function getTestStats(Test $test): array
    {
        $attempts = Attempt::where('test_id', $test->id)
            ->where('status', '!=', 'in_progress')
            ->get();
        
        return [
            'total_participants' => $attempts->count(),
            'average_score' => $attempts->avg('total_score'),
            'highest_score' => $attempts->max('total_score'),
            'lowest_score' => $attempts->min('total_score'),
            'pass_rate' => $this->calculatePassRate($attempts, $test),
        ];
    }
    
    /**
     * Get question analytics
     */
    public function getQuestionStats(Question $question): array
    {
        $answers = Answer::where('question_id', $question->id)->get();
        
        return [
            'total_answers' => $answers->count(),
            'correct_count' => $answers->where('is_correct', true)->count(),
            'wrong_count' => $answers->where('is_correct', false)->count(),
            'accuracy_rate' => $answers->count() > 0 
                ? ($answers->where('is_correct', true)->count() / $answers->count()) * 100 
                : 0,
            'average_time' => $answers->avg('time_taken_seconds'),
        ];
    }
}

Controllers

Controllers handle HTTP requests and delegate to services. They should be thin and focused on request/response handling.

Controller Structure

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTestRequest;
use App\Services\JoinCodeService;
use App\Models\Test;

class TestController extends Controller
{
    public function __construct(
        private JoinCodeService $joinCodeService
    ) {}
    
    /**
     * Display tests list
     */
    public function index()
    {
        $tests = Test::with('creator')
            ->latest()
            ->paginate(15);
        
        return view('admin.tests.index', compact('tests'));
    }
    
    /**
     * Store new test
     */
    public function store(StoreTestRequest $request)
    {
        $test = Test::create([
            ...$request->validated(),
            'created_by' => auth()->id(),
            'join_code' => $this->joinCodeService->generate(),
        ]);
        
        return redirect()
            ->route('admin.tests.show', $test)
            ->with('success', 'Test created successfully');
    }
    
    /**
     * Update test
     */
    public function update(StoreTestRequest $request, Test $test)
    {
        $test->update($request->validated());
        
        return redirect()
            ->route('admin.tests.show', $test)
            ->with('success', 'Test updated successfully');
    }
    
    /**
     * Delete test
     */
    public function destroy(Test $test)
    {
        $test->delete();
        
        return redirect()
            ->route('admin.tests.index')
            ->with('success', 'Test deleted successfully');
    }
}

Key Controller Patterns

  1. Dependency Injection: Inject services in constructor
  2. Form Requests: Use dedicated request classes for validation
  3. Resource Routes: Follow RESTful conventions
  4. Redirect with Flash: Use session flash messages
  5. Authorization: Use policies or middleware

Models & Relationships

Model Example: Test

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Test extends Model
{
    protected $fillable = [
        'created_by',
        'title',
        'description',
        'mode',
        'duration_seconds',
        'status',
        'join_code',
    ];
    
    protected $casts = [
        'duration_seconds' => 'integer',
    ];
    
    /**
     * Creator relationship
     */
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
    
    /**
     * Questions relationship
     */
    public function questions(): HasMany
    {
        return $this->hasMany(Question::class)->orderBy('order_no');
    }
    
    /**
     * Live sessions relationship
     */
    public function liveSessions(): HasMany
    {
        return $this->hasMany(LiveSession::class);
    }
    
    /**
     * Participants relationship
     */
    public function participants(): HasMany
    {
        return $this->hasMany(Participant::class);
    }
    
    /**
     * Attempts relationship
     */
    public function attempts(): HasMany
    {
        return $this->hasMany(Attempt::class);
    }
    
    /**
     * Check if test is published
     */
    public function isPublished(): bool
    {
        return $this->status === 'published';
    }
    
    /**
     * Check if test is live mode
     */
    public function isLive(): bool
    {
        return $this->mode === 'live';
    }
    
    /**
     * Check if test is async mode
     */
    public function isAsync(): bool
    {
        return $this->mode === 'async';
    }
}

Relationship Map

User (Admin)
 ├─ hasMany → Test

Test
 ├─ belongsTo → User (creator)
 ├─ hasMany → Question
 ├─ hasMany → LiveSession
 ├─ hasMany → Participant
 └─ hasMany → Attempt

Question
 └─ belongsTo → Test

LiveSession
 ├─ belongsTo → Test
 ├─ belongsTo → Question (current)
 └─ hasMany → Participant

Participant
 ├─ belongsTo → Test
 ├─ belongsTo → LiveSession (nullable)
 ├─ hasMany → Attempt
 └─ hasMany → Answer

Attempt
 ├─ belongsTo → Test
 ├─ belongsTo → Participant
 ├─ belongsTo → LiveSession (nullable)
 └─ hasMany → Answer

Answer
 ├─ belongsTo → Attempt
 ├─ belongsTo → Participant
 └─ belongsTo → Question

Frontend Architecture

Technology Stack

Layout Structure

{{-- resources/views/layouts/admin.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>@yield('title') - Test2Go Admin</title>
    
    <link href="{{ asset('css/bootstrap.min.css') }}" rel="stylesheet">
    <link href="{{ asset('css/admin.css') }}" rel="stylesheet">
    @stack('styles')
</head>
<body>
    @include('layouts.partials.admin-header')
    
    <div class="container-fluid">
        <div class="row">
            @include('layouts.partials.admin-sidebar')
            
            <main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
                @include('layouts.partials.alerts')
                
                @yield('content')
            </main>
        </div>
    </div>
    
    <script src="{{ asset('js/bootstrap.bundle.min.js') }}"></script>
    <script src="{{ asset('js/admin.js') }}"></script>
    @stack('scripts')
</body>
</html>

Polling Implementation

File: resources/js/participant/live-quiz.js

class LiveQuizPoller {
    constructor(sessionId, pollingInterval = 1000) {
        this.sessionId = sessionId;
        this.pollingInterval = pollingInterval;
        this.isPolling = false;
        this.currentQuestionId = null;
    }
    
    start() {
        this.isPolling = true;
        this.poll();
    }
    
    stop() {
        this.isPolling = false;
    }
    
    async poll() {
        if (!this.isPolling) return;
        
        try {
            const response = await fetch(`/api/live-quiz/${this.sessionId}/state`, {
                headers: {
                    'Authorization': `Bearer ${this.getParticipantToken()}`,
                    'Accept': 'application/json',
                }
            });
            
            if (response.ok) {
                const data = await response.json();
                this.handleStateUpdate(data);
            }
        } catch (error) {
            console.error('Polling error:', error);
        }
        
        // Schedule next poll
        setTimeout(() => this.poll(), this.pollingInterval);
    }
    
    handleStateUpdate(state) {
        // Update UI based on state
        if (state.status === 'finished') {
            this.stop();
            window.location.href = '/results';
            return;
        }
        
        // New question started
        if (state.current_question && state.current_question.id !== this.currentQuestionId) {
            this.currentQuestionId = state.current_question.id;
            this.displayQuestion(state.current_question);
        }
        
        // Update leaderboard
        if (state.leaderboard) {
            this.updateLeaderboard(state.leaderboard);
        }
    }
    
    getParticipantToken() {
        return localStorage.getItem('participant_token');
    }
    
    displayQuestion(question) {
        // Render question UI
        document.getElementById('question-text').textContent = question.question_text;
        // Render options...
    }
    
    updateLeaderboard(leaderboard) {
        // Update leaderboard UI
    }
}

// Initialize
const poller = new LiveQuizPoller(sessionId, parseInt('{{ config("test2go.polling_interval_ms") }}'));
poller.start();

Authentication & Authorization

Admin Authentication

Uses Laravel’s built-in authentication system.

Middleware: app/Http/Middleware/ActiveAdmin.php

public function handle(Request $request, Closure $next)
{
    if (!auth()->check()) {
        return redirect()->route('login');
    }
    
    if (!auth()->user()->is_active) {
        auth()->logout();
        return redirect()->route('login')
            ->with('error', 'Your account has been deactivated.');
    }
    
    return $next($request);
}

Participant Authentication

Token-based authentication for guest participants.

Middleware: app/Http/Middleware/ValidateParticipantToken.php

public function handle(Request $request, Closure $next)
{
    $token = $request->header('Authorization') 
        ?? $request->input('token')
        ?? $request->cookie('participant_token');
    
    if (!$token) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }
    
    $participant = Participant::where('participant_token', $token)->first();
    
    if (!$participant) {
        return response()->json(['error' => 'Invalid token'], 401);
    }
    
    // Attach to request
    $request->merge(['participant' => $participant]);
    
    return $next($request);
}

Route Protection

// routes/web.php

// Public routes
Route::get('/', [PublicController::class, 'index'])->name('home');
Route::get('/join', [JoinController::class, 'show'])->name('join.show');
Route::post('/join', [JoinController::class, 'join'])->name('join.submit');

// Admin routes
Route::middleware(['auth', 'active.admin'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    Route::resource('tests', TestController::class);
    Route::resource('tests.questions', QuestionController::class);
    Route::resource('live-sessions', LiveSessionController::class);
    Route::resource('users', UserController::class);
    Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics');
});

// Participant routes
Route::middleware('participant.token')->prefix('participant')->name('participant.')->group(function () {
    Route::get('/lobby', [LiveQuizController::class, 'lobby'])->name('lobby');
    Route::get('/live-quiz', [LiveQuizController::class, 'quiz'])->name('live-quiz');
    Route::get('/async-test', [AsyncTestController::class, 'test'])->name('async-test');
    Route::post('/submit-answer', [AsyncTestController::class, 'submitAnswer'])->name('submit-answer');
});

// Polling API
Route::middleware('participant.token')->prefix('api')->group(function () {
    Route::get('/live-quiz/{session}/state', [LiveQuizController::class, 'getState']);
});

API Endpoints

Live Quiz Polling API

Endpoint: GET /api/live-quiz/{session}/state

Authentication: Participant token

Response:

{
    "status": "active",
    "current_question": {
        "id": 5,
        "question_text": "What is Laravel?",
        "options": {
            "A": "PHP Framework",
            "B": "JavaScript Library",
            "C": "Database",
            "D": "Server"
        },
        "time_limit_seconds": 30
    },
    "time_remaining": 25,
    "participant_count": 15,
    "leaderboard": [
        {
            "name": "John Doe",
            "score": 50
        },
        {
            "name": "Jane Smith",
            "score": 40
        }
    ]
}

Submit Answer API

Endpoint: POST /api/participant/submit-answer

Authentication: Participant token

Request:

{
    "attempt_id": 10,
    "question_id": 5,
    "selected_option": "A"
}

Response:

{
    "success": true,
    "is_correct": true,
    "score_awarded": 10,
    "total_score": 50
}

Real-Time Updates

Polling Strategy

Test2Go uses AJAX polling instead of WebSockets for simplicity and compatibility.

Configuration: .env

REALTIME_MODE=polling
POLLING_INTERVAL_MS=1000

Flow:

Participant Browser          Laravel Server
      |                            |
      +--- GET /api/live/{session}/state --->|
      |                            +- Get Live State
      |                            +- Check Current Question
      |                            +- Get Leaderboard
      |<--- JSON Response -------------------|
      |                            |
      +--- Update UI               |
      |                            |
      +--- Poll (every 1s) ------->|
      |                            |

Optimization Tips

  1. Cache Session State: Use caching to reduce database queries
  2. Database Indexing: Index frequently queried fields
  3. Eager Loading: Load relationships to avoid N+1 queries
  4. Response Caching: Cache leaderboard for short periods

Configuration

Custom Configuration File

File: config/test2go.php

<?php

return [
    
    /*
    |--------------------------------------------------------------------------
    | Real-Time Mode
    |--------------------------------------------------------------------------
    |
    | Determines how live updates are delivered. Options: polling
    | Future versions may support websocket
    |
    */
    'realtime_mode' => env('REALTIME_MODE', 'polling'),
    
    /*
    |--------------------------------------------------------------------------
    | Polling Interval
    |--------------------------------------------------------------------------
    |
    | Interval in milliseconds between polling requests
    |
    */
    'polling_interval_ms' => env('POLLING_INTERVAL_MS', 1000),
    
    /*
    |--------------------------------------------------------------------------
    | Join Code Length
    |--------------------------------------------------------------------------
    |
    | Length of generated join codes
    |
    */
    'join_code_length' => 6,
    
    /*
    |--------------------------------------------------------------------------
    | Default Test Duration
    |--------------------------------------------------------------------------
    |
    | Default duration for tests in seconds
    |
    */
    'default_test_duration' => 1800, // 30 minutes
    
    /*
    |--------------------------------------------------------------------------
    | Default Question Score
    |--------------------------------------------------------------------------
    |
    | Default score for each question
    |
    */
    'default_question_score' => 10,
    
];

Testing

Running Tests

# Run all tests
php artisan test

# Run specific test
php artisan test --filter TestTest

# With coverage
php artisan test --coverage

Test Structure

tests/
+-- Feature/
|   +-- AdminAuthTest.php
|   +-- JoinTest.php
|   +-- LeaderboardServiceTest.php
|   +-- LocaleSwitchTest.php
|   +-- QuestionImportTest.php
|   +-- ScoringServiceTest.php
+-- Unit/
|   +-- JoinCodeServiceTest.php
|   +-- QuestionImportParserTest.php
+-- TestCase.php

Example Test

<?php

namespace Tests\Feature\Admin;

use Tests\TestCase;
use App\Models\User;
use App\Models\Test;
use Illuminate\Foundation\Testing\RefreshDatabase;

class TestManagementTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_admin_can_create_test()
    {
        $admin = User::factory()->create(['is_active' => true]);
        
        $response = $this->actingAs($admin)->post(route('admin.tests.store'), [
            'title' => 'Laravel Quiz',
            'description' => 'Test your Laravel knowledge',
            'mode' => 'live',
            'duration_seconds' => 1800,
        ]);
        
        $response->assertRedirect();
        $this->assertDatabaseHas('tests', [
            'title' => 'Laravel Quiz',
            'created_by' => $admin->id,
        ]);
    }
    
    public function test_inactive_admin_cannot_access_dashboard()
    {
        $admin = User::factory()->create(['is_active' => false]);
        
        $response = $this->actingAs($admin)->get(route('admin.dashboard'));
        
        $response->assertRedirect(route('login'));
        $this->assertGuest();
    }
}

Conclusion

This documentation provides a comprehensive overview of Test2Go’s architecture and implementation. For more information:


← Back to Home

Last updated: May 2026