To build a Laravel LMS, you need five pieces: a course structure (courses, sections, and lessons), enrollments that control access, progress tracking, a quiz engine that grades on the server, and payments through Laravel Cashier and Stripe. With an AI app builder generating the models, migrations, and admin screens, a working learning management system in Laravel can take days, not months.

Here's the uncomfortable part. Most course creators start on Teachable or Thinkific, and that's a fine choice for a first course. But the moment a client wants custom certificates, cohort pricing, a B2B seat model, or their own branding everywhere, a hosted platform starts saying no.

That's when founders, freelancers, and agencies look at building their own. This guide shows you how to build an LMS in Laravel, covering the database schema, access control, lesson progress, quizzes, Stripe payments, video hosting, and where AI saves you the most time.

Key Takeaways

  • A Laravel LMS needs about 10 tables: courses, sections, lessons, enrollments, lesson_completions, quizzes, questions, options, quiz_attempts, and attempt_answers.

  • Enrollment is the access key. Every lesson, quiz, and video URL should check one policy: is this user enrolled in this course?

  • Grant access from the Stripe webhook, not the success page. Listen for checkout.session.completed and create the enrollment with firstOrCreate so retries never duplicate it.

  • Grade quizzes on the server. Never send is_correct flags to the browser.

  • AI handles the boilerplate. Tools like StackMivo generate the models, migrations, policies, and admin CRUD, so you can spend your time on the parts that make your course platform different.

The people in this article's examples are illustrative composites based on common Laravel projects.

Why Build a Laravel LMS Instead of Using Teachable?

Hosted course platforms are quick to set up. You pay a monthly fee, upload videos, and start selling. The trade-off is control: you work within their checkout, page builder, and data model.

A custom learning management system in Laravel makes sense when one of these is true:

  • You sell to businesses. Team seats, invoices, and SSO rarely fit a creator-focused platform.

  • The course is part of a product. A SaaS onboarding academy or members-only training hub should live inside your app, not on a separate domain.

  • You're an agency. Clients want ownership of the code and data, and you want a reusable LMS base you can rebrand.

  • Fees add up. Transaction fees and tier-based pricing get expensive as revenue grows.

Here's a quick comparison:

Option

Best for

Trade-off

Teachable / Thinkific

Solo creators testing a first course

Limited custom logic, platform fees

WordPress + LMS plugin

Content-heavy sites already on WordPress

Plugin sprawl, harder custom features

Moodle

Schools and universities

Heavy, dated UX for commercial courses

Custom Laravel LMS

Products, B2B training, agency builds

You maintain the code

Daniel runs a small agency in Leeds. In March, a compliance-training client asked for per-employee progress reports, manager dashboards, and annual recertification. Their hosted platform couldn't support the manager view at all. Daniel quoted a custom Laravel build instead, reused the same codebase for two more clients by summer, and turned a one-off project into a product.

If you already run a paid community, the patterns overlap heavily with our guide to building a membership site in Laravel. An LMS is a membership site with structure and progress added.

Want to see the scaffold before writing a line? Describe your course platform in plain English, and StackMivo generates the Laravel models, migrations, and admin screens for you.

The Laravel LMS Data Model

Get the schema right, and everything else becomes more straightforward. Here's the core structure.

Courses, Sections, and Lessons

// database/migrations/xxxx_create_courses_table.php
Schema::create('courses', function (Blueprint $table) {
    $table->id();
    $table->foreignId('instructor_id')->constrained('users');
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('stripe_price_id')->nullable(); // null = free course
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});

