MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Ruby on Rails + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Multithreaded Services

2

Part 2: Clean Architecture in Ruby on Rails & React: Repository Pattern, Use Cases, Result Monads & Typed Frontend Services (PR #1)

This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia
🇬🇧 English📚 Ruby on Rails + React E-Learning Rebuild

Rebuilding Laravel E-Learning to Ruby on Rails & React: Convention over Configuration, Multithreaded Puma & Cloud-Ready Migration

A comprehensive guide on rebuilding a monolithic Laravel E-Learning & CBT platform into a high-concurrency, cloud-ready architecture using Ruby 3.3, Ruby on Rails 8.1 API, React 19, Tailwind CSS v4, PostgreSQL 16, Redis 7, and Action Cable real-time proctoring.

Muhammad Fari MadyanAuthor

15 min read

·

Oct 9, 2026


The Motivation: Developer Velocity Meets Multithreaded Efficiency

Earlier in this blog series, I documented the complete evolution of my 2017 legacy junior high school examination platform from Laravel 5.2 to modern Laravel 13, Filament 3, and Livewire 3 (open-sourced at mfarim/laravel-elearning), followed by enterprise companion editions built with Java 21 Spring Boot & React and Python 3.12 FastAPI & React.

The monolithic Laravel platform remains exceptionally productive for standard school operations:

  • Filament 3 empowers administrators with instant administrative CRUD dashboards.
  • Livewire 3 brings reactive student exam timers and question switching without requiring separate frontend build toolchains.
  • Pest PHP delivers automated regression testing and rigorous IDOR security hardening.

However, as Computer Based Testing (CBT) and Learning Management Systems (LMS) scale to district-wide academic networks, inter-school assessment federations, and high-concurrency examination events, specific architectural trade-offs become evident:

  1. The "Exam Rush Hour" Concurrency Surge: When 2,000 to 10,000 candidates click "Start Exam" at precisely 08:00:00 AM, traditional synchronous PHP-FPM workers allocate one full operating system process per request (consuming 40MB–80MB resident RAM per worker). Spawning thousands of synchronous workers causes memory exhaustion, heavy process thrashing, and high infrastructure bills.
  2. Puma's Multithreaded Concurrency Advantage: Ruby on Rails 8 running on the Puma web server combines clustered worker processes with concurrent execution threads (defaulting to 5 threads per worker). This hybrid concurrency model enables Rails to handle multiple simultaneous I/O-bound database queries and network calls within a single worker process, dramatically slashing memory consumption compared to PHP-FPM.
  3. The Heritage of Convention over Configuration: Ruby on Rails pioneered the architectural idioms that inspired modern web frameworks—including Laravel itself. Active Record remains one of the most expressive, elegant Object-Relational Mappings in computer science. By utilizing Rails 8 in API-only mode (--api), we discard legacy asset pipelines, sessions, and cookie middleware to create an ultra-lean JSON backend with unmatched developer velocity.
  4. Real-Time Proctoring & Push Telemetry: Teachers supervising live examination halls require continuous telemetry: countdown timers, answered question palettes, and anti-cheat violation flags (fullscreen exit, blur events). Rails provides first-class real-time WebSocket communication through Action Cable and Redis Pub/Sub, enabling bidirectional live updates with zero client-side polling overhead.
  5. Decoupled Multi-Platform Client Ecosystem: Modern academic institutions require standalone mobile applications (iOS/Android) and modern web clients consuming unified, typed REST and WebSocket endpoints.

To address these operational demands, I engineered the fourth companion edition in this series: Ruby on Rails 8 + React E-Learning.

┌─────────────────────────────────────────────────────────────────────────────┐ │ ARCHITECTURAL EVOLUTION OVERVIEW │ ├──────────────────────────────────────┬──────────────────────────────────────┤ │ SOURCE: Monolithic Stack │ TARGET: Decoupled Rails API Stack │ │ (https://github.com/mfarim/ │ (https://github.com/mfarim/ │ │ laravel-elearning) │ ruby-rails-elearning-react) │ ├──────────────────────────────────────┼──────────────────────────────────────┤ │ • Laravel 13 & PHP 8.3 │ • Ruby 3.3+ & Ruby on Rails 8.1 API │ │ • Server-Side Blade & Livewire 3 │ • React 19 + TypeScript + Vite 6 │ │ • Filament 3 Admin Panel │ • Custom Tailwind CSS v4 Dashboards │ │ • Stateful Sessions & CSRF Cookies │ • Stateless JWT (HS256) + RBAC │ │ • Laravel Reverb / Pusher WebSockets │ • Action Cable + Redis 7 Pub/Sub │ │ • Eloquent ORM & Laravel Migrations │ • Active Record + PostgreSQL 16 │ │ • PHP-FPM Process-Per-Request │ • Puma Multithreaded Clustered Server│ │ • MySQL 8 Database │ • PostgreSQL 16 + Redis 7 Cache │ │ • Maatwebsite / PhpSpreadsheet │ • Roo Streaming Excel (.xlsx/.csv) │ │ • Pest PHP Automated Testing │ • RSpec 7 + FactoryBot + DatabaseCl. │ └──────────────────────────────────────┴──────────────────────────────────────┘

Architectural Comparison: Monolith vs. Decoupled Rails Service

Here is how each critical architectural layer transitions between the monolithic Laravel foundation and the decoupled Ruby on Rails 8 + React platform:

Architectural ConcernLaravel Monolithic EditionRuby on Rails 8 + React Edition
Language & RuntimePHP 8.3 (Interpreted / OPcache), PHP-FPM process-per-requestRuby 3.3+ with YJIT, Puma multithreaded clustered server (5 threads/worker)
Backend FrameworkLaravel 13 MonolithRuby on Rails 8.1 (API-only mode, stripped middleware)
Frontend FrameworkBlade templates + Livewire 3 reactive server componentsReact 19 SPA + TypeScript + Vite 6 + Tailwind CSS v4
Data SerializationLaravel API Resources (JsonResource)Active Record serialization with automated snake_case to camelCase mapping
AuthenticationStateful PHP session cookies + CSRF tokensStateless JWT Bearer tokens with 1-Click Administrative Impersonation Engine
AuthorizationLaravel Gate & Model Policies (#[Authorize])Controller guards (require_admin!, require_teacher!, require_student!)
Database & MigrationsMySQL 8 with Laravel Artisan migrationsPostgreSQL 16 with Active Record migrations (db:migrate)
Caching LayerRedis cache via Laravel Cache facadeRedis 7 via redis gem connection pool
Real-Time TelemetryLaravel Reverb / Echo clientAction Cable WebSockets (/cable) with Redis Pub/Sub adapter
Batch ProcessingLaravel Queues + PhpSpreadsheetroo gem streaming batch parser for student & question .xlsx templates
Spreadsheet GenerationPhpSpreadsheet / DomPDFcaxlsx gem for server-side candidate exam tickets & hall passes
Automated TestingPest PHP + Laravel TestCaseRSpec (rspec-rails 7.1) + FactoryBot + DatabaseCleaner
Code QualityLaravel Pint (PHP-CS-Fixer)RuboCop Rails Omakase (rubocop-rails-omakase)
Deployment ModelSingle container / Linux VPS systemd unitMulti-container Docker Compose (Nginx, React, Rails API, Postgres, Redis)

System Architecture Blueprint

Below is the decoupled cloud-ready architecture implemented in the Ruby on Rails 8 + React repository:

┌───────────────────────────────┐ │ CLIENT LAYER (SPA) │ │ React 19 + Tailwind CSS v4 │ │ Zustand 5 State Management │ └───────────────┬───────────────┘ │ HTTPS (REST) / WSS (Action Cable) │ ▼ ┌─────────────────────────────────────────────────────────────────────────────────────────────────┐ │ PUMA WEB SERVER (PORT 8000) │ │ Clustered Workers + Multi-Threaded │ │ │ │ ┌───────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ SECURITY & DISPATCH LAYER │ │ │ │ • Rack::Cors Middleware (Cross-Origin Protection) │ │ │ │ • JsonWebToken Service: HS256 Bearer Token Decoder & Claims Verification │ │ │ │ • Api::V1::BaseController: authenticate_user!, require_admin!, camelize_keys │ │ │ │ • 1-Click Account Impersonation (/api/v1/auth/impersonate/:user_id) │ │ │ └─────────────────────────────────────────────┬─────────────────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────────────────────┼──────────────────────────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ User & Auth │ │ Academic & │ │ CBT Exam & │ │ │ │ Controller │ │ Learning │ │ Proctoring │ │ │ │ - Students │ │ - Classrooms │ │ - Runner API │ │ │ │ - Teachers │ │ - Materials │ │ - Anti-Cheat │ │ │ │ - Impersonate│ │ - Assignment │ │ - Telemetry │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ BUSINESS LOGIC & SERVICE LAYER │ │ │ │ • ExcelService (Roo Streaming Parser for Student & Question Banks) │ │ │ │ • Examination Grading Engine (Multiple Choice, True/False & Essay Evaluation) │ │ │ │ • Caxlsx Hall Ticket & Exam Candidate Badge Generator │ │ │ └─────────────────────────────────────────────┬─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ ACTIVE RECORD ORM & PERSISTENCE │ │ │ │ • User, Student, Teacher, Classroom, Subject, Examination, Question, ExamAttempt │ │ │ │ • PostgreSQL Connection Pool (pg gem, Prepared Statements, Strict Transactions) │ │ │ └───────────────────────────────┬───────────────────────────┬───────────────────────────────┘ │ └──────────────────────────────────┼───────────────────────────┼──────────────────────────────────┘ │ │ ▼ ▼ ┌────────────────────────────┐ ┌────────────────────────────┐ │ POSTGRESQL 16 │ │ REDIS 7 │ │ Relational Persistence │ │ Action Cable Pub/Sub │ │ B-Tree Foreign Keys │ │ Session & Telemetry │ └────────────────────────────┘ └────────────────────────────┘

Core Technical Highlights & Deep Dives

Deep Dive 1: Stateless JWT Authentication & 1-Click Impersonation

In enterprise educational environments, school administrators frequently need to troubleshoot student exam issues mid-session or inspect curriculum views configured by specific teachers. Rather than requiring shared passwords or temporary database alterations, the Rails backend implements a 1-click administrative impersonation engine:

RUBY

# backend/app/services/json_web_token.rb
class JsonWebToken
  SECRET_KEY = ENV.fetch("SECRET_KEY") { 
    Rails.application.secret_key_base || "ruby-rails-elearning-secret-key-2026-super-secure" 
  }

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY, 'HS256')
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })[0]
    HashWithIndifferentAccess.new(decoded)
  rescue JWT::DecodeError
    nil
  end
