Filament for Rapid Admin Panel Development in Laravel - Part 2

Posted on 10 February 2025 Reading time: 12 min read
Filament for Rapid Admin Panel Development in Laravel - Part 2

In Part 1, we built a functional admin panel for our e-learning platform using Filament. In this second part, we’ll focus on creating a frontend using Livewire Volt to show the course details and allow students to login and view the courses they are enrolled for.

To follow along, you can either complete the admin panel from Part 1 or clone the repository from GitHub and follow the instructions in the README file.

Frontend Development with Volt

Volt is Laravel’s latest addition to the Livewire ecosystem that brings a more React-like approach to building dynamic components without needing to write JavaScript. We’ll use it to create our public-facing pages.

Setting Up Volt

First, install Volt in your project:

composer require livewire/volt
php artisan volt:install

Project Structure

Our frontend consists of three main components:

  • Course Catalog (Homepage)
  • Course Details Page
  • Student Dashboard

Building the Course Catalog

Let’s create our first Volt component for the course catalog. This shows a listing of our courses so that visitors can see which courses are available. Here, we query our database using the Course model to return all courses with their instructors, displaying pages of 12 records at a time. The result is rendered to the view using the course-catalog.blade.php file.

<?php

namespace App\Livewire;

use App\Models\Course;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Contracts\View\View;

class CourseCatalog extends Component
{
    use WithPagination;

    public function render(): View
    {
        $courses = Course::query()
            ->with('instructor')
            ->latest()
            ->paginate(12);

        return view('livewire.course-catalog', [
            'courses' => $courses
        ])->layout('components.layouts.app');
    }
}

Next, we create the corresponding view at resources/views/livewire/course-catalog.blade.php. The view is made up of a blade template which allows us to write html with some PHP to render dynamic content.

<div class="py-12">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
            @foreach ($courses as $course)
                <div class="bg-white rounded-lg shadow-lg overflow-hidden">
                    <div class="p-6">
                        <div class="flex items-center justify-between mb-4">
                            <span class="px-2 py-1 text-sm rounded {{ $course->level === 'beginner' ? 'bg-green-100 text-green-800' : ($course->level === 'intermediate' ? 'bg-blue-100 text-blue-800' : 'bg-red-100 text-red-800') }}">
                                {{ ucfirst($course->level) }}
                            </span>
                            <span class="text-gray-600">${{ $course->price }}</span>
                        </div>
                        <h3 class="text-xl font-semibold text-gray-900">{{ $course->title }}</h3>
                        <p class="mt-2 text-gray-600">{{ Str::limit($course->description, 150) }}</p>
                        <div class="mt-4 flex items-center justify-between">
                            <a href="{{ route('courses.show', $course) }}"
                               class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                                View Course
                            </a>
                        </div>
                    </div>
                </div>
            @endforeach
        </div>

        <div class="mt-8">
            {{ $courses->links() }}
        </div>
    </div>
</div>

Course Details Page

Now that we have the course listings done, let’s create a component to display the course details. Our component has a dependency, which is the Course model.

In order words, the component will always have a course attached to it. The mount function is special - it runs when the component is first created. When it receives a course, it also loads all the models associated with that course. Think of it like automatically pulling out all the chapters when you open a book.

Notice that we have added an enroll method to enroll a student in a course. This method checks if the student user is authenticated and redirects them to the login page if they are not. if they are authenticated, it allows the student to enroll in the course.

namespace App\Livewire;

use App\Models\Course;
use Livewire\Component;
use Illuminate\Contracts\View\View;

class CourseDetails extends Component
{
    public Course $course;

    public function mount(Course $course): void
    {
        $this->course = $course->load(['modules']);
    }

    public function render(): View
    {
        return view('livewire.course-details')->layout('components.layouts.app');
    }

    public function enroll()
    {
        if (! auth()->check()) {
            return redirect()->route('login');
        }

        // Enroll the student
        auth()->user()->enrolledCourses()->attach($this->course);

        return redirect()->route('student.dashboard');
    }
}

As we did for the Course catalogue page, we also need to create the view for the component at resources/views/livewire/course-details.blade.php. This allows us to display the details of the course including the instructor and modules for the course.

