Skip to content

Latest commit

Β 

History

81 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸŽ“ Web Akademik Terintegrasi Teknik Komputer

Laravel Modular Monolith Architecture

Laravel Supabase PostgreSQL PHP

Sistem terintegrasi berbasis Laravel Modular Monolith yang terdiri dari empat aplikasi akademik dalam satu proyek terpusat.

πŸ“– Documentation β€’ πŸš€ Quick Start β€’ πŸ—οΈ Architecture β€’ ⚑ Performance β€’ 🀝 Contributing


πŸ“‹ Table of Contents


🀝 Contributor

Development Team

  • Project Lead: Bimo Kusumo Putro Wicaksono
  • Bank Soal: Dzaki Eka Atmaja, Evan Adkara Christian P, Nabil Bintang Ardiansyah P.
  • Capstone + TA: Ananda Prida Yusuf S, Fayyadh Muhammad Habibie, Muhammad Riza Saputra
  • E-Office: Andhinee Clarisaa Tanasale, Cetta Masinda Amany, Elvina Nasywa Ariyani
  • Manajemen Kemahasiswaan + KP: Devarlo Rahadyan Razan, Muhammad Reswara Suryawan, Surya Hari Putra, Syahbana Hatab

🌟 Overview

Web Akademik Terintegrasi Teknik Komputer adalah platform akademik terpusat yang dibangun dengan Laravel Modular Architecture dan Supabase sebagai database backend, menggabungkan empat sistem utama dalam satu codebase untuk efisiensi dan konsistensi data.

✨ Key Features

  • 🎯 Modular Architecture - Setiap modul independen namun terintegrasi
  • 🟒 Supabase Backend - PostgreSQL hosting dengan realtime features
  • πŸ—„οΈ Single Database - Satu database PostgreSQL terpusat
  • πŸ” Role-Based Access Control - 4 level user roles
  • ⚑ Optimized Performance - Redis caching + persistent DB connection
  • πŸ”„ Scalable Design - Mudah dikembangkan ke microservices

πŸ“¦ System Modules

πŸ“˜ Capstone + TA

Manajemen topik, bimbingan, workflow, dan evaluasi tugas akhir

πŸ“— Bank Soal

Sistem manajemen bank soal dan ujian online

πŸ“™ Kemahasiswaan

Kegiatan, organisasi, dan administrasi mahasiswa

πŸ“• E-Office

Surat menyurat dan manajemen dokumen internal


πŸ—οΈ System Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Client (Browser/Mobile)             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      Laravel Application (Monolith)         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚         Core (Global Layer)           β”‚  β”‚
β”‚  β”‚  β€’ users  β€’ students  β€’ lecturers     β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚          Module Layer                 β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚  β”‚
β”‚  β”‚  β”‚ Capstone β”‚  β”‚ BankSoal β”‚          β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚  β”‚
β”‚  β”‚  β”‚Kemahasis-β”‚  β”‚ EOffice  β”‚          β”‚  β”‚
β”‚  β”‚  β”‚  waan    β”‚  β”‚          β”‚          β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β–Ό                       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ πŸ”΄ Redis    β”‚     β”‚ 🟒 Supabase          β”‚
β”‚ (Cache +    β”‚     β”‚ (PostgreSQL Database) β”‚
β”‚  Session)   β”‚     β”‚                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“‚ Project Structure

WebsiteTekkom/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ Http/
β”‚   β”‚   └── Middleware/
β”‚   β”‚       └── CheckRole.php     # Role-based access dengan Redis cache
β”‚   β”œβ”€β”€ Models/
β”‚   β”‚   └── User.php              # getCachedRoles(), cacheUserData()
β”‚   └── Providers/
β”‚       └── AppServiceProvider.php # Cached Eloquent User Provider
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ auth.php                  # cached-eloquent driver
β”‚   β”œβ”€β”€ database.php              # PDO persistent connection
β”‚   └── cache.php
β”œβ”€β”€ database/
β”‚   β”œβ”€β”€ migrations/
β”‚   └── seeders/
β”‚       └── DatabaseSeeder.php    # Global seeder entry point
β”œβ”€β”€ Modules/
β”‚   β”œβ”€β”€ Capstone/
β”‚   β”‚   └── Database/
β”‚   β”‚       └── Seeders/
β”‚   β”œβ”€β”€ BankSoal/
β”‚   β”‚   └── Database/
β”‚   β”‚       └── Seeders/
β”‚   β”œβ”€β”€ Kemahasiswaan/
β”‚   β”‚   └── Database/
β”‚   β”‚       └── Seeders/
β”‚   └── EOffice/
β”‚       └── Database/
β”‚           └── Seeders/
β”œβ”€β”€ routes/
β”œβ”€β”€ .env.example
β”œβ”€β”€ composer.json
└── README.md

πŸš€ Quick Start

Prerequisites

  • Laravel >= 12
  • PHP >= 8.2.12
  • Composer >= 2.9.5
  • Supabase Account (or PostgreSQL >= 14)
  • Node.js >= 18 (optional, for frontend assets)
  • Redis (Memurai untuk Windows, atau Redis untuk Linux/Mac)

1️⃣ Clone Repository

git clone https://github.com/bimo3058/WebsiteTekkom.git
cd WebsiteTekkom

2️⃣ Install Dependencies

composer install
composer require predis/predis

3️⃣ Environment Setup

cp .env.example .env
php artisan key:generate

4️⃣ Configure Database & Redis

Edit .env:

# Database - Supabase Singapore (ap-southeast-1)
DB_CONNECTION=pgsql
DB_HOST=aws-0-ap-southeast-1.pooler.supabase.com
DB_PORT=6543
DB_DATABASE=postgres
DB_USERNAME=postgres.your-project-ref
DB_PASSWORD=your-supabase-password

# Redis Cache
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

5️⃣ Run Migrations

php artisan migrate

6️⃣ Add Database Indexes

Jalankan di Supabase SQL Editor untuk optimasi query:

-- Index untuk login query
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_active
    ON users(email) WHERE deleted_at IS NULL;

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_id_active
    ON users(id) WHERE deleted_at IS NULL;

-- Index untuk roles lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_roles_user_id
    ON user_roles(user_id);

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_roles_name
    ON roles(name);

-- Index untuk capstone
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_capstone_groups_period_status
    ON capstone_groups(period_id, status) WHERE deleted_at IS NULL;

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_capstone_titles_status
    ON capstone_titles(status, approved_by_admin) WHERE deleted_at IS NULL;

-- Index untuk bank soal
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pertanyaan_mk_status
    ON bs_pertanyaan(mk_id, status);

7️⃣ Update Auth Config

Edit config/auth.php:

'providers' => [
    'users' => [
        'driver' => 'cached-eloquent', // pakai cached provider
        'model'  => App\Models\User::class,
    ],
],

8️⃣ Start Development Server

# Start Redis dulu (pastikan Memurai jalan di Windows)
# Lalu jalankan Laravel
composer run dev

Visit: http://localhost:8000


⚑ Performance Configuration

Sistem ini telah dioptimasi dari response time ~3200ms β†’ <1000ms melalui beberapa teknik berikut.

Hasil Optimasi

Request Sebelum Sesudah Improvement
POST /login ~3200ms ~400ms 87% faster
GET /dashboard ~2580ms <1000ms ~60% faster
GET /superadmin/dashboard ~2580ms <1000ms ~60% faster

1. Persistent Database Connection

File: config/database.php

Tambahkan PDO::ATTR_PERSISTENT di konfigurasi pgsql agar koneksi ke Supabase di-reuse antar request, menghilangkan overhead TCP handshake (~500ms) di setiap request.

'pgsql' => [
    'driver'         => 'pgsql',
    'url'            => env('DB_URL'),
    'host'           => env('DB_HOST', '127.0.0.1'),
    'port'           => env('DB_PORT', '5432'),
    'database'       => env('DB_DATABASE', 'laravel'),
    'username'       => env('DB_USERNAME', 'root'),
    'password'       => env('DB_PASSWORD', ''),
    'charset'        => env('DB_CHARSET', 'utf8'),
    'prefix'         => '',
    'prefix_indexes' => true,
    'search_path'    => 'public',
    'sslmode'        => 'require',
    'options'        => [
        PDO::ATTR_PERSISTENT => true,  // reuse koneksi antar request
        PDO::ATTR_TIMEOUT    => 10,
    ],
],

2. Cached Eloquent User Provider

File: app/Providers/AppServiceProvider.php

Laravel memanggil retrieveById() di setiap request untuk re-authenticate user dari session. Override ini mengambil user dari Redis (~1ms) bukan DB (~1150ms).

use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;

public function register(): void
{
    Auth::resolved(function ($auth) {
        $auth->provider('cached-eloquent', function ($app, array $config) {
            return new class($app['hash'], $config['model']) extends EloquentUserProvider {
                public function retrieveById($identifier): ?Authenticatable
                {
                    $cacheKey = "user:{$identifier}:data";
                    $cached   = Cache::get($cacheKey);

                    if ($cached) {
                        $model = $this->createModel();
                        return $model->newFromBuilder($cached);
                    }

                    $user = parent::retrieveById($identifier);
                    if ($user) {
                        Cache::put($cacheKey, $user->withoutRelations()->toArray(), now()->addHours(8));
                    }

                    return $user;
                }
            };
        });
    });

    // ... singleton registrations
}

Daftarkan di config/auth.php:

'providers' => [
    'users' => [
        'driver' => 'cached-eloquent',
        'model'  => App\Models\User::class,
    ],
],

3. Cached Roles di User Model

File: app/Models/User.php

hasRole(), hasAnyRole(), hasAllRoles() mengambil roles dari Redis cache bukan query DB setiap kali dipanggil.

use Illuminate\Support\Facades\Cache;

// Relasi dengan select kolom spesifik
public function roles()
{
    return $this->belongsToMany(Role::class, 'user_roles')
                ->select('roles.id', 'roles.name', 'roles.module');
}

// Semua role helper pakai getCachedRoles()
public function hasRole(string $roleName, ?string $module = null): bool
{
    return $this->getCachedRoles()
        ->when($module, fn($c) => $c->where('module', $module))
        ->contains('name', strtolower($roleName));
}

protected function getCachedRoles(): \Illuminate\Support\Collection
{
    if ($this->relationLoaded('roles')) {
        return collect($this->roles);
    }

    $cached = Cache::get("user:{$this->id}:roles");
    if ($cached) {
        return collect($cached);
    }

    $roles = $this->roles()->get();
    Cache::put("user:{$this->id}:roles", $roles->toArray(), now()->addHours(8));

    return $roles;
}

// Cache semua data user setelah login
public function cacheUserData(): void
{
    Cache::put(
        "user:{$this->id}:data",
        $this->makeVisible(['remember_token'])->withoutRelations()->toArray(),
        now()->addHours(8)
    );
}