end

When an administrator clicks the Impersonate button in the React dashboard, the request hits AuthController#impersonate. The Rails API verifies administrative authorization and signs a new JWT containing the target user's identity, tracking the original administrator's ID in the impersonated_by claim:

RUBY

# backend/app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < BaseController
      skip_before_action :authenticate_user!, only: [:login, :register]

      def impersonate
        require_admin!
        return if performed?

        target_user = User.find(params[:user_id])
        token = JsonWebToken.encode(user_id: target_user.id)
        payload = build_auth_payload(target_user, token, impersonated_by: current_user.id)
        json_response(data: payload, message: "Impersonation started successfully")
      end

      def stop_impersonate
        admin_user = User.find_by(role: 'ROLE_ADMIN') || current_user
        token = JsonWebToken.encode(user_id: admin_user.id)
        payload = build_auth_payload(admin_user, token)
        json_response(data: payload, message: "Returned to admin session")
      end

      private

      def build_auth_payload(user, token, impersonated_by: nil)
        {
          accessToken: token,
          refreshToken: token,
          tokenType: "Bearer",
          userId: user.id,
          name: user.name,
          email: user.email,
          roles: [user.role],
          active: user.active,
          impersonatedBy: impersonated_by || user.impersonated_by_id,
          token: token
        }
      end
    end
  end