<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
    <div class="mb-6">
        <a href="/" class="text-blue-600 hover:text-blue-800">← Back to Courses</a>
    </div>

    <div class="lg:grid lg:grid-cols-3 lg:gap-8">
        <!-- Main Content -->
        <div class="col-span-2">
            <div class="flex items-center justify-between mb-4">
                <span class="px-3 py-1 text-sm rounded-full {{
                    $course->level === 'beginner' ? 'bg-green-100 text-green-800' :
                    ($course->level === 'intermediate' ? 'bg-blue-100 text-blue-800' : 'bg-red-100 text-red-800')
                }}">
                    {{ ucfirst($course->level) }}
                </span>
                <span class="text-2xl font-bold text-gray-900">${{ number_format($course->price, 2) }}</span>
            </div>

            <h1 class="text-3xl font-bold text-gray-900 mb-6">{{ $course->title }}</h1>

            <div class="bg-white rounded-lg shadow-sm p-6 mb-6">
                <div class="flex items-center mb-4">
                    <img src="https://ui-avatars.com/api/?name=Instructor&background=random"
                         alt="Instructor"
                         class="h-12 w-12 rounded-full">
                    <div class="ml-4">
                        <p class="text-lg font-medium text-gray-900">Instructor</p>
                        <p class="text-gray-600">Course Instructor</p>
                    </div>
                </div>
            </div>

            <div class="prose prose-blue max-w-none">
                <p class="text-gray-600">{{ $course->description }}</p>
            </div>
        </div>

        <!-- Sidebar -->
        <div>
            <div class="bg-white rounded-lg shadow-lg p-6 sticky top-6">
                <h2 class="text-xl font-semibold mb-4">Course Content</h2>
                <div class="space-y-3">
                    @foreach ($course->modules as $module)
                        <div class="flex items-center p-3 bg-gray-50 rounded-lg">
                            <span class="flex-shrink-0 h-6 w-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600">
                                {{ $loop->iteration }}
                            </span>
                            <span class="ml-3 text-gray-700">{{ $module->title }}</span>
                        </div>
                    @endforeach
                </div>

                <button wire:click="enroll"
                        class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
                    Enroll Now
                </button>
            </div>
        </div>
    </div>
</div>

The next component we wil create will be the students dashboard. This will be a private page that only authenticated users can access.

<?php

namespace App\Livewire;

use Livewire\Component;
use Illuminate\Contracts\View\View;

class StudentDashboard extends Component
{
    public function render(): View
    {
        return view('livewire.student-dashboard', [
            'enrolledCourses' => auth()->user()
                ->enrolledCourses()
                ->with(['modules'])
                ->get()
        ])->layout('components.layouts.app');
    }
}

And as previously done with other components, we create the view at resources/views/livewire/student-dashboard.blade.php.

<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
    <h1 class="text-3xl font-bold text-gray-900 mb-8">My Learning Dashboard</h1>

    @if($enrolledCourses->isEmpty())
        <div class="bg-white rounded-lg shadow-sm p-6 text-center">
            <h3 class="text-lg font-medium text-gray-900 mb-2">No courses yet</h3>
            <p class="text-gray-600 mb-4">Start your learning journey by enrolling in a course.</p>
            <a href="/" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                Browse Courses
            </a>
        </div>
    @else
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            @foreach($enrolledCourses as $course)
                <div class="bg-white rounded-lg shadow-sm overflow-hidden">
                    <div class="p-6">
                        <div class="flex items-center justify-between mb-4">
                            <span class="px-3 py-1 text-sm rounded-full {{
                                $course->level === 'beginner' ? 'bg-green-100 text-green-800' :
                                ($course->level === 'intermediate' ? 'bg-blue-100 text-blue-800' : 'bg-red-100 text-red-800')
                            }}">
                                {{ ucfirst($course->level) }}
                            </span>
                        </div>

                        <h3 class="text-xl font-semibold text-gray-900 mb-2">{{ $course->title }}</h3>

                        <!-- Progress Section -->
                        <div class="mt-4">
                            <div class="flex justify-between text-sm text-gray-600 mb-1">
                                <span>Progress</span>
                                <span>{{ $course->pivot->progress ?? 0 }}%</span>
                            </div>
                            <div class="w-full bg-gray-200 rounded-full h-2.5">
                                <div class="bg-blue-600 h-2.5 rounded-full"
                                     style="width: {{ $course->pivot->progress ?? 0 }}%">
                                </div>
                            </div>
                        </div>

                        <!-- Modules Complete -->
                        <div class="mt-4 text-sm text-gray-600">
                            {{ $course->pivot->completed_modules ?? 0 }} of {{ $course->modules->count() }} modules complete
                        </div>

                        <div class="mt-6">
                            <a href="{{ route('courses.show', $course) }}"
                               class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                                Continue Learning
                            </a>
                        </div>
                    </div>
                </div>
            @endforeach
        </div>
    @endif