// Hapus cache saat logout atau data user berubah
public function clearUserCache(): void
{
    Cache::forget("user:{$this->id}:data");
    Cache::forget("user:{$this->id}:roles");
}

4. Cache Saat Login & Clear Saat Logout

File: app/Http/Controllers/Auth/AuthenticatedSessionController.php

Simpan user data + roles ke Redis segera setelah login berhasil, sehingga request berikutnya (dashboard) tidak perlu query DB sama sekali.

public function store(LoginRequest $request): RedirectResponse
{
    $request->authenticate();
    $request->session()->regenerate();

    $user      = auth()->user();
    $userRoles = $user->roles()->get();

    // Cache sekaligus setelah login
    $user->cacheUserData();
    Cache::put("user:{$user->id}:roles", $userRoles->toArray(), now()->addHours(8));

    $roleNames = $userRoles->pluck('name');

    if ($roleNames->intersect(['superadmin', 'admin'])->isNotEmpty()) {
        return redirect()->intended(route('superadmin.dashboard'));
    }

    if ($roleNames->contains('dosen')) {
        return redirect()->intended(route('dashboard'));
    }

    return redirect()->intended(route('dashboard'));
}

public function destroy(Request $request): RedirectResponse
{
    $user = auth()->user();
    Auth::guard('web')->logout();

    if ($user) {
        $user->clearUserCache(); // hapus cache saat logout
    }

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

    return redirect('/');
}

5. Cached Role Middleware

File: app/Http/Middleware/CheckRole.php

Middleware ini jalan di setiap request. Tanpa cache, tiap halaman akan query roles ke DB. Dengan cache, cukup 1ms dari Redis.

public function handle(Request $request, Closure $next, string $role): Response
{
    if (!auth()->check()) {
        return redirect('/login');
    }

    $userId = auth()->id(); // tidak trigger DB query
    $cached = Cache::get("user:{$userId}:roles");

    if ($cached) {
        $userRoles = collect($cached)->pluck('name');
    } else {
        $rolesCollection = auth()->user()->roles()->get();
        Cache::put("user:{$userId}:roles", $rolesCollection->toArray(), now()->addHours(8));
        $userRoles = $rolesCollection->pluck('name');
    }

    $roles   = collect(explode('|', $role))->map(fn($r) => strtolower($r));
    $hasRole = $roles->some(fn($r) => $userRoles->contains($r));

    if (!$hasRole) {
        abort(403, 'Unauthorized');
    }

    return $next($request);
}

⚠️ Penting: Setiap kali update roles user, wajib panggil $user->clearUserCache() supaya perubahan langsung efektif.


6. Cache Data per Modul

Untuk data yang jarang berubah tapi sering dibaca, gunakan pola Cache::remember():

// Contoh di service class
public function getActivePeriod(): ?CapstonePeriod
{
    return Cache::remember('capstone:period:active', now()->addHour(), fn() =>
        CapstonePeriod::where('is_active', true)->first()
    );
}

public function getMataKuliahList(): Collection
{
    return Cache::remember('banksoal:mk:all', now()->addDay(), fn() =>
        MataKuliah::with('cpls')->orderBy('kode')->get()
    );
}

Cache key convention:

{modul}:{entity}:{scope}:{id}

user:1:data          β†’ data user
user:1:roles         β†’ roles user
capstone:period:active
banksoal:mk:all
banksoal:statistik:mk:42

πŸ”΄ Redis Setup

Redis digunakan sebagai cache dan session driver untuk menghindari query DB berulang di setiap request.

Windows (Memurai)

1. Install Memurai

Download di memurai.com/get-memurai β†’ install. Memurai otomatis berjalan sebagai Windows Service.

Verifikasi:

memurai-cli ping
# PONG

2. Install Predis

composer require predis/predis

3. Hapus php_redis.dll dari php.ini

Buka C:\xampp\php\php.ini, comment out jika ada:

;extension=php_redis.dll

4. Update .env

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

5. Clear config

php artisan config:clear
php artisan cache:clear

6. Verifikasi

php artisan tinker
Cache::put('test', 'redis working!', 60);
Cache::get('test'); // β†’ "redis working!"

Linux / Mac

# Ubuntu/Debian
sudo apt install redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server

# Mac (Homebrew)
brew install redis
brew services start redis

# Verifikasi
redis-cli ping  # PONG

Lanjutkan dari langkah 2. Install Predis di atas.


Laragon (Alternatif XAMPP)

Laragon sudah include Redis bawaan β€” lebih simpel dari setup manual:

  1. Download di laragon.org
  2. Install β†’ klik kanan tray icon β†’ centang Redis
  3. Pindahkan project ke C:\laragon\www\webtekkom
  4. Update .env seperti di atas

Catatan Penting Redis

⚠️ Redis harus jalan sebelum Laravel dijalankan. Karena session disimpan di Redis, kalau Redis mati semua user tidak bisa login.

⚠️ Jalankan php artisan cache:clear setelah mengubah data cache untuk menghindari stale data.


πŸ—„οΈ Database Convention

Core Tables (Global)

Tabel global tanpa prefix:

Table Description
users User authentication
students Student data
lecturers Lecturer data

Module Tables (Dengan Prefix)