end

To eliminate casing mismatch between Ruby's idiomatic snake_case models and React's TypeScript camelCase conventions, BaseController automatically transforms the JSON payload:

RUBY

# backend/app/controllers/api/v1/base_controller.rb
def json_response(data: nil, message: "Success", status: :ok, errors: nil)
  camelized_data = camelize_keys(data.as_json)

  render json: {
    success: [:ok, :created, 200, 201].include?(status),
    message: message,
    data: camelized_data,
    errors: errors,
    timestamp: Time.current.iso8601
  }, status: status
end

def camelize_keys(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(k, v), result|
      result[k.to_s.camelize(:lower)] = camelize_keys(v)
    end
  when Array
    obj.map { |item| camelize_keys(item) }
  else
    obj
  end
end

Deep Dive 2: High-Concurrency CBT Exam Runner & Anti-Cheat Engine

The Computer Based Testing engine in ExamRunnerController handles high-concurrency exam attempts with absolute server-side time enforcement and anti-cheat tracking:

  1. Authoritative Server Timer: The remaining exam duration is never trusted to the client clock. When the student initiates or reloads the exam, elapsed time is computed strictly on the server:

    RUBY

    elapsed = (Time.current - attempt.started_at).to_i
    remaining = [exam.duration_minutes * 60 - elapsed, 0].max
    
  2. Instant Answer Auto-Save: When a student selects an option on the React question runner, an asynchronous request immediately persists the answer using Active Record's find_or_initialize_by:

    RUBY

    # backend/app/controllers/api/v1/exam_runner_controller.rb
    def answer
      require_student!
      return if performed?
    
      attempt = ExamAttempt.find(params[:attempt_id])
      return render_error("Attempt is already submitted") if attempt.status != 'in_progress'
    
      question = Question.find(params[:questionId])
      ans = ExamAnswer.find_or_initialize_by(exam_attempt: attempt, question: question)
      ans.student_answer = params[:answer].to_s
      ans.save!
    
      json_response(data: nil, message: "Answer saved")
    end
    
  3. Anti-Cheat Violation Recording: When the React exam runner detects a window blur, tab switch, or fullscreen exit, the violation event is incremented server-side:

    RUBY

    def violation
      require_student!
      return if performed?
    
      attempt = ExamAttempt.find(params[:attempt_id])
      attempt.increment!(:violations_count)
      json_response(data: { violations: attempt.violations_count }, message: "Violation recorded")
    end
    
  4. Automated Submission & Grading Pipeline: When the timer expires or the candidate clicks Submit, multiple choice and true/false questions are evaluated automatically. If essay questions are present, the attempt status transitions to needs_grading for teacher review:

    RUBY

    def submit
      require_student!
      return if performed?
    
      attempt = ExamAttempt.find(params[:attempt_id])
      exam = attempt.examination
    
      questions = exam.questions.index_by(&:id)
      answers = attempt.exam_answers.includes(:question)
    
      total_points = questions.values.sum(&:points)
      earned_points = 0.0
      has_essay = false
    
      answers.each do |ans|
        q = ans.question
        next unless q
    
        if q.question_type == 'multiple_choice' || q.question_type == 'true_false'
          is_correct = ans.student_answer.to_s.strip.downcase == q.correct_answer.to_s.strip.downcase
          ans.update!(
            is_correct: is_correct,
            points_awarded: is_correct ? q.points : 0.0,
            graded_at: Time.current
          )
          earned_points += q.points if is_correct
        elsif q.question_type == 'essay'
          has_essay = true
        end
      end
    
      score_pct = total_points > 0 ? (earned_points / total_points * 100.0).round(2) : 0.0
      passed = score_pct >= exam.passing_score
    
      attempt.update!(
        score: score_pct,
        passed: passed,
        finished_at: Time.current,
        status: has_essay ? 'needs_grading' : 'completed',
        remaining_seconds: 0
      )
    
      json_response(data: {
        score: attempt.score,
        passed: attempt.passed,
        status: attempt.status,
        totalQuestions: questions.size,
        answeredCount: answers.size
      }, message: "Exam submitted successfully")
    end
    

Deep Dive 3: Streaming Batch Excel Processing with Roo

Enrolling hundreds of students and importing question banks with dozens of multiple-choice options must not block Puma worker threads. Using the lightweight roo gem, the backend reads .xlsx, .xls, and .csv files as streaming iterators:

RUBY

# backend/app/services/excel_service.rb
require 'roo'

class ExcelService
  def self.parse_students(file_path)
    spreadsheet = open_spreadsheet(file_path)
    header = spreadsheet.row(1).map { |h| h.to_s.strip.downcase.underscore }
    rows = []

    (2..spreadsheet.last_row).each do |i|
      row = Hash[[header, spreadsheet.row(i)].transpose]
      next if row['name'].blank? || row['nis'].blank?
      rows << row
    end

    rows
  end

  def self.parse_questions(file_path)
    spreadsheet = open_spreadsheet(file_path)
    header = spreadsheet.row(1).map { |h| h.to_s.strip.downcase.underscore }
    rows = []

    (2..spreadsheet.last_row).each do |i|
      row = Hash[[header, spreadsheet.row(i)].transpose]
      next if row['question_text'].blank?
      rows << row
    end

    rows
  end

  private

  def self.open_spreadsheet(file_path)
    ext = File.extname(file_path).downcase
    case ext
    when '.xlsx' then Roo::Excelx.new(file_path)
    when '.xls'  then Roo::Excel.new(file_path)
    when '.csv'  then Roo::CSV.new(file_path)
    else
      raise "Unsupported file format: #{ext}. Please upload .xlsx, .xls, or .csv"
    end
  end
end

In StudentsController, the imported data is wrapped in a PostgreSQL transaction (User.transaction do ... end). User accounts and associated Student profiles are provisioned atomically, ensuring zero orphaned records if any row contains invalid data:

RUBY

# backend/app/controllers/api/v1/students_controller.rb
def import_excel
  require_admin!
  return if performed?

  file = params[:file]
  return render_error("No file uploaded") unless file

  rows = ExcelService.parse_students(file.tempfile.path)
  imported_count = 0

  User.transaction do
    rows.each do |r|
      user = User.find_or_create_by!(email: "#{r['nis']}@elearning.test") do |u|
        u.name = r['name']
        u.password = "password123"
        u.role = 'ROLE_STUDENT'
        u.active = true
      end

      student = Student.find_or_initialize_by(user: user)
      student.name = r['name']
      student.email = user.email
      student.nis = r['nis']
      student.gender = r['gender'].presence || 'M'
      student.classroom_id = params[:classroom_id] if params[:classroom_id].present?
      student.active = true
      student.save!

      imported_count += 1
    end
  end

  json_response(data: { importedCount: imported_count }, message: "Imported #{imported_count} students successfully")
end

Deep Dive 4: Modern Decoupled Frontend (React 19 + TypeScript + Vite 6)

The frontend client is engineered as a modern Single Page Application (SPA) using:

  • React 19: Utilizing modern hooks and functional patterns.
  • TypeScript 5: Strict typing across all API contracts and DTOs.
  • Tailwind CSS v4: Powered by the native @tailwindcss/vite engine for zero-runtime CSS generation.
  • Zustand 5: Centralized authentication and exam state management (useAuthStore).
  • Axios Interceptors: Transparent JWT injection and automatic session restoration upon impersonation.

TYPESCRIPT

// frontend/src/services/api.ts
import axios from 'axios';
import { useAuthStore } from '../store/authStore';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
  headers: {
    'Content-Type': 'application/json',
  },
});