</div>

Our component views need to be wrapped in a layout file. We will create a layout file resources/views/components/layouts/app.blade.php to include the header and footer of our application.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />

    <!-- Scripts -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
    <!-- Navigation -->
    <nav class="bg-white border-b border-gray-100">
        <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div class="flex justify-between h-16">
                <div class="flex">
                    <!-- Logo -->
                    <div class="flex-shrink-0 flex items-center">
                        <a href="/" class="text-xl font-bold text-gray-800">
                            {{ config('app.name', 'Laravel') }}
                        </a>
                    </div>
                </div>

                <!-- Navigation Links -->
                <div class="flex items-center">
                    @auth
                        <a href="{{ route('filament.admin.pages.dashboard') }}" class="text-gray-700 hover:text-gray-900 px-3 py-2">Admin Dashboard</a>
                        <form method="POST" action="{{ route('logout') }}">
                            @csrf
                            <button type="submit" class="text-gray-700 hover:text-gray-900 px-3 py-2">Logout</button>
                        </form>
                    @else
                        <a href="{{ route('login') }}" class="text-gray-700 hover:text-gray-900 px-3 py-2">Login</a>
                        <a href="{{ route('register') }}" class="text-gray-700 hover:text-gray-900 px-3 py-2">Register</a>
                    @endauth
                </div>
            </div>
        </div>
    </nav>

    <!-- Page Content -->
    <main>
        {{ $slot }}
    </main>
</div>

@livewireScripts
</body>
</html>

Let’s update our routes to include the new components. We’ll also create a route to show the student dashboard page which we will build later. Add the following to your routes/web.php file:

// Public routes
Route::get('/', App\Livewire\CourseCatalog::class)->name('home');
Route::get('/courses/{course}', App\Livewire\CourseDetails::class)->name('courses.show');

// Student routes
Route::middleware(['auth'])->group(function () {
    Route::get('/dashboard', StudentDashboard::class)->name('student.dashboard');
});

If you are following along, you should be able to see the course catalog and course details pages as shown below:

Course Catalog

E-learning Course Catalogue

Course Details

E-learning Course Details

Authentication and Authorization

Authentication is the process of verifying a user’s identity. It ensures that the user is who they claim to be, typically using credentials like a username and password, multi-factor authentication (MFA), or Single Sign-On (SSO).

Authorization on the other hand, is the process of determining what an authenticated user is allowed to do. It defines access control by specifying which resources or actions a user can access based on roles, permissions, or policies.

In simple terms, authentication answers “Who are you?”, while authorization answers “What are you allowed to do?”

In our case, we want to redirect the users based on their identities and roles (guest, instructor or student). We can achieve this by creating a HandleRedirects trait and using this in the login component.

<?php

namespace App\Traits;

trait HandlesRedirects
{
    public function getRedirectRoute()
    {
        if (auth()->user()->hasRole('admin')) {
            return route('filament.admin.pages.dashboard');
        }

        return route('student.dashboard');
    }
}

Next, we’ll update the Login component in resources/views/livewire/pages/auth/login.blade.php to use the HandlesRedirects trait.

<?php

use App\Livewire\Forms\LoginForm;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
use App\Traits\HandlesRedirects;

new #[Layout('layouts.guest')] class extends Component
{
    use HandlesRedirects;

    public LoginForm $form;

    /**
     * Handle an incoming authentication request.
     */
    public function login(): void
    {
        $this->validate();

        $this->form->authenticate();

        Session::regenerate();

        $this->redirectIntended(default: $this->getRedirectRoute(), navigate: true);
    }
}; ?>