Module Prefix Example Tables
πŸ“˜ Capstone capstone_ capstone_periods, capstone_topics
πŸ“— Bank Soal bs_ bs_pertanyaan, bs_mata_kuliah
πŸ“™ Kemahasiswaan mk_ mk_kegiatan, mk_pengumuman
πŸ“• E-Office eo_ eo_surat, eo_dokumen

⚠️ IMPORTANT: Semua tabel module WAJIB menggunakan prefix yang sesuai.


πŸ”„ Migration Guide

Run All Migrations

php artisan migrate

Run Specific Module Migration

php artisan migrate --path=Modules/Capstone/Database/Migrations
php artisan migrate --path=Modules/BankSoal/Database/Migrations
php artisan migrate --path=Modules/Kemahasiswaan/Database/Migrations
php artisan migrate --path=Modules/EOffice/Database/Migrations

Reset Database (⚠️ Danger Zone)

php artisan migrate:fresh

Create New Migration

# Global migration
php artisan make:migration create_users_table

# Module-specific migration
php artisan make:migration create_capstone_periods_table --path=Modules/Capstone/Database/Migrations

🌱 Database Seeding

Seeding digunakan untuk mengisi database dengan data awal (roles, user dummy, data referensi, dll). Proyek ini menggunakan dua lapisan seeder: global di database/seeders/ dan per-modul di Modules/{Nama}/Database/Seeders/.


Struktur Seeder

database/
└── seeders/
    β”œβ”€β”€ DatabaseSeeder.php          ← Entry point utama (global)
    β”œβ”€β”€ RoleSeeder.php              ← Seed semua roles (global + per-modul)
    β”œβ”€β”€ SuperAdminSeeder.php        ← Seed akun superadmin
    └── UserSeeder.php              ← Seed dosen, mahasiswa & assign roles

Modules/
β”œβ”€β”€ Capstone/
β”‚   └── Database/
β”‚       └── Seeders/
β”‚           β”œβ”€β”€ CapstoneSeeder.php  ← Seeder utama modul Capstone
β”‚           └── ...
β”œβ”€β”€ BankSoal/
β”‚   └── Database/
β”‚       └── Seeders/
β”‚           β”œβ”€β”€ BankSoalSeeder.php
β”‚           └── ...
β”œβ”€β”€ Kemahasiswaan/
β”‚   └── Database/
β”‚       └── Seeders/
β”‚           β”œβ”€β”€ KemahasiswaanSeeder.php
β”‚           └── ...
└── EOffice/
    └── Database/
        └── Seeders/
            β”œβ”€β”€ EOfficeSeeder.php
            └── ...

Global Seeder

database/seeders/DatabaseSeeder.php adalah entry point utama. Cukup memanggil RoleSeeder (wajib duluan) lalu UserSeeder yang sudah mencakup semua user dummy beserta assignment role-nya.

⚠️ Urutan wajib dijaga: RoleSeeder harus dijalankan sebelum UserSeeder karena UserSeeder akan langsung firstOrFail() ke tabel roles. Kalau roles belum ada, seeder langsung error.

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            RoleSeeder::class,   // WAJIB pertama β€” UserSeeder bergantung pada ini
            UserSeeder::class,   // seed dosen, mahasiswa + assign roles
        ]);
    }
}

RoleSeeder.php β€” seed semua roles, termasuk role spesifik per modul seperti gpm untuk Bank Soal:

<?php

namespace Database\Seeders;

use App\Models\Role;
use Illuminate\Database\Seeder;

class RoleSeeder extends Seeder
{
    public function run(): void
    {
        $roles = [
            // Global roles
            ['name' => 'superadmin', 'module' => 'global'],
            ['name' => 'admin',      'module' => 'global'],
            ['name' => 'dosen',      'module' => 'global'],
            ['name' => 'mahasiswa',  'module' => 'global'],

            // Module-specific roles
            ['name' => 'gpm', 'module' => 'bank_soal'],
        ];

        foreach ($roles as $role) {
            Role::firstOrCreate(
                ['name' => $role['name'], 'module' => $role['module']],
                $role
            );
        }
    }
}

SuperAdminSeeder.php β€” seed akun superadmin saja, bisa dijalankan terpisah jika dibutuhkan:

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class SuperAdminSeeder extends Seeder
{
    public function run(): void
    {
        User::updateOrCreate(
            ['email' => 'superadmin@kampus.ac.id'],
            [
                'name'     => 'Super Admin',
                'password' => Hash::make('password123'),
            ]
        );
    }
}

πŸ’‘ SuperAdminSeeder tidak dipanggil dari DatabaseSeeder karena pembuatan akun superadmin sudah ditangani di dalam UserSeeder. Seeder ini tersedia sebagai utilitas terpisah β€” berguna jika akun superadmin terhapus dan perlu di-restore cepat tanpa harus seed ulang semua data.


UserSeeder.php β€” seed semua user (dosen, dosen+gpm, mahasiswa) sekaligus membuat record terkait di tabel lecturers dan students, serta assign roles:

<?php

namespace Database\Seeders;