Schema::create('sections', function (Blueprint $table) {
    $table->id();
    $table->foreignId('course_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->unsignedInteger('position'); // controls display order
    $table->timestamps();
});

Schema::create('lessons', function (Blueprint $table) {
    $table->id();
    $table->foreignId('section_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->longText('content')->nullable();
    $table->string('video_id')->nullable(); // ID at your video host, not a file path
    $table->boolean('is_preview')->default(false); // free sample lesson
    $table->unsignedInteger('position');
    $table->timestamps();
});

Store a position column on sections and lessons. Instructors will want to reorder content, and sorting by id makes that impossible.

Enrollments and Progress

Schema::create('enrollments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('course_id')->constrained()->cascadeOnDelete();
    $table->string('source')->default('manual'); // stripe, manual, coupon, team
    $table->string('stripe_checkout_id')->nullable()->unique();
    $table->timestamp('expires_at')->nullable(); // null = lifetime access
    $table->timestamps();
    $table->unique(['user_id', 'course_id']); // one enrollment per course
});

Schema::create('lesson_completions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('lesson_id')->constrained()->cascadeOnDelete();
    $table->timestamp('completed_at');
    $table->unique(['user_id', 'lesson_id']);
});

The unique indexes matter. They prevent duplicate enrollments and completions at the database level, even if your code has a bug.

Access Control: Who Can Watch Which Lesson?

Access control is critical to every Laravel LMS. One policy should answer the same question for lessons, quizzes, and video URLs.

// app/Policies/LessonPolicy.php
class LessonPolicy
{
    public function view(?User $user, Lesson $lesson): bool
    {
        // Free preview lessons are public
        if ($lesson->is_preview) {
            return true;
        }

        if (! $user) {
            return false;
        }

        $courseId = $lesson->section->course_id;

        return $user->enrollments()
            ->where('course_id', $courseId)
            ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
            ->exists();
    }
}

Then authorize in the controller:

public function show(Course $course, Lesson $lesson)
{
    $this->authorize('view', $lesson);

    return view('lessons.show', compact('course', 'lesson'));
}

The nullable ?User type lets guests reach the policy, which is what makes free previews work. See the Laravel authorization docs for guest-user policy rules.

Also make sure the lesson actually belongs to the course in the URL. Scoped route bindings (->scopeBindings()) handle this, so /courses/php-basics/lessons/42 returns a 404 if lesson 42 belongs to a different course.

Tracking Progress

Marking a lesson complete is one idempotent call:

public function complete(Request $request, Lesson $lesson)
{
    $this->authorize('view', $lesson);

    LessonCompletion::firstOrCreate(
        ['user_id' => $request->user()->id, 'lesson_id' => $lesson->id],
        ['completed_at' => now()],
    );

    return back();
}

Course progress is the number of completed lessons divided by the total number of lessons. Cache it per user if your course pages receive heavy traffic.

Building a Quiz System in Laravel

Quizzes need five tables: quizzes (attached to a lesson or section, with a pass_mark), questions, options (with an is_correct flag), and quiz_attempts plus attempt_answers to record each student's choices.

Grade on the Server, Always

One of the most common LMS security bugs is sending correct answers to the browser. If your JSON includes is_correct, anyone with browser developer tools can pass every quiz. Load options for display without that column, and grade after submission:

public function submit(Request $request, Quiz $quiz)
{
    $this->authorize('take', $quiz);

    $validated = $request->validate([
        'answers'   => ['required', 'array'],
        'answers.*' => ['integer', 'exists:options,id'],
    ]);

    $questions = $quiz->questions()->with('options')->get();
    $correct = 0;

    foreach ($questions as $question) {
        $chosenId = $validated['answers'][$question->id] ?? null;

        // Only accept an option that belongs to this question
        $chosen = $question->options->firstWhere('id', $chosenId);

        if ($chosen?->is_correct) {
            $correct++;
        }
    }

    $score = $questions->count() > 0
        ? (int) round($correct / $questions->count() * 100)
        : 0;

    $attempt = $quiz->attempts()->create([
        'user_id' => $request->user()->id,
        'score'   => $score,
        'passed'  => $score >= $quiz->pass_mark,
    ]);

    return redirect()->route('quiz-attempts.show', $attempt);
}

Note the check that the chosen option belongs to the question. Without it, a student could submit a correct option ID from a different question.

Practical Quiz Rules

  • Limit attempts with a max_attempts column and a count check in the policy.

  • Randomize question order with ->inRandomOrder() when displaying the quiz, not when grading it.

  • Gate the next section on a passed attempt if the course needs a strict path.

  • Issue certificates when all lessons are complete and all required quizzes are passed. A queued job that renders a PDF works well.

