Real-World Scenario
You’re building an online learning platform where educational institutions, companies, or individual instructors create courses. Think Udemy for business, corporate training platforms, or school LMS systems. Each organization (team) needs to:- Create and manage courses and lessons
- Enroll students and track their progress
- Issue certificates upon course completion
- View analytics and student performance
- Manage instructors and content creators
The Business Model
Platform Structure:- Each institution/company gets their own workspace (team)
- Create unlimited courses with lessons and quizzes
- Enroll students (team members) in specific courses
- Track completion, scores, and issue certificates
- Instructor roles can create/edit content
- Dedicated learning environment for their organization
- Course creation and management tools
- Student enrollment and progress tracking
- Automated certificate generation
- Analytics and reporting dashboard
Database Structure
1. Courses Table
Core course information:Copy
php artisan make:migration create_courses_table
Copy
Schema::create('courses', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('instructor_id')->constrained('users');
$table->string('title');
$table->text('description');
$table->text('learning_objectives')->nullable();
$table->string('thumbnail_path')->nullable();
$table->enum('difficulty', ['beginner', 'intermediate', 'advanced']);
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
$table->unsignedInteger('duration_minutes')->default(0); // Total course duration
$table->boolean('is_featured')->default(false);
$table->unsignedInteger('passing_score')->default(70); // Percentage required to pass
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['team_id', 'status']);
$table->index(['team_id', 'instructor_id']);
});
2. Lessons Table
Individual lessons within courses:Copy
php artisan make:migration create_lessons_table
Copy
Schema::create('lessons', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('content'); // Markdown or HTML content
$table->enum('type', ['video', 'text', 'quiz', 'assignment']);
$table->string('video_url')->nullable();
$table->unsignedInteger('duration_minutes')->default(0);
$table->unsignedInteger('order')->default(0);
$table->boolean('is_free_preview')->default(false);
$table->timestamps();
$table->index(['team_id', 'course_id', 'order']);
});
3. Enrollments Table
Track student enrollments in courses:Copy
php artisan make:migration create_enrollments_table
Copy
Schema::create('enrollments', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('enrolled_by')->nullable()->constrained('users'); // Who enrolled them
$table->enum('status', ['active', 'completed', 'dropped', 'expired'])->default('active');
$table->unsignedInteger('progress_percentage')->default(0);
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->unique(['team_id', 'course_id', 'user_id']);
$table->index(['team_id', 'user_id']);
$table->index(['team_id', 'status']);
});
4. Lesson Progress Table
Track completion of individual lessons:Copy
php artisan make:migration create_lesson_progress_table
Copy
Schema::create('lesson_progress', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('enrollment_id')->constrained()->cascadeOnDelete();
$table->foreignId('lesson_id')->constrained()->cascadeOnDelete();
$table->boolean('is_completed')->default(false);
$table->unsignedInteger('time_spent_seconds')->default(0);
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('last_viewed_at')->nullable();
$table->timestamps();
$table->unique(['enrollment_id', 'lesson_id']);
$table->index(['team_id', 'lesson_id']);
});
5. Quiz Results Table
Store quiz attempts and scores:Copy
php artisan make:migration create_quiz_results_table
Copy
Schema::create('quiz_results', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('enrollment_id')->constrained()->cascadeOnDelete();
$table->foreignId('lesson_id')->constrained()->cascadeOnDelete(); // Quiz lesson
$table->json('answers'); // Student's answers
$table->unsignedInteger('score'); // Percentage 0-100
$table->unsignedInteger('questions_total');
$table->unsignedInteger('questions_correct');
$table->boolean('passed');
$table->unsignedInteger('attempt_number')->default(1);
$table->unsignedInteger('time_taken_seconds');
$table->timestamp('submitted_at');
$table->timestamps();
$table->index(['team_id', 'enrollment_id']);
$table->index(['team_id', 'lesson_id']);
});
6. Certificates Table
Generate and store course completion certificates:Copy
php artisan make:migration create_certificates_table
Copy
Schema::create('certificates', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('enrollment_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->string('certificate_number')->unique(); // e.g., CERT-2024-001234
$table->string('pdf_path')->nullable();
$table->unsignedInteger('final_score'); // Overall course score
$table->date('issued_at');
$table->date('expires_at')->nullable(); // For certifications that expire
$table->timestamps();
$table->index(['team_id', 'user_id']);
$table->index('certificate_number');
});
Complete Model Implementation
Course Model with Enrollment Logic
Copy
<?php
namespace App\Models;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Course extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'instructor_id',
'title',
'description',
'learning_objectives',
'thumbnail_path',
'difficulty',
'status',
'duration_minutes',
'is_featured',
'passing_score',
'published_at',
];
protected $casts = [
'duration_minutes' => 'integer',
'is_featured' => 'boolean',
'passing_score' => 'integer',
'published_at' => 'datetime',
];
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (Filament::getTenant()) {
$builder->where('team_id', Filament::getTenant()->id);
}
});
static::creating(function (Course $course) {
if (!$course->team_id && Filament::getTenant()) {
$course->team_id = Filament::getTenant()->id;
}
});
// Auto-publish when status changes to published
static::updating(function (Course $course) {
if ($course->isDirty('status') && $course->status === 'published' && !$course->published_at) {
$course->published_at = now();
}
});
}
// Relationships
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function instructor(): BelongsTo
{
return $this->belongsTo(User::class, 'instructor_id');
}
public function lessons(): HasMany
{
return $this->hasMany(Lesson::class)->orderBy('order');
}
public function enrollments(): HasMany
{
return $this->hasMany(Enrollment::class);
}
public function certificates(): HasMany
{
return $this->hasMany(Certificate::class);
}
// Business Logic
/**
* Check if course is published and available
*/
public function isPublished(): bool
{
return $this->status === 'published' && $this->published_at !== null;
}
/**
* Get total number of students enrolled
*/
public function getStudentCountAttribute(): int
{
return $this->enrollments()->where('status', 'active')->count();
}
/**
* Get completion rate percentage
*/
public function getCompletionRateAttribute(): float
{
$total = $this->enrollments()->count();
if ($total === 0) {
return 0;
}
$completed = $this->enrollments()->where('status', 'completed')->count();
return round(($completed / $total) * 100, 1);
}
/**
* Get average score across all completions
*/
public function getAverageScoreAttribute(): ?float
{
$certificates = $this->certificates()->avg('final_score');
return $certificates ? round($certificates, 1) : null;
}
/**
* Get total lesson count
*/
public function getLessonCountAttribute(): int
{
return $this->lessons()->count();
}
/**
* Check if user is enrolled
*/
public function isUserEnrolled(User $user): bool
{
return $this->enrollments()
->where('user_id', $user->id)
->where('status', 'active')
->exists();
}
/**
* Enroll a user in this course
*/
public function enrollUser(User $user, ?User $enrolledBy = null): Enrollment
{
return $this->enrollments()->create([
'team_id' => $this->team_id,
'user_id' => $user->id,
'enrolled_by' => $enrolledBy?->id ?? auth()->id(),
'started_at' => now(),
]);
}
/**
* Get status badge color
*/
public function getStatusColorAttribute(): string
{
return match($this->status) {
'published' => 'success',
'draft' => 'warning',
'archived' => 'danger',
default => 'gray',
};
}
/**
* Get difficulty badge color
*/
public function getDifficultyColorAttribute(): string
{
return match($this->difficulty) {
'beginner' => 'success',
'intermediate' => 'warning',
'advanced' => 'danger',
default => 'gray',
};
}
}
Enrollment Model with Progress Tracking
Copy
<?php
namespace App\Models;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Enrollment extends Model
{
protected $fillable = [
'team_id',
'course_id',
'user_id',
'enrolled_by',
'status',
'progress_percentage',
'started_at',
'completed_at',
'expires_at',
];
protected $casts = [
'progress_percentage' => 'integer',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'expires_at' => 'datetime',
];
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (Filament::getTenant()) {
$builder->where('team_id', Filament::getTenant()->id);
}
});
static::creating(function (Enrollment $enrollment) {
if (!$enrollment->team_id && Filament::getTenant()) {
$enrollment->team_id = Filament::getTenant()->id;
}
if (!$enrollment->started_at) {
$enrollment->started_at = now();
}
});
}
// Relationships
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
public function student(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function enrolledBy(): BelongsTo
{
return $this->belongsTo(User::class, 'enrolled_by');
}
public function lessonProgress(): HasMany
{
return $this->hasMany(LessonProgress::class);
}
public function quizResults(): HasMany
{
return $this->hasMany(QuizResult::class);
}
public function certificate(): HasOne
{
return $this->hasOne(Certificate::class);
}
// Business Logic
/**
* Calculate and update progress percentage
*/
public function updateProgress(): void
{
$totalLessons = $this->course->lessons()->count();
if ($totalLessons === 0) {
return;
}
$completedLessons = $this->lessonProgress()
->where('is_completed', true)
->count();
$progressPercentage = round(($completedLessons / $totalLessons) * 100);
$this->update(['progress_percentage' => $progressPercentage]);
// Check if course is complete
if ($progressPercentage === 100 && $this->status === 'active') {
$this->markAsCompleted();
}
}
/**
* Mark enrollment as completed
*/
public function markAsCompleted(): void
{
$averageScore = $this->calculateFinalScore();
$this->update([
'status' => 'completed',
'completed_at' => now(),
'progress_percentage' => 100,
]);
// Issue certificate if passing score met
if ($averageScore >= $this->course->passing_score) {
$this->issueCertificate($averageScore);
}
}
/**
* Calculate final score based on all quiz results
*/
public function calculateFinalScore(): int
{
$quizResults = $this->quizResults()
->groupBy('lesson_id')
->selectRaw('lesson_id, MAX(score) as best_score')
->get();
if ($quizResults->isEmpty()) {
return 0;
}
$averageScore = $quizResults->avg('best_score');
return (int) round($averageScore);
}
/**
* Issue certificate for course completion
*/
protected function issueCertificate(int $finalScore): Certificate
{
return Certificate::create([
'team_id' => $this->team_id,
'enrollment_id' => $this->id,
'user_id' => $this->user_id,
'course_id' => $this->course_id,
'certificate_number' => Certificate::generateCertificateNumber(),
'final_score' => $finalScore,
'issued_at' => now(),
]);
}
/**
* Check if enrollment is expired
*/
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
/**
* Get time spent on course (sum of all lesson progress)
*/
public function getTotalTimeSpentAttribute(): int
{
return $this->lessonProgress()->sum('time_spent_seconds');
}
/**
* Get formatted time spent (e.g., "2h 30m")
*/
public function getFormattedTimeSpentAttribute(): string
{
$seconds = $this->total_time_spent;
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
if ($hours > 0) {
return "{$hours}h {$minutes}m";
}
return "{$minutes}m";
}
/**
* Get status badge color
*/
public function getStatusColorAttribute(): string
{
return match($this->status) {
'completed' => 'success',
'active' => 'primary',
'dropped' => 'warning',
'expired' => 'danger',
default => 'gray',
};
}
}
Certificate Model
Copy
<?php
namespace App\Models;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Certificate extends Model
{
protected $fillable = [
'team_id',
'enrollment_id',
'user_id',
'course_id',
'certificate_number',
'pdf_path',
'final_score',
'issued_at',
'expires_at',
];
protected $casts = [
'final_score' => 'integer',
'issued_at' => 'date',
'expires_at' => 'date',
];
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (Filament::getTenant()) {
$builder->where('team_id', Filament::getTenant()->id);
}
});
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function enrollment(): BelongsTo
{
return $this->belongsTo(Enrollment::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
/**
* Generate unique certificate number
*/
public static function generateCertificateNumber(): string
{
$year = now()->year;
$random = str_pad(mt_rand(1, 999999), 6, '0', STR_PAD_LEFT);
return "CERT-{$year}-{$random}";
}
/**
* Check if certificate is expired
*/
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
/**
* Check if certificate is valid
*/
public function isValid(): bool
{
return !$this->isExpired();
}
}
Filament Resources
Course Resource
Copy
<?php
namespace App\Filament\App\Resources;
use App\Models\Course;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CourseResource extends Resource
{
protected static ?string $model = Course::class;
protected static ?string $navigationIcon = 'heroicon-o-academic-cap';
protected static ?int $navigationSort = 1;
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')
->label('Thumbnail')
->circular(),
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('instructor.name')
->sortable()
->searchable(),
Tables\Columns\BadgeColumn::make('difficulty')
->colors([
'success' => 'beginner',
'warning' => 'intermediate',
'danger' => 'advanced',
]),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => 'draft',
'success' => 'published',
'danger' => 'archived',
]),
Tables\Columns\TextColumn::make('student_count')
->label('Students')
->badge()
->color('primary'),
Tables\Columns\TextColumn::make('lesson_count')
->label('Lessons')
->badge(),
Tables\Columns\TextColumn::make('completion_rate')
->label('Completion')
->suffix('%')
->color(fn ($state) => $state >= 70 ? 'success' : ($state >= 40 ? 'warning' : 'danger')),
Tables\Columns\TextColumn::make('duration_minutes')
->label('Duration')
->formatStateUsing(fn ($state) =>
$state >= 60
? floor($state / 60) . 'h ' . ($state % 60) . 'm'
: $state . 'm'
),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
]),
Tables\Filters\SelectFilter::make('difficulty')
->options([
'beginner' => 'Beginner',
'intermediate' => 'Intermediate',
'advanced' => 'Advanced',
]),
Tables\Filters\Filter::make('featured')
->query(fn (Builder $query) => $query->where('is_featured', true))
->label('Featured only'),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('enroll_students')
->icon('heroicon-o-user-plus')
->color('success')
->form([
Forms\Components\Select::make('user_ids')
->label('Students')
->multiple()
->relationship('team.users', 'name')
->required(),
])
->action(function (Course $record, array $data) {
foreach ($data['user_ids'] as $userId) {
$user = User::find($userId);
if (!$record->isUserEnrolled($user)) {
$record->enrollUser($user);
}
}
}),
])
->defaultSort('created_at', 'desc');
}
}
Student Dashboard Widget
Copy
<?php
namespace App\Filament\App\Widgets;
use App\Models\Enrollment;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class StudentDashboardWidget extends BaseWidget
{
protected function getStats(): array
{
$user = auth()->user();
$teamId = Filament::getTenant()->id;
$activeEnrollments = Enrollment::where('user_id', $user->id)
->where('team_id', $teamId)
->where('status', 'active')
->count();
$completedCourses = Enrollment::where('user_id', $user->id)
->where('team_id', $teamId)
->where('status', 'completed')
->count();
$certificates = Certificate::where('user_id', $user->id)
->where('team_id', $teamId)
->count();
$avgProgress = Enrollment::where('user_id', $user->id)
->where('team_id', $teamId)
->where('status', 'active')
->avg('progress_percentage') ?? 0;
return [
Stat::make('Active Courses', $activeEnrollments)
->description('Currently enrolled')
->descriptionIcon('heroicon-o-academic-cap')
->color('primary'),
Stat::make('Completed', $completedCourses)
->description('Courses finished')
->descriptionIcon('heroicon-o-check-circle')
->color('success'),
Stat::make('Certificates', $certificates)
->description('Earned certificates')
->descriptionIcon('heroicon-o-trophy')
->color('warning'),
Stat::make('Average Progress', round($avgProgress) . '%')
->description('Across active courses')
->descriptionIcon('heroicon-o-chart-bar')
->color('info'),
];
}
}
Why This Example is Better
Complete Learning System
- ✅ Full course management (courses, lessons, quizzes)
- ✅ Student enrollment with progress tracking
- ✅ Automated certificate generation
- ✅ Quiz system with multiple attempts
- ✅ Instructor role management
- ✅ Time tracking per lesson
Production-Ready Features
- ✅ Soft deletes on courses
- ✅ Automatic progress calculation
- ✅ Passing score requirements
- ✅ Certificate expiration support
- ✅ Course difficulty levels
- ✅ Featured courses
Business Logic
- ✅ Completion rate calculations
- ✅ Average score tracking
- ✅ Final score from best quiz attempts
- ✅ Automatic certificate issuance
- ✅ Enrollment expiration
- ✅ Time spent formatting
Analytics & Reporting
- ✅ Student count per course
- ✅ Completion rates
- ✅ Average scores
- ✅ Progress percentages
- ✅ Time tracking
- ✅ Dashboard widgets
Next Steps
- Agency Client Portal - Client management system
- CRM System - Customer relationship management
- SaaS Platform - Subscription platform with usage tracking