use App\Models\Lecturer;
use App\Models\Role;
use App\Models\Student;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UserSeeder extends Seeder
{
    public function run(): void
    {
        // Load semua role di awal β€” langsung error kalau RoleSeeder belum dijalankan
        $roles = [
            'superadmin' => Role::where('name', 'superadmin')->where('module', 'global')->firstOrFail(),
            'dosen'      => Role::where('name', 'dosen')->where('module', 'global')->firstOrFail(),
            'mahasiswa'  => Role::where('name', 'mahasiswa')->where('module', 'global')->firstOrFail(),
            'gpm'        => Role::where('name', 'gpm')->where('module', 'bank_soal')->firstOrFail(),
        ];

        // 1. SUPERADMIN
        $superadmin = User::firstOrCreate(
            ['email' => 'superadmin@kampus.ac.id'],
            [
                'external_id' => 'EXT-SUPERADMIN-001',
                'name'        => 'Super Admin',
                'password'    => Hash::make('password'),
            ]
        );
        $superadmin->roles()->syncWithoutDetaching([$roles['superadmin']->id]);

        // 2. DOSEN
        $dosenUsers = [
            [
                'external_id'     => 'EXT-DSN-001',
                'name'            => 'Dr. Budi Santoso',
                'email'           => 'budi.santoso@kampus.ac.id',
                'employee_number' => 'NIP-2001-001',
            ],
            [
                'external_id'     => 'EXT-DSN-002',
                'name'            => 'Dr. Siti Rahayu',
                'email'           => 'siti.rahayu@kampus.ac.id',
                'employee_number' => 'NIP-2001-002',
            ],
        ];

        foreach ($dosenUsers as $data) {
            $user = User::firstOrCreate(
                ['email' => $data['email']],
                [
                    'external_id' => $data['external_id'],
                    'name'        => $data['name'],
                    'password'    => Hash::make('password'),
                ]
            );
            $user->roles()->syncWithoutDetaching([$roles['dosen']->id]);

            Lecturer::firstOrCreate(
                ['user_id' => $user->id],
                ['employee_number' => $data['employee_number']]
            );
        }

        // 3. DOSEN + GPM (punya dua role sekaligus)
        $dosenGpmUsers = [
            [
                'external_id'     => 'EXT-GPM-001',
                'name'            => 'Prof. Ahmad Fauzi',
                'email'           => 'ahmad.fauzi@kampus.ac.id',
                'employee_number' => 'NIP-2001-003',
            ],
            [
                'external_id'     => 'EXT-GPM-002',
                'name'            => 'Prof. Dewi Lestari',
                'email'           => 'dewi.lestari@kampus.ac.id',
                'employee_number' => 'NIP-2001-004',
            ],
        ];

        foreach ($dosenGpmUsers as $data) {
            $user = User::firstOrCreate(
                ['email' => $data['email']],
                [
                    'external_id' => $data['external_id'],
                    'name'        => $data['name'],
                    'password'    => Hash::make('password'),
                ]
            );
            $user->roles()->syncWithoutDetaching([$roles['dosen']->id, $roles['gpm']->id]);

            Lecturer::firstOrCreate(
                ['user_id' => $user->id],
                ['employee_number' => $data['employee_number']]
            );
        }

        // 4. MAHASISWA
        $mahasiswaUsers = [
            [
                'external_id'    => 'EXT-MHS-001',
                'name'           => 'Andi Pratama',
                'email'          => 'andi.pratama@student.kampus.ac.id',
                'student_number' => '2021001001',
                'cohort_year'    => 2021,
            ],
            [
                'external_id'    => 'EXT-MHS-002',
                'name'           => 'Bela Safitri',
                'email'          => 'bela.safitri@student.kampus.ac.id',
                'student_number' => '2021001002',
                'cohort_year'    => 2021,
            ],
            [
                'external_id'    => 'EXT-MHS-003',
                'name'           => 'Cahyo Nugroho',
                'email'          => 'cahyo.nugroho@student.kampus.ac.id',
                'student_number' => '2021001003',
                'cohort_year'    => 2021,
            ],
            [
                'external_id'    => 'EXT-MHS-004',
                'name'           => 'Dina Marlina',
                'email'          => 'dina.marlina@student.kampus.ac.id',
                'student_number' => '2022001001',
                'cohort_year'    => 2022,
            ],
        ];

        foreach ($mahasiswaUsers as $data) {
            $user = User::firstOrCreate(
                ['email' => $data['email']],
                [
                    'external_id' => $data['external_id'],
                    'name'        => $data['name'],
                    'password'    => Hash::make('password'),
                ]
            );
            $user->roles()->syncWithoutDetaching([$roles['mahasiswa']->id]);

            Student::firstOrCreate(
                ['user_id' => $user->id],
                [
                    'student_number' => $data['student_number'],
                    'cohort_year'    => $data['cohort_year'],
                ]
            );
        }
    }
}

Module Seeder

Setiap modul punya seeder sendiri untuk data spesifik modulnya. Seeder modul tidak dipanggil otomatis dari DatabaseSeeder β€” harus didaftarkan secara eksplisit atau dijalankan manual.

Contoh CapstoneSeeder.php:

<?php

namespace Modules\Capstone\Database\Seeders;

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class CapstoneSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            CapstonePeriodSeeder::class,   // seed periode capstone
            CapstoneTopicSeeder::class,    // seed topik/judul dummy
        ]);
    }
}

Contoh BankSoalSeeder.php:

<?php

namespace Modules\BankSoal\Database\Seeders;

use Illuminate\Database\Seeder;

class BankSoalSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            MataKuliahSeeder::class,   // seed daftar mata kuliah
            BankSoalDummySeeder::class, // seed soal dummy
        ]);
    }
}

Cara Menjalankan Seeder

β–Ά Jalankan semua seeder global

php artisan db:seed

Menjalankan DatabaseSeeder beserta semua seeder yang didaftarkan di dalamnya.


β–Ά Jalankan seeder global tertentu saja

# Hanya seed roles
php artisan db:seed --class=RoleSeeder