<div>
    <!-- Session Status -->
    <x-auth-session-status class="mb-4" :status="session('status')" />

    <form wire:submit="login">
        <!-- Email Address -->
        <div>
            <x-input-label for="email" :value="__('Email')" />
            <x-text-input wire:model="form.email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
            <x-input-error :messages="$errors->get('form.email')" class="mt-2" />
        </div>

        <!-- Password -->
        <div class="mt-4">
            <x-input-label for="password" :value="__('Password')" />

            <x-text-input wire:model="form.password" id="password" class="block mt-1 w-full"
                          type="password"
                          name="password"
                          required autocomplete="current-password" />

            <x-input-error :messages="$errors->get('form.password')" class="mt-2" />
        </div>

        <!-- Remember Me -->
        <div class="block mt-4">
            <label for="remember" class="inline-flex items-center">
                <input wire:model="form.remember" id="remember" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
                <span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
            </label>
        </div>

        <div class="flex items-center justify-end mt-4">
            @if (Route::has('password.request'))
                <a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}" wire:navigate>
                    {{ __('Forgot your password?') }}
                </a>
            @endif

            <x-primary-button class="ms-3">
                {{ __('Log in') }}
            </x-primary-button>
        </div>
    </form>
</div>

Let’s update our routes to that authenticated users can log out and be redirected to the login page. Add the following to your routes/web.php file:

Route::post('/logout', function (Request $request) {
    Auth::logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect('/');
})->name('logout');

Remember to import the relevant classes at the top of the file:

use App\Livewire\StudentDashboard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route;

To ensure the student is redirected to the dashboard after login, we need to update the AdminPanelProvider by adding the following constant to the file App\Providers\Filament\AdminPanelProvider.php

namespace App\Providers\Filament;

use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

class AdminPanelProvider extends PanelProvider
{
    public const HOME_ROUTE = 'student.dashboard';
}

We will need to create a new migration to add a role column to the users table to differentiate between students and instructors.

php artisan make:migration add_role_to_users_table

Edit the migration file to include the role column.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('role')->default('student');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('role');
        });
    }
};

It is important that we update the User model to include the role attribute in the fillable array as well include a hasRole method to check the role of the user. While we are in the User model, let’s also add the enrolledCourses method to get all the courses a student is enrolled in.

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function instructor()
    {
        return $this->hasOne(Instructor::class);
    }

    public function student()
    {
        return $this->hasOne(Student::class);
    }

    public function hasRole($role): bool
    {
        return $this->role === $role;
    }

    public function enrolledCourses()
    {
        return $this->belongsToMany(Course::class, 'enrollments', 'student_id', 'course_id');
    }
}

Let’s add one more migration to show the progress of a student in a course. This will be a pivot table between the users and courses table.

php artisan make:migration add_progress_to_course_user_table

Edit the migration file:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('course_user', function (Blueprint $table) {
            $table->id();
            $table->foreignId('course_id')->constrained()->onDelete('cascade');
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->integer('progress')->default(0);
            $table->integer('completed_modules')->default(0);
            $table->timestamp('last_accessed_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('course_user');
    }
};

Run the migrations:

php artisan migrate

if you haven’t done so already, make sure you have compiled your assets by running:

npm install
npm run dev

If you followed along, you should now have a functional e-learning platform with an admin panel and a frontend for students to view courses and enroll.

In this two-part tutorial, we’ve built a complete e-learning platform that demonstrates the power of modern Laravel development tools.

While Part 1 focused on rapidly creating an admin panel with Filament, Part 2 showed how to build an engaging frontend using Livewire Volt. We’ve covered essential features including:

  • A responsive course catalog with pagination
  • Detailed course pages with module listings
  • Student authentication and role-based authorization
  • Personalized student dashboard showing course progress
  • Integration between the admin and student interfaces

The combination of Filament for administration and Livewire Volt for the frontend provides a seamless full-stack development experience while maintaining Laravel’s elegant syntax and conventions. This architecture allows for rapid development without sacrificing maintainability or user experience.

Remember that this implementation represents a starting point - you can build upon it by adding features like video streaming, quiz functionality, or integration with payment gateways depending on your specific needs.

The complete source code is available on GitHub. If you find any bugs or run into issues during development, feel free to open an issue or reach out to me on Twitter at @stpopoola.