Laravel Saas - Building a Laravel 12 Multi-Tenant, Dynamic Theming and Modular Application from A to Z
Last update: November 2025
Learning Path
Part 1: Project Initialization & Prerequisites
Part 2: Multi-Tenant Architecture
Part 3: Tenant Management
Part 4: Application Modularization
Part 5: Dynamic Theming per Tenant
Part 1: Project Initialization & Prerequisites
This course comes with a complete, open-source demo project. You can view, clone, or contribute to the source code directly on GitHub. View the project on GitHub →
Chapter 1: Installing Laravel 12 and the Base Layout
We start with the basics: creating a fresh Laravel 12 project, configuring the database, and setting up a simple layout structure with a Blade component to get ready for building our interface.
1.1. Create the Laravel Project
composer create-project laravel/laravel multitenants
1.2. Configure the Environment (.env)
Modify your .env file to connect the project to your central MySQL database.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_multitenant_central
DB_USERNAME=root
DB_PASSWORD=
1.3. Run the First Migration
Run the initial migration to create Laravel's base tables (users, etc.) in the central database.
php artisan migrate
1.4. Create the Layout Component
We create an AppLayout component that will serve as the main structure for our application's pages.
php artisan make:component AppLayout
This generates App/View/Components/AppLayout.php and resources/views/components/app-layout.blade.php. You can then structure your layout by including partials like a header and a sidebar (main-sidebar.blade.php).
Chapter 2: Integrating Redis for Cache & Sessions
To improve performance, we will configure Redis as the driver for cache, sessions, and queues.
2.1. Install the Predis Client via Composer
composer require predis/predis
2.2. Update the .env File
Modify these variables to make Laravel use Redis. Note: if the line CACHE_STORE=database is present, it must be removed to avoid conflicts.
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
2.3. Check Configuration Files
Ensure that the config/database.php and config/cache.php files are correctly set up to use these Redis environment variables.
// In config/database.php
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
'cache' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
],
],
// In config/cache.php
'stores' => [
// ...
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
// ...
],
2.4. Test the Redis Connection
Use the Redis command-line interface to ensure the server is running and responsive.
redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> exit
Part 2: Multi-Tenant Architecture
Chapter 3: Setting up Stancl/Tenancy
We install the stancl/tenancy package, which will provide us with all the tools to manage separate databases for each client (tenant).
3.1. Install the Package
composer require stancl/tenancy
php artisan tenancy:install
3.2. Create the Tenant Model
Create the file app/Models/Tenant.php. This Eloquent model will represent our clients in the central database.
<?php
namespace App\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
protected $fillable = ['id', 'data'];
protected $casts = [
'data' => 'array',
];
}
3.3. Configuration and Central Migration
Open config/tenancy.php and ensure the tenant_model key points to your new model. Then, run the migration to create the tenants table.
// In config/tenancy.php
'tenant_model' => \App\Models\Tenant::class,
php artisan migrate
Chapter 4: Tenant Migrations, Seeders, and Routing
We will now structure our application to handle migrations and routes specific to each tenant.
4.1. Tenant Migrations
Migrations for tenant tables (e.g., products, invoices...) must be placed in database/migrations/tenant. To create one:
php artisan make:migration create_eleves_table --path=database/migrations/tenant
To run these migrations on all existing tenants or on a specific tenant:
# For all tenants
php artisan tenants:migrate
# For a single tenant
php artisan tenants:migrate --tenant=school1
# To reset and re-run migrations for all tenants
php artisan tenants:migrate-fresh
4.2. Tenant Seeders
To ensure seeding commands target the correct class, configure config/tenancy.php:
// In config/tenancy.php
'seeder_parameters' => [
'--class' => 'TenantDatabaseSeeder', // root seeder class
],
Then, run the seeder:
php artisan tenants:seed
4.3. Create and configure TenancyServiceProvider
This Service Provider, included with the package, is crucial. Ensure it is created (app/Providers/TenancyServiceProvider.php) and registered in bootstrap/providers.php.
<?php
// In bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\TenancyServiceProvider::class, // Add this line
];
4.4. Route Separation
The routes/tenant.php file will contain routes accessible via a tenant's domain. The routes/web.php file is for the central application.
Example for routes/tenant.php:
<?php
// routes/tenant.php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
Route::middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])->group(function () {
Route::middleware('guest')->group(function () {
Route::get('/login', [\App\Http\Controllers\AuthController::class, 'loginView'])->name('login');
Route::post('/login', [\App\Http\Controllers\AuthController::class, 'login'])->name('login');
});
Route::middleware('auth')->group(function () {
Route::post('/logout', [\App\Http\Controllers\AuthController::class, 'logout'])->name('logout');
Route::get('/', [\App\Http\Controllers\PagesController::class, 'index'])->name('index');
});
});
Example for routes/web.php:
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('/loginadmin', [\App\Http\Controllers\AuthController::class, 'loginadmin'])->name('loginadmin');
Route::post('/loginadmin', [\App\Http\Controllers\AuthController::class, 'loginadminpost'])->name('loginadminpost');
});
Route::middleware('auth')->group(function () {
Route::post('/logoutadmin', [\App\Http\Controllers\AuthController::class, 'logoutadmin'])->name('logoutadmin');
Route::get('/indexadmin', [\App\Http\Controllers\PagesController::class, 'tenants'])->name('indexadmin');
// Add routes for the tenant CRUD
Route::resource('tenants', \App\Http\Controllers\TenantController::class)->except(['show']);
Route::get('tenants/export', [\App\Http\Controllers\TenantController::class, 'export'])->name('tenants.export');
});
4.5. Create a First Tenant for Testing
Let's use Tinker to manually create our first tenant.
php artisan tinker
# In Tinker
use App\Models\Tenant;
$tenant = Tenant::create(['id' => 'school1']);
$tenant->domains()->create(['domain' => 'school1.myapp.com']);
exit;
Don't forget to add 127.0.0.1 school1.myapp.com to your hosts file to access it locally via `http://school1.myapp.com:8000`.
Part 3: Tenant Management (Central Application)
Chapter 5: Creating the CRUD for Tenants
We will now build the interface in our central application to list, create, edit, and delete tenants. This includes the controller and the Blade views.
5.1. The Controller: TenantController.php
This controller handles the business logic for the tenant CRUD, including search, sorting, pagination, and Excel export.
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use Illuminate\Http\Request;
use App\Exports\TenantsExport;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Validation\Rule;
class TenantController extends Controller
{
public function index(Request $request)
{
$filters = [
'search' => $request->input('search', ''),
'sort_by' => $request->input('sort_by', 'created_at'),
'sort_dir' => $request->input('sort_dir', 'desc'),
'per_page' => $request->input('per_page', 10),
];
$query = Tenant::with('domains');
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('id', 'like', '%' . $filters['search'] . '%')
->orWhere('data->name', 'like', '%' . $filters['search'] . '%')
->orWhereHas('domains', function ($domainQuery) use ($filters) {
$domainQuery->where('domain', 'like', '%' . $filters['search'] . '%');
});
});
}
$query->orderBy($filters['sort_by'], $filters['sort_dir']);
$tenants = $query->paginate($filters['per_page'])->withQueryString();
return view('tenants.index', compact('tenants', 'filters'));
}
public function create()
{
return view('tenants.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'id' => 'required|string|unique:tenants|alpha_dash|min:3',
'domain' => ['required', 'string', 'unique:domains,domain', 'regex:/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'],
'data' => 'required|array',
'data.name' => 'required|string|max:255',
'data.plan' => 'nullable|string',
]);
$tenant = Tenant::create([
'id' => $validated['id'],
'data' => [
'name' => $validated['data']['name'],
'plan' => $validated['data']['plan'] ?? 'Standard',
],
]);
$tenant->createDomain(['domain' => $validated['domain']]);
return redirect()->route('tenants.index')->with('success', 'Tenant created successfully.');
}
public function edit(Tenant $tenant)
{
$tenant->load('domains');
return view('tenants.edit', compact('tenant'));
}
public function update(Request $request, Tenant $tenant)
{
$validated = $request->validate([
'domain' => [
'required', 'string',
Rule::unique('domains')->ignore($tenant->domains->first()?->id),
'regex:/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'
],
'data' => 'required|array',
'data.name' => 'required|string|max:255',
'data.plan' => 'nullable|string',
]);
$tenant->update(['data' => $validated['data']]);
if ($tenant->domains->first()) {
$tenant->domains->first()->update(['domain' => $validated['domain']]);
} else {
$tenant->createDomain(['domain' => $validated['domain']]);
}
return redirect()->route('tenants.index')->with('success', 'Tenant updated successfully.');
}
public function destroy(Tenant $tenant)
{
$tenant->delete();
return redirect()->route('tenants.index')->with('success', 'Tenant deleted successfully.');
}
public function export()
{
return Excel::download(new TenantsExport, 'tenants.xlsx');
}
}
5.2. The Blade Views
We need several views for the CRUD: one for the list (index.blade.php), one for creation (create.blade.php), one for editing (edit.blade.php), and a partial form (_form.blade.php).
resources/views/tenants/index.blade.php:
<x-app-layout title="Tenant Management">
<div class="py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto" x-data="columnsManager">
<div class="sm:flex sm:items-center sm:justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-200">Tenant Management</h2>
<p class="mt-1 text-sm text-gray-500">Manage client accounts (tenants) and their access domains.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-4 flex space-x-3 items-center">
<a href="{{ route('tenants.create') }}" class="btn bg-primary text-white">New Tenant</a>
<a href="{{ route('tenants.export') }}" class="btn bg-success text-white">Export Excel</a>
</div>
</div>
<!-- Table, filters, pagination... -->
</div>
</div>
</x-app-layout>
resources/views/tenants/_form.blade.php:
@if ($errors->any())
<div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@unless(isset($tenant))
<div class="md:col-span-2">
<label for="id" class="block text-sm font-medium text-gray-700">Tenant ID</label>
<input type="text" name="id" id="id" value="{{ old('id') }}" class="mt-1 block w-full" placeholder="e.g., acme, companyx" required>
<p class="mt-2 text-xs text-gray-500">Must be unique, no spaces or special characters (except dashes).</p>
</div>
@endunless
<div>
<label for="data_name" class="block text-sm font-medium text-gray-700">Company Name</label>
<input type="text" name="data[name]" id="data_name" value="{{ old('data.name', isset($tenant) ? $tenant->data['name'] ?? '' : '') }}" class="mt-1 block w-full" required>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-700">Main Domain</label>
<input type="text" name="domain" id="domain" value="{{ old('domain', isset($tenant) ? $tenant->domains->first()?->domain ?? '' : '') }}" class="mt-1 block w-full" placeholder="e.g., client.mydomain.com" required>
</div>
</div>
Chapter 6: Automating Database Creation
When we create a tenant through our interface, its database, tables, and initial data should be created automatically in the background. We'll use Laravel's events and jobs for this.
6.1. The `SetupTenantDatabaseListener`
This listener will listen for the TenantCreated event and delegate the heavy lifting to a queued job.
<?php
namespace App\Listeners;
use Stancl\Tenancy\Events\TenantCreated;
use App\Jobs\SetupTenantDatabase;
class SetupTenantDatabaseListener
{
public function handle(TenantCreated $event)
{
$tenantModel = $event->tenant;
if ($tenantModel->database_initialized) {
return; // already processed
}
SetupTenantDatabase::dispatch($tenantModel);
}
}
6.2. The `SetupTenantDatabase` Job
This job is responsible for running migrations and seeders for the new tenant.
<?php
namespace App\Jobs;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Database\Seeders\tenant\TenantDatabaseSeeder;
class SetupTenantDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tenant;
public function __construct($tenant)
{
$this->tenant = $tenant;
}
public function handle()
{
$tenantId = $this->tenant->id;
// 1. Migration
MigrateDatabase::dispatchSync($this->tenant);
// 2. Initialize tenant context
tenancy()->initialize($this->tenant);
// 3. Run Seeder
try {
Log::info("Job SeedTenantDatabase started for: {$tenantId}");
(new TenantDatabaseSeeder())->run();
Log::info("Seeding completed for: {$tenantId}");
} catch (\Exception $e) {
Log::error("Seeding job failed for {$tenantId}: " . $e->getMessage());
throw $e;
}
// 4. End tenant context
tenancy()->end();
}
}
6.3. Event Registration and Configuration
We register the listener in `AppServiceProvider` and configure the central domains for production.
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Events\TenantCreated;
use App\Listeners\SetupTenantDatabaseListener;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(
TenantCreated::class,
SetupTenantDatabaseListener::class,
);
}
}
Don't forget to add your production domain to the central domains list in config/tenancy.php to prevent it from being treated as a tenant.
// config/tenancy.php
'central_domains' => [
'127.0.0.1',
'localhost',
'central.manar.com', // Very important for production
],
Part 4: Application Modularization
Chapter 7: Integrating Laravel-Modules (Nwidart)
For large applications, it's better to split the code into modules (e.g., Billing, Schooling). We use the nwidart/laravel-modules package to achieve this.
7.1. Installation and Publishing
composer require nwidart/laravel-modules
php artisan vendor:publish --provider="Nwidart\Modules\LaravelModulesServiceProvider"
7.2. Configure composer.json
Add the `merge-plugin` so that Composer automatically discovers each module's dependencies.
"extra": {
"laravel": {
"dont-discover": []
},
"merge-plugin": {
"include": [
"Modules/*/composer.json"
]
}
},
7.3. Create a First Module
This command will create a Modules/School directory with a full file structure (Controllers, Models, Views, etc.).
php artisan module:make School
7.4. Reload Composer's Autoloader
After creating a module, it's crucial to update the class autoloader.
composer dump-autoload
php artisan optimize:clear
Chapter 8: Migrations and Routes in Modules
Working with modules slightly changes how we manage migrations and routes, especially in a multi-tenant context.
8.1. Tenant Migrations in a Module
For tenancy to automatically discover our modules' tenant migrations, we need to update config/tenancy.php.
// in config/tenancy.php
'migration_parameters' => [
'--force' => true,
'--path' => [
'database/migrations/tenant',
'Modules/*/database/migrations/tenant' // Add this line
],
'--realpath' => true,
],
Then, to create a tenant migration for a module, specify the path:
php artisan make:migration create_test_table --path="Modules/School/database/migrations/tenant"
8.2. Tenant Seeders in a Module
Your main tenant seeder (e.g., `TenantDatabaseSeeder.php`) can call seeders from each module.
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class TenantDatabaseSeeder extends Seeder
{
public function run(): void
{
User::factory()->create([
'name' => 'Manar',
'email' => 'manar@admin.com',
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
]);
// Call module seeders
$this->call(\Modules\School\Database\Seeders\SchoolDatabaseSeeder::class);
}
}
8.3. Module Routes and Tenant Context
To make a module's routes aware of the current tenant, create an alias for the `tenancy` middleware in bootstrap/app.php.
<?php // in bootstrap/app.php
// ...
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
]);
// ...
})
// ...
Then, in your module's routes file (e.g., Modules/School/routes/web.php), you can protect your routes like this:
<?php
use Illuminate\Support\Facades\Route;
use Modules\School\Http\Controllers\SchoolController;
Route::middleware(['web', 'tenant', 'auth'])->prefix('admin')->group(function () {
Route::get('/schools', [SchoolController::class, 'index'])->name('schools.index');
});
Part 5: Dynamic Themes per Tenant
Chapter 9: Configuring Vite.js and Theme Helpers
To offer deep customization, we will allow each tenant to have their own theme. The first step is to configure our asset bundler, Vite.js.
9.1. Update `vite.config.js`
We need to list the entry points (JS/CSS files) for each theme in Vite's configuration file.
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from 'tailwindcss';
export default defineConfig({
plugins: [
laravel({
input: [
// Central theme
'resources/css/app.css',
'resources/js/app.js',
// Theme minimalist
'resources/views/themes/minimalist/assets/css/app.css',
'resources/views/themes/minimalist/assets/js/app.js',
// Theme modern
'resources/views/themes/modern/assets/css/app.css',
'resources/views/themes/modern/assets/js/app.js',
],
refresh: true,
}),
tailwindcss(),
],
});
9.2. Create a `theme_vite()` helper
To load the correct assets in our views, we create a custom helper function. Create the app/helpers.php file and register it in composer.json to be autoloaded.
<?php
use App\Services\ThemeManager;
use Illuminate\Foundation\Vite;
use Illuminate\Support\HtmlString;
if (! function_exists('theme_vite')) {
function theme_vite(string|array $entrypoints): HtmlString
{
$themeSlug = app(ThemeManager::class)->getCurrentThemeSlug();
if (!$themeSlug) {
return new HtmlString('');
}
$projectRelativeEntrypoints = collect($entrypoints)->map(function ($entrypoint) use ($themeSlug) {
return 'resources/views/themes/' . $themeSlug . '/' . ltrim($entrypoint, '/');
})->all();
return app(Vite::class)($projectRelativeEntrypoints);
}
}
Chapter 10: Dynamic Provider and Layout Components
The brain of our theming system will be a `ThemeManager` and a `ThemeServiceProvider`. They will determine which theme to load and configure Blade accordingly.
10.1. The `ThemeManager` Service
This service's sole responsibility is to determine the current theme's slug based on a tenant's configuration.
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
class ThemeManager
{
public function getCurrentThemeSlug(): string
{
if (!tenancy()->initialized || !tenancy()->tenant) {
return 'minimalist'; // Default theme for central app or if no tenant
}
$cacheKey = 'tenant_theme_' . tenancy()->tenant->getTenantKey();
return Cache::rememberForever($cacheKey, function () {
$themeSetting = Setting::where('key', 'theme')->first()?->value;
return $themeSetting ?? 'minimalist';
});
}
}
10.2. The `ThemeServiceProvider`
This provider uses the `ThemeManager` to tell Laravel where to find the active theme's Blade views and components. Don't forget to register it in bootstrap/providers.php.
<?php
namespace App\Providers;
use App\Services\ThemeManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ThemeManager::class, function () {
return new ThemeManager();
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$themeComponentPath = resource_path('views/themes/minimalist/components');
if (is_dir($themeComponentPath)) {
Blade::anonymousComponentPath($themeComponentPath, 'theme');
}
return;
}
View::composer('*', function ($view) {
$themeManager = $this->app->make(ThemeManager::class);
$currentTheme = $themeManager->getCurrentThemeSlug();
$themeViewPath = resource_path('views/themes/' . $currentTheme);
$themeComponentPath = resource_path('views/themes/' . $currentTheme . '/components');
if (is_dir($themeViewPath)) {
$this->app['view']->prependLocation($themeViewPath);
}
if (is_dir($themeComponentPath)) {
Blade::anonymousComponentPath($themeComponentPath, 'theme');
}
});
}
}
10.3. Layout Components
Finally, components like `AppLayout` and `BaseLayout` must be modified to use the `ThemeManager` to render the layout view corresponding to the active theme.
<?php
namespace App\View\Components;
use App\Services\ThemeManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\View\Component;
class AppLayout extends Component
{
protected ThemeManager $themeManager;
protected string $themeSlug;
public function __construct(ThemeManager $themeManager)
{
$this->themeManager = $themeManager;
$this->themeSlug = $this->themeManager->getCurrentThemeSlug();
}
public function render()
{
$themePath = resource_path('views/themes/' . $this->themeSlug);
$themeViewsPath = $themePath . '/views';
$themeComponentsPath = $themePath . '/components';
if (is_dir($themeViewsPath)) {
view()->getFinder()->prependLocation($themeViewsPath);
}
if (is_dir($themeComponentsPath)) {
Blade::anonymousComponentPath($themeComponentsPath, 'theme');
}
return view('layouts.app');
}
}
Conclusion & Future Steps
Congratulations on completing this journey on Laravel Multi-Tenancy!
You have deeply explored the concepts required to build a robust and scalable SaaS application. From project initialization to modularization and dynamic theme management, you now have the skills to build complex, well-structured applications. The journey doesn't end here. Keep exploring, experimenting, and building. Practice is the key to mastery!
Explore the Source Code
The complete code for this project is available on GitHub. Feel free to clone it, study it, or even contribute!
View on GitHub