Aisha built a nursing exam-prep course in Laravel and shipped her first quiz with answers embedded in a Vue component. Within a week, a student posted a 100% score screenshot in the community before watching a single lesson. Moving grading to the controller above took an afternoon, and pass rates dropped to a believable 68%.

Taking Payments with Laravel Cashier and Stripe

For one-off course sales, Laravel Cashier's Stripe Checkout support is the shortest path. Customers pay on Stripe's hosted Checkout page, so card data never touches your server. Our Laravel Cashier subscription billing guide covers installation and recurring plans in depth.

Start a Checkout Session

// routes/web.php
Route::post('/courses/{course}/buy', function (Request $request, Course $course) {
    abort_if(! $course->stripe_price_id, 404);

    return $request->user()->checkout([$course->stripe_price_id => 1], [
        'success_url' => route('courses.show', $course).'?checkout=success',
        'cancel_url'  => route('courses.show', $course),
        'metadata'    => ['course_id' => $course->id],
    ]);
})->middleware('auth')->name('courses.buy');

The Laravel billing docs show this checkout() method for single charges. The metadata travels with the session, so you know which course was purchased.

Grant Access from the Webhook

Don't enroll the student on the success page. Users might close their tabs, and anyone can visit a URL. Grant access when Stripe confirms payment:

// app/Listeners/GrantCourseAccess.php
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;

class GrantCourseAccess
{
    public function handle(WebhookReceived $event): void
    {
        // completed: instant payments; async_payment_succeeded: delayed methods like bank debits
        $handled = ['checkout.session.completed', 'checkout.session.async_payment_succeeded'];

        if (! in_array($event->payload['type'], $handled, true)) {
            return;
        }

        $session = $event->payload['data']['object'];

        // A completed session can still be unpaid for delayed methods; wait for the async event
        if (($session['payment_status'] ?? null) !== 'paid') {
            return;
        }

        $user = Cashier::findBillable($session['customer']);
        $courseId = $session['metadata']['course_id'] ?? null;

        if (! $user || ! $courseId) {
            return;
        }

        // Idempotent: Stripe retries webhooks, so never create twice
        Enrollment::firstOrCreate(
            ['user_id' => $user->id, 'course_id' => $courseId],
            ['source' => 'stripe', 'stripe_checkout_id' => $session['id']],
        );
    }
}