api.interceptors.request.use((config) => {
  const token = useAuthStore.getState().token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      useAuthStore.getState().logout();
    }
    return Promise.reject(error);
  }
);

export default api;

Student Examination Runner Screen

The student exam runner component enforces full-screen proctoring and real-time auto-saving:

  • Dynamic Question Palette: Color-coded buttons showing answered (green) vs remaining (gray) questions.
  • Synchronized Countdown: Automatically fires submitExam() when the remaining seconds reach zero.
  • Anti-Cheat Listeners: Captures document.addEventListener('visibilitychange') and window.addEventListener('blur') to increment violation counters on the Rails server.

4-Way Architectural Comparison Matrix

With four editions now completed in this series, here is how each framework stacks up across key architectural vectors:

Architectural MetricLaravel 13 MonolithJava 21 Spring Boot 3.4Python 3.12 FastAPIRuby on Rails 8.1 API
ArchitectureServer-Side MonolithDecoupled REST + SPADecoupled Async REST + SPADecoupled REST + SPA
Concurrency ModelProcess-per-request (PHP-FPM)Virtual Threads (Project Loom)Non-blocking Event Loop (ASGI)Clustered Multithreaded (Puma)
Memory per Worker40MB – 80MB200MB – 400MB (JVM Base)15MB – 35MB50MB – 90MB (5 threads)
Data Access LayerEloquent ORM (Active Record)Spring Data JPA + HibernateSQLAlchemy 2.0 AsyncpgActive Record (Ruby)
Type VerificationDynamic / PHPStan Level 8Static (Java Compile-time)Runtime Pydantic v2 (Rust engine)Dynamic + RSpec contracts
Real-Time ProtocolReverb / Pusher WebSocketsSTOMP over SockJS (/ws/cbt)Native ASGI WebSocketsAction Cable + Redis PubSub
Developer Velocity★★★★★ (Instant Filament CRUD)★★★☆☆ (Strict boilerplate)★★★★☆ (Rapid typed routing)★★★★★ (Convention over Config)
SuitabilitySingle schools, rapid MVPsHigh-security enterprise systemsAI-driven LMS & data scienceHigh-velocity scalable startups