# Hanya seed user
php artisan db:seed --class=UserSeeder

β–Ά Jalankan seeder modul tertentu

Karena seeder modul berada di namespace berbeda, gunakan flag --class dengan fully qualified class name:

# Capstone
php artisan db:seed --class="Modules\Capstone\Database\Seeders\CapstoneSeeder"

# Bank Soal
php artisan db:seed --class="Modules\BankSoal\Database\Seeders\BankSoalSeeder"

# Kemahasiswaan
php artisan db:seed --class="Modules\Kemahasiswaan\Database\Seeders\KemahasiswaanSeeder"

# E-Office
php artisan db:seed --class="Modules\EOffice\Database\Seeders\EOfficeSeeder"

β–Ά Migrate fresh + seed sekaligus (⚠️ data lama terhapus)

# Hanya global seeder
php artisan migrate:fresh --seed

# Migrate fresh + seed semua modul sekaligus
php artisan migrate:fresh --seed && \
php artisan db:seed --class="Modules\Capstone\Database\Seeders\CapstoneSeeder" && \
php artisan db:seed --class="Modules\BankSoal\Database\Seeders\BankSoalSeeder" && \
php artisan db:seed --class="Modules\Kemahasiswaan\Database\Seeders\KemahasiswaanSeeder" && \
php artisan db:seed --class="Modules\EOffice\Database\Seeders\EOfficeSeeder"

β–Ά Daftarkan modul seeder ke DatabaseSeeder (opsional)

Jika ingin semua modul ikut ter-seed saat php artisan db:seed atau migrate:fresh --seed, tambahkan ke DatabaseSeeder:

// database/seeders/DatabaseSeeder.php
public function run(): void
{
    $this->call([
        // Global
        RoleSeeder::class,
        UserSeeder::class,
        StudentSeeder::class,
        LecturerSeeder::class,

        // Modules β€” uncomment sesuai kebutuhan
        \Modules\Capstone\Database\Seeders\CapstoneSeeder::class,
        \Modules\BankSoal\Database\Seeders\BankSoalSeeder::class,
        \Modules\Kemahasiswaan\Database\Seeders\KemahasiswaanSeeder::class,
        \Modules\EOffice\Database\Seeders\EOfficeSeeder::class,
    ]);
}

πŸ’‘ Tips: Di environment development, daftarkan semua modul seeder agar mudah reset & rebuild data. Di production, jalankan modul seeder secara manual sesuai kebutuhan untuk menghindari data dummy masuk ke production.


Membuat Seeder Baru

# Global seeder
php artisan make:seeder NamaSeeder

# Module seeder (buat manual di folder yang sesuai)
# Contoh: Modules/Capstone/Database/Seeders/CapstonePeriodSeeder.php

Untuk module seeder yang dibuat manual, gunakan namespace yang sesuai:

<?php

namespace Modules\Capstone\Database\Seeders;

use Illuminate\Database\Seeder;

class CapstonePeriodSeeder extends Seeder
{
    public function run(): void
    {
        // ...
    }
}

Tips Seeding

⚠️ Urutan penting! Seed global (roles, users) selalu duluan sebelum modul, karena modul seeder biasanya butuh foreign key ke tabel global.

βœ… Gunakan updateOrInsert() atau firstOrCreate() agar seeder aman dijalankan berulang tanpa duplikasi data.

πŸ”„ Setelah seeding, clear cache Redis agar data lama tidak tersisa:

php artisan cache:clear

πŸ› οΈ Essential Commands

Cache Management

php artisan route:clear
php artisan config:clear
php artisan cache:clear
php artisan view:clear

# Clear semua sekaligus
php artisan optimize:clear

Production Optimization

php artisan optimize
php artisan config:cache
php artisan route:cache
php artisan view:cache
composer dump-autoload -o

Development Tools

php artisan route:list
php artisan route:list | grep capstone
php artisan db:show
php artisan storage:link

πŸ‘€ User Role System

Available Roles

Role Code Description
πŸ”΄ Superadmin superadmin Full system access
🟠 Admin admin Administrative access
🟑 Dosen dosen Lecturer/faculty access
🟒 Mahasiswa mahasiswa Student access (default)

Usage di Controller / Middleware

// Cek single role
$user->hasRole('dosen');

// Cek salah satu dari beberapa role
$user->hasAnyRole(['superadmin', 'admin']);

// Cek dengan filter module
$user->hasRole('dosen', 'capstone');

// Di route middleware
Route::middleware(['auth', 'role:superadmin|admin'])->group(function () {
    // ...
});

Invalidasi Cache Saat Update Role

// Wajib dipanggil setiap kali roles user diubah
public function updateUserRoles(User $user, array $roleIds): void
{
    $user->roles()->sync($roleIds);
    $user->clearUserCache(); // hapus cache lama agar langsung efektif
}

πŸ” Development Rules

⚠️ WAJIB DIBACA TIM

Rule Description
🚫 No Migration Edit Jangan edit migration yang sudah dijalankan di production
πŸ“› Use Prefix Gunakan prefix sesuai module untuk semua tabel
πŸ”„ Clear Cache Selalu clear cache setelah ubah route/config
πŸ”’ No .env Commit Jangan commit file .env ke repository
⚑ Eager Loading Gunakan with() untuk menghindari N+1 query problem
πŸ—‘οΈ Clear User Cache Panggil clearUserCache() setiap kali update data/roles user
πŸ“ Code Documentation Tambahkan docblock untuk function public
πŸ§ͺ Test Before Commit Test fitur sebelum commit ke branch utama
πŸ”΄ Redis First Pastikan Redis/Memurai jalan sebelum start Laravel
🌱 Safe Seeder Gunakan updateOrInsert / firstOrCreate agar seeder idempotent

Git Workflow

git checkout -b feature/module-name-feature
git add .
git commit -m "feat(module): description"
git push origin feature/module-name-feature

Commit Message Convention

feat(capstone): add topic submission feature
fix(bank-soal): resolve question duplication bug
docs(readme): update installation guide
refactor(kemahasiswaan): optimize event query
perf(auth): add redis caching for user roles
seed(capstone): add period and topic dummy data

πŸ”­ Laravel Telescope (Opsional)

Laravel Telescope adalah debug assistant bawaan Laravel yang memungkinkan kamu memantau setiap request yang masuk: berapa lama prosesnya, query apa saja yang dijalankan, apakah ada query lambat, exception apa yang terjadi, dan masih banyak lagi β€” semuanya lewat UI web yang rapi.

⚠️ Telescope hanya untuk environment local / development. Jangan aktifkan di production karena menyimpan seluruh data request ke database dan berpotensi membocorkan informasi sensitif.


Instalasi

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

Perintah telescope:install akan:

  • Mempublish config ke config/telescope.php
  • Mempublish assets (CSS/JS) ke public/vendor/telescope
  • Mendaftarkan TelescopeServiceProvider ke bootstrap/providers.php

Konfigurasi Agar Hanya Aktif di Local

Pastikan Telescope tidak pernah load di production. Buka app/Providers/TelescopeServiceProvider.php dan verifikasi method register():

// app/Providers/TelescopeServiceProvider.php

public function register(): void
{
    // Telescope hanya aktif di environment local
    if ($this->app->isLocal()) {
        $this->app->register(\Laravel\Telescope\TelescopeApplicationServiceProvider::class);
    }
}

Atau bisa juga via config/telescope.php:

// config/telescope.php
'enabled' => env('TELESCOPE_ENABLED', false),

Dan di .env development:

TELESCOPE_ENABLED=true

βœ… Dengan cara ini, Telescope tidak akan aktif kecuali kamu eksplisit menyalakannya di .env lokal.


Akses Dashboard

Setelah server berjalan, buka:

http://localhost:8000/telescope

Memantau Request & Response Time

Ini adalah fitur utama yang paling berguna untuk profiling performa.

Tab Requests

Buka Telescope β†’ Requests. Kamu akan melihat tabel seperti ini:

Method Path Status Duration Time
POST /login 302 387ms 14:32:01
GET /dashboard 200 210ms 14:32:02
GET /superadmin/dashboard 200 950ms 14:32:05

Kolom Duration menunjukkan total waktu dari request masuk hingga response keluar.

Detail Request

Klik salah satu request untuk melihat breakdown lengkapnya:

Request Detail
β”œβ”€β”€ πŸ“‹ Request Info
β”‚   β”œβ”€β”€ Method, URL, Status Code
β”‚   β”œβ”€β”€ Controller & Action yang dipanggil
β”‚   └── Middleware yang dijalankan
β”‚
β”œβ”€β”€ πŸ—„οΈ Queries (paling penting untuk profiling!)
β”‚   β”œβ”€β”€ Jumlah query yang dieksekusi
β”‚   β”œβ”€β”€ Durasi tiap query (ms)
β”‚   └── Raw SQL dengan binding-nya
β”‚
β”œβ”€β”€ ⚑ Cache
β”‚   β”œβ”€β”€ Cache hit / miss
β”‚   └── Key yang diakses
β”‚
β”œβ”€β”€ πŸ“¦ Session
β”‚   └── Data session yang aktif
β”‚
└── πŸ”’ Response
    └── Status & response body (jika JSON)

Mengidentifikasi Request Lambat

Telescope bisa highlight otomatis request yang melebihi threshold tertentu. Konfigurasi di config/telescope.php:

// config/telescope.php

'slow_queries' => [
    'enabled'   => true,
    'threshold' => 100, // query > 100ms dianggap lambat (dalam ms)
],

'slow_requests' => [
    'enabled'   => true,
    'threshold' => 1000, // request > 1000ms dianggap lambat (dalam ms)
],

Request dan query yang melewati threshold akan ditandai merah di dashboard Telescope.


Tips Membaca Hasil Telescope

Cek N+1 Query Problem:

Kalau kamu buka satu halaman dan di tab Queries muncul puluhan query dengan pola yang mirip-mirip, itu tanda N+1. Solusinya pakai eager loading with():

// ❌ Tanpa eager loading β†’ muncul N+1 di Telescope
$groups = CapstoneGroup::all();
foreach ($groups as $group) {
    echo $group->students->count(); // query baru tiap iterasi
}

// βœ… Dengan eager loading β†’ hanya 2 query di Telescope
$groups = CapstoneGroup::with('students')->get();

Cek Cache Hit/Miss:

Di tab Cache, pastikan key seperti user:{id}:roles dan user:{id}:data berstatus hit (bukan miss) setelah login pertama. Kalau terus miss, berarti caching belum bekerja.

Bandingkan Before/After Optimasi:

Gunakan Telescope sebelum dan sesudah menerapkan perubahan (tambah index, eager loading, caching) untuk melihat penurunan duration secara konkret.


Membersihkan Data Telescope