A few production details:

  • Exclude stripe/* from CSRF checks in bootstrap/app.php, as the Cashier docs describe, so Stripe can reach the webhook.

  • Set STRIPE_WEBHOOK_SECRET so Cashier verifies webhook signatures. Never process unsigned webhooks.

  • Subscribe your Stripe webhook to both checkout events the listener handles. Cashier's default webhook events focus on subscriptions, so check that checkout.session.completed and checkout.session.async_payment_succeeded are enabled for your endpoint.

  • Log every webhook you skip, so missing enrollments are easy to trace.

Subscriptions Instead of One-Off Sales

For an "all courses" membership, replace the one-off checkout with newSubscription('default', $priceId)->checkout() and check $user->subscribed('default') inside the lesson policy. Many platforms support both individual course purchases and an all-access plan.

Payments, policies, and webhooks are the fiddly part. Try StackMivo free and have the Cashier integration, enrollment model, and access policy generated together, then review and adjust the code yourself.

Video Hosting and Performance

Don't store course videos on your web server or in a public S3 bucket. Anyone with the URL can download and share them, and you'll pay for every byte.

Use a video platform with signed playback URLs, such as Mux, Vimeo, or Bunny Stream. Store only the provider's video_id on the lesson. When an enrolled student opens a lesson, your controller asks the provider for a short-lived signed URL after the policy check passes.

Other performance habits can help keep a Laravel course platform fast:

  • Eager load the curriculum: $course->load('sections.lessons') avoids N+1 queries on the course page.

  • Queue heavy work: Certificate PDFs, progress emails, and video-processing callbacks belong in jobs.

  • Cache published course pages for guests, and clear the cache when an instructor edits content.

  • Seed realistic data early. A course with 12 sections and 150 lessons can reveal layout and query problems that three test lessons hide. Our Laravel seeders and factories guide explains how.

How AI Speeds Up Your Laravel LMS Build

An LMS is mostly made up of well-known patterns: CRUD for courses and lessons, an admin panel, policies, a checkout flow, and progress tracking. That's exactly the kind of work AI app builders handle best.

With StackMivo, you describe the app in plain English. A prompt like this is a solid starting point:

"Build a Laravel LMS. Instructors create courses with ordered sections and lessons. Lessons can be free previews. Students buy courses through Stripe Checkout using Laravel Cashier, and enrollment is granted from the checkout.session.completed webhook. Track lesson completion and course progress. Add multiple-choice quizzes graded on the server with a pass mark and max attempts. Include an admin dashboard for courses, students, and revenue."

From there, AI generates the models, migrations, relationships, policies, and admin screens. You still own the code, so you can review it, run the tests, and change anything you disagree with.

Where you should spend your own time:

  • Your differentiator. Cohorts, B2B seats, drip schedules, or certificates with verification links.

  • Security review. Confirm the lesson policy, quiz grading, and webhook handling match the patterns above.

  • Content and UX. The course experience is what students remember.

Priya, a freelance developer in Pune, quoted six weeks for a coding bootcamp's LMS in 2025. On her next LMS project, she generated the base app with AI on day one, spent the first week on the client's cohort scheduling and mentor feedback features, and delivered in under three weeks. She charged the same price, spent half the hours, and delivered the custom features first.

Laravel LMS Launch Checklist

Before you open enrollment, check each item:

  1. Lesson, quiz, and video routes all run through one enrollment-based policy.

  2. Scoped route bindings stop students from guessing lesson IDs across courses.

  3. Quiz answers are graded on the server, and is_correct never reaches the browser.

  4. Enrollments come from verified Stripe webhooks and are created idempotently.

  5. Videos use signed, expiring playback URLs.

  6. Progress, certificates, and emails run in queued jobs.

  7. Refunds are handled. Decide whether a refund revokes access, and listen for charge.refunded if it does.

  8. Backups are scheduled and tested before the first paying student signs up.

Frequently Asked Questions

Is Laravel good for building an LMS?

Yes. Laravel provides authentication, authorization policies, queues, Cashier billing, and a mature ecosystem out of the box. These cover most of what a learning management system needs, so you can build features instead of plumbing.

How long does it take to build an LMS in Laravel?

A basic Laravel LMS with courses, lessons, progress, and Stripe payments usually takes a developer two to six weeks to build by hand. With an AI builder generating the scaffold, the core can be running in a few days, leaving time for custom features.

Can I sell courses with Laravel Cashier?

Yes. Use Cashier's Stripe Checkout support for one-time course purchases or subscriptions for all-access plans. Grant course access from the checkout.session.completed webhook rather than the success page.

Is there an open-source Laravel LMS I can start from?

Several community projects exist, but check how recently each one was updated and whether it supports your Laravel version before committing. Many teams find it faster to generate a clean, current codebase and own every line.

How do I protect course videos from being downloaded?

Host videos on a provider that supports signed, expiring playback URLs, and generate those URLs only after your lesson policy confirms that the student is enrolled. No setup makes screen recording impossible, but signed URLs help prevent casual link sharing.

Conclusion

A Laravel LMS isn't one big feature. It's a handful of well-understood pieces that fit together:

  • A clean schema for courses, sections, lessons, and enrollments

  • One access policy that protects lessons, quizzes, and videos

  • Server-side quiz grading

  • Stripe payments that grant access from verified webhooks

  • Signed video URLs and queued background work

Build those pieces correctly, and you have a learning platform you fully control, ready for B2B seats, cohorts, or whatever your clients ask for next.

The fastest way to get there is to let AI write the repetitive parts while you focus on what makes your course platform worth paying for. Start building your Laravel LMS with StackMivo, free with 20 credits →