Quick Start with Docker Compose

You can boot the entire stack (PostgreSQL 16, Redis 7, Rails 8 API, and React 19 Frontend) in under two minutes:

BASH

# 1. Clone the repository
git clone https://github.com/mfarim/ruby-rails-elearning-react.git
cd ruby-rails-elearning-react

# 2. Copy the environment configuration
cp .env.example .env

# 3. Spin up all containers
docker compose up -d --build

# 4. Stream Rails backend logs
docker compose logs -f backend

Once initialized, access the platform:

Demo Credentials

RoleEmailPassword
Administrator[email protected]password123
Teacher[email protected]password123
Student[email protected]password123

Running the Test Suite (RSpec)

The backend features an automated test suite verifying authentication, exam runners, and Active Record transactions:

BASH

cd backend
bundle install
bundle exec rspec

Conclusion & Series Next Steps

Rebuilding the Laravel E-Learning platform into Ruby on Rails 8 and React proves that developer velocity does not have to come at the expense of architectural scalability:

  • Rails 8 API Mode delivers a clean, lean JSON backend without frontend bloat.
  • Puma's multithreaded architecture easily absorbs high-concurrency exam rushes.
  • Stateless JWTs with 1-click administrative impersonation streamline exam hall support.
  • Active Record and Roo streaming ensure clean, maintainable domain code.

In the upcoming installments of this series, we will dive deeper into the database migrations, Active Record domain modeling, Action Cable real-time channels, and production deployment tuning.

Explore the open-source companion repositories:

Continue Reading

Previous article

← Previous Article

Rebuilding Laravel E-Learning to Python FastAPI & React: High-Concurrency Async Architecture & Cloud-Ready Migration

Next Article →

Fleet Management System Part 1: Introduction & System Architecture

Next article