Data Telescope tersimpan di tabel telescope_entries dan telescope_entries_tags. Bersihkan secara berkala agar tidak memberatkan database lokal:

# Hapus semua data Telescope
php artisan telescope:clear

# Atau jalankan pruning otomatis (hapus data > 24 jam)
php artisan telescope:prune

# Jalankan pruning dengan custom hours
php artisan telescope:prune --hours=48

Bisa juga dijadwalkan di routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('telescope:prune')->daily();

Uninstall Telescope (Jika Tidak Diperlukan)

Jika teammate tidak ingin install Telescope (misal di mesin yang resource-nya terbatas):

composer remove laravel/telescope
php artisan migrate:rollback  # rollback migration telescope

Hapus juga TelescopeServiceProvider dari bootstrap/providers.php jika masih terdaftar.

πŸ’‘ Karena diinstall dengan flag --dev, Telescope tidak akan ikut ter-install di production saat composer install --no-dev.


πŸ§ͺ Troubleshooting

❌ Route tidak berubah setelah edit
php artisan route:clear
php artisan config:clear
❌ Migration error "table already exists"
php artisan migrate:status

# Jika perlu reset (⚠️ data hilang)
php artisan migrate:fresh
❌ Laravel terasa lambat / response > 2 detik

Pastikan checklist berikut:

  1. Redis/Memurai sudah jalan
  2. .env sudah set CACHE_STORE=redis dan SESSION_DRIVER=redis
  3. config/auth.php sudah pakai cached-eloquent driver
  4. Index database sudah dibuat di Supabase SQL Editor
  5. config/database.php sudah ada PDO::ATTR_PERSISTENT => true
php artisan config:clear
php artisan cache:clear
❌ Redis connection refused

Windows: Pastikan Memurai sudah jalan. Buka Start Menu β†’ cari "Memurai" β†’ Start. Atau cek di services.msc.

Linux/Mac:

sudo systemctl start redis-server  # Linux
brew services start redis          # Mac

Verifikasi:

memurai-cli ping  # Windows
redis-cli ping    # Linux/Mac
# Harus balik: PONG
❌ Unable to load php_redis.dll

Buka C:\xampp\php\php.ini, cari dan comment out:

;extension=php_redis.dll

Restart XAMPP/Laragon, lalu jalankan ulang Laravel.

❌ Call to a member function contains() on array

Ini terjadi karena data dari cache berupa array biasa, bukan Collection. Pastikan selalu wrap dengan collect() sebelum memanggil method Collection:

// ❌ Salah
$cached = Cache::get("user:{$id}:roles");
$cached->contains('superadmin');

// βœ… Benar
$userRoles = collect(Cache::get("user:{$id}:roles"))->pluck('name');
$userRoles->contains('superadmin');

Jalankan php artisan cache:clear untuk hapus cache lama yang formatnya mungkin berbeda.

❌ The attribute [remember_token] does not exist

Pastikan remember_token tidak ada di $hidden di User.php, dan cacheUserData() menggunakan makeVisible(['remember_token']) sebelum serialize ke cache.

// User.php β€” jangan masukkan remember_token ke $hidden
protected $hidden = [
    'password',
    // remember_token TIDAK di-hidden
];
❌ Authentication user provider [cached-eloquent] is not defined

Pastikan dua hal:

  1. AppServiceProvider.php sudah ada Auth::resolved(...) di method register()
  2. config/auth.php sudah diupdate:
'providers' => [
    'users' => [
        'driver' => 'cached-eloquent',
        'model'  => App\Models\User::class,
    ],
],

Lalu jalankan:

php artisan config:clear
❌ Supabase connection timeout
  1. Gunakan connection pooling port 6543 bukan 5432
  2. Pastikan region Supabase project di Singapore (ap-southeast-1) bukan Mumbai
  3. Test koneksi:
php artisan db:show
❌ Seeder error: Class not found (module seeder)

Pastikan namespace di file seeder modul sudah benar, lalu regenerate autoload:

composer dump-autoload

Jalankan ulang seeder dengan fully qualified class name:

php artisan db:seed --class="Modules\Capstone\Database\Seeders\CapstoneSeeder"
❌ Seeder error: Duplicate entry / unique constraint violation

Seeder dijalankan lebih dari sekali tanpa guard idempotent. Ganti insert() dengan updateOrInsert() atau firstOrCreate():

// ❌ Akan error jika dijalankan dua kali
DB::table('roles')->insert(['name' => 'superadmin']);

// βœ… Aman dijalankan berulang
DB::table('roles')->updateOrInsert(
    ['name' => 'superadmin'],
    ['updated_at' => now()]
);

Atau truncate tabel dulu sebelum insert (hati-hati di production):

DB::table('roles')->truncate();
DB::table('roles')->insert([...]);

πŸš€ Future Roadmap

  • πŸ”„ Microservices Migration - Isolasi per module
  • πŸ—„οΈ Database Per Module - Separate database untuk setiap modul
  • πŸ” Enhanced RBAC - Permission-based access control
  • 🏒 Multi-Tenant - Support multiple institutions
  • πŸ“± Mobile App - Native mobile application
  • πŸ€– API Gateway - Centralized API management
  • πŸ“Š Analytics Dashboard - System-wide reporting
  • πŸ”” Real-time Notifications - WebSocket integration

πŸ“š Documentation


Made with ❀️ by Tim Capstone

About

backeend manajemen mahasiswa laravel and shit (nyoba-nyoba)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages