Bagian 1: Pendahuluan - Pentingnya Halaman Admin untuk Platform Mencari Jodoh
Halo teman-teman! Saya Angga Risky Setiawan, founder BuildWithAngga. Kali ini saya akan mengajak kalian belajar membuat website mencari jodoh dari nol hingga selesai menggunakan Laravel 12, Filament, Spatie Permission, dan Midtrans.
Kenapa Projek Ini Menarik?
Saya memilih projek dating platform karena kompleksitasnya cukup tinggi dan mencakup hampir semua skill yang dibutuhkan web developer profesional. Kita akan belajar mengelola data sensitif, membangun matching system, real-time chat, hingga payment gateway.
Pentingnya Halaman Admin yang Terstruktur
Memiliki admin panel yang rapi adalah kunci sukses menjalankan platform seperti ini. Berikut fitur-fitur yang akan saya bangun:
- Pengelolaan Profil Pengguna — Admin bisa melihat seluruh data user, memverifikasi keaslian profil, dan mengambil tindakan jika ada pelanggaran
- Sistem Moderasi Konten — Tim moderator bisa mereview laporan, mengecek foto yang diupload, dan memblokir fake profile atau scammer
- Dashboard Matching System — Melihat statistik match harian, conversation rate, dan metrik kesehatan platform lainnya
- Pengelolaan Paket Premium — Membuat paket Basic, Gold, Platinum dengan benefit berbeda seperti unlimited likes atau melihat siapa yang like profil
- Laporan & Analytics — Insight tentang pertumbuhan user, demografi, retention rate, dan conversion ke premium
Dengan Filament saya bisa membangun admin panel modern dengan cepat. Spatie Permission mengatur akses berbeda untuk admin, moderator, dan customer support. Lalu Midtrans menangani pembayaran subscription.
Mari kita mulai!
Bagian 2: Membuat Projek Laravel Baru dan Konfigurasi Database
Sekarang saya akan mulai masuk ke tahap teknis pertama, yaitu membuat projek Laravel baru dan mengatur koneksi database. Saya akan memandu teman-teman step by step agar projek bisa berjalan dengan lancar dari awal.
Persiapan Environment
Sebelum saya mulai, saya pastikan dulu bahwa di komputer saya sudah terinstall PHP versi 8.2 atau lebih tinggi, Composer, dan MySQL. Ini adalah requirement dasar untuk menjalankan Laravel 12. Kalau teman-teman belum punya, silakan install terlebih dahulu.
Membuat Projek Laravel
Saya buka terminal dan navigasikan ke folder tempat saya ingin menyimpan projek. Kemudian saya bikin projek Laravel baru dengan perintah:
composer create-project laravel/laravel jodoh-app
Saya beri nama projeknya jodoh-app agar simpel dan mudah diingat. Proses ini memakan waktu beberapa menit tergantung koneksi internet.
Setelah selesai, saya masuk ke direktori projek:
cd jodoh-app
Saya coba jalankan development server untuk memastikan instalasi berhasil:
php artisan serve
Kalau muncul pesan server berjalan di http://127.0.0.1:8000, berarti projek Laravel sudah siap.
Membuat Database MySQL
Sekarang saya bikin database baru di MySQL. Saya buka terminal MySQL:
mysql -u root -p
Setelah masukkan password, saya buat database:
CREATE DATABASE jodoh_app;
Kemudian saya keluar dengan mengetik exit.
Konfigurasi File .env
Saya buka file .env di root projek dan atur konfigurasi database seperti ini:
APP_NAME="Jodoh App"
APP_ENV=local
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_DEBUG=true
APP_TIMEZONE=Asia/Jakarta
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=jodoh_app
DB_USERNAME=root
DB_PASSWORD=password_mysql_saya
Saya jelaskan beberapa konfigurasi penting. APP_NAME saya isi dengan nama aplikasi yang akan muncul di berbagai tempat seperti email dan title halaman. APP_TIMEZONE saya set ke Asia/Jakarta karena target user saya di Indonesia.
Untuk database, DB_CONNECTION saya isi mysql, DB_DATABASE sesuai nama database yang tadi dibuat, dan DB_USERNAME serta DB_PASSWORD sesuai kredensial MySQL di komputer saya.
Konfigurasi Tambahan
Saya juga tambahkan beberapa konfigurasi untuk kebutuhan projek dating app ini:
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
FILESYSTEM_DISK=public
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=app_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
BROADCAST_CONNECTION=reverb
Saya set FILESYSTEM_DISK ke public karena nanti akan banyak upload foto profil yang perlu diakses publik. BROADCAST_CONNECTION saya set ke reverb untuk fitur real-time chat nanti.
Testing Koneksi Database
Saya jalankan migration untuk memastikan koneksi database berhasil:
php artisan migrate
Kalau muncul output migration berhasil, berarti konfigurasi sudah benar.
Membuat Storage Link
Saya juga bikin symbolic link untuk storage agar foto yang diupload bisa diakses:
php artisan storage:link
Sampai di sini projek Laravel sudah siap dengan konfigurasi database yang benar. Di bagian selanjutnya, saya akan mulai membuat struktur database dengan migration dan model untuk semua tabel yang dibutuhkan platform mencari jodoh ini.
Bagian 3: Membuat Migration dan Model dengan Fillable dan Relationship
Sekarang saya masuk ke bagian paling penting yaitu membuat struktur database untuk platform mencari jodoh. Saya akan bikin beberapa tabel yang saling berhubungan untuk mengelola profil pengguna, sistem matching, percakapan, dan langganan premium.
Struktur Database yang Akan Dibuat
Saya jelaskan dulu hubungan antar tabel. User memiliki satu Profile yang berisi informasi detail seperti bio, tanggal lahir, dan preferensi pasangan. Setiap Profile bisa memiliki banyak Interest melalui tabel pivot profile_interests. Ketika dua user saling like, akan terbentuk Match yang membuka akses Conversation. Di dalam Conversation ada banyak Messages. User juga bisa berlangganan SubscriptionPlan melalui tabel Subscriptions.
Migration untuk Tabel Profiles
Saya mulai dengan membuat migration untuk profiles:
php artisan make:migration create_profiles_table
Saya buka file migration dan isi seperti ini:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('display_name');
$table->text('bio')->nullable();
$table->date('date_of_birth');
$table->enum('gender', ['male', 'female']);
$table->enum('looking_for', ['male', 'female', 'both'])->default('both');
$table->string('location')->nullable();
$table->decimal('latitude', 10, 8)->nullable();
$table->decimal('longitude', 11, 8)->nullable();
$table->string('occupation')->nullable();
$table->string('education')->nullable();
$table->integer('height_cm')->nullable();
$table->enum('relationship_goal', ['serious', 'casual', 'friendship', 'unsure'])->default('unsure');
$table->integer('age_min_preference')->default(18);
$table->integer('age_max_preference')->default(50);
$table->integer('distance_preference_km')->default(50);
$table->string('profile_photo')->nullable();
$table->boolean('is_verified')->default(false);
$table->boolean('is_active')->default(true);
$table->boolean('is_profile_complete')->default(false);
$table->timestamp('last_active_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('profiles');
}
};
Saya menambahkan kolom latitude dan longitude untuk fitur pencarian berdasarkan lokasi. Kolom preference seperti age_min_preference, age_max_preference, dan distance_preference_km akan digunakan untuk algoritma matching.
Migration untuk Tabel Interests
php artisan make:migration create_interests_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('interests', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('icon')->nullable();
$table->string('category')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('interests');
}
};
Migration untuk Tabel Pivot Profile Interests
php artisan make:migration create_profile_interests_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('profile_interests', function (Blueprint $table) {
$table->id();
$table->foreignId('profile_id')->constrained()->onDelete('cascade');
$table->foreignId('interest_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['profile_id', 'interest_id']);
});
}
public function down(): void
{
Schema::dropIfExists('profile_interests');
}
};
Migration untuk Tabel Swipes
Saya bikin tabel untuk menyimpan aksi like dan pass:
php artisan make:migration create_swipes_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('swipes', function (Blueprint $table) {
$table->id();
$table->foreignId('swiper_id')->constrained('profiles')->onDelete('cascade');
$table->foreignId('swiped_id')->constrained('profiles')->onDelete('cascade');
$table->enum('action', ['like', 'pass', 'super_like']);
$table->timestamps();
$table->unique(['swiper_id', 'swiped_id']);
});
}
public function down(): void
{
Schema::dropIfExists('swipes');
}
};
Migration untuk Tabel Matches
php artisan make:migration create_matches_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('matches', function (Blueprint $table) {
$table->id();
$table->foreignId('profile_one_id')->constrained('profiles')->onDelete('cascade');
$table->foreignId('profile_two_id')->constrained('profiles')->onDelete('cascade');
$table->timestamp('matched_at');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['profile_one_id', 'profile_two_id']);
});
}
public function down(): void
{
Schema::dropIfExists('matches');
}
};
Migration untuk Tabel Conversations dan Messages
php artisan make:migration create_conversations_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('match_id')->constrained()->onDelete('cascade');
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('conversations');
}
};
php artisan make:migration create_messages_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->onDelete('cascade');
$table->foreignId('sender_id')->constrained('profiles')->onDelete('cascade');
$table->text('body');
$table->string('attachment')->nullable();
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};
Migration untuk Subscription Plans dan Subscriptions
php artisan make:migration create_subscription_plans_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscription_plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->integer('duration_days');
$table->integer('daily_likes_limit')->nullable();
$table->integer('daily_super_likes')->default(0);
$table->boolean('can_see_who_likes')->default(false);
$table->boolean('can_boost_profile')->default(false);
$table->boolean('unlimited_likes')->default(false);
$table->boolean('no_ads')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('subscription_plans');
}
};
php artisan make:migration create_subscriptions_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('subscription_plan_id')->constrained()->onDelete('cascade');
$table->timestamp('starts_at');
$table->timestamp('ends_at');
$table->enum('status', ['active', 'expired', 'cancelled'])->default('active');
$table->string('payment_method')->nullable();
$table->string('transaction_id')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};
Migration untuk Tabel Reports
php artisan make:migration create_reports_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->id();
$table->foreignId('reporter_id')->constrained('profiles')->onDelete('cascade');
$table->foreignId('reported_id')->constrained('profiles')->onDelete('cascade');
$table->enum('reason', ['fake_profile', 'inappropriate_content', 'harassment', 'spam', 'scam', 'other']);
$table->text('description')->nullable();
$table->enum('status', ['pending', 'reviewed', 'resolved', 'dismissed'])->default('pending');
$table->foreignId('reviewed_by')->nullable()->constrained('users')->onDelete('set null');
$table->text('admin_notes')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('reports');
}
};
Menjalankan Migration
php artisan migrate
Membuat Model Profile
php artisan make:model Profile
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Profile extends Model
{
protected $fillable = [
'user_id', 'display_name', 'bio', 'date_of_birth', 'gender',
'looking_for', 'location', 'latitude', 'longitude', 'occupation',
'education', 'height_cm', 'relationship_goal', 'age_min_preference',
'age_max_preference', 'distance_preference_km', 'profile_photo',
'is_verified', 'is_active', 'is_profile_complete', 'last_active_at',
];
protected $casts = [
'date_of_birth' => 'date',
'is_verified' => 'boolean',
'is_active' => 'boolean',
'is_profile_complete' => 'boolean',
'last_active_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function interests(): BelongsToMany
{
return $this->belongsToMany(Interest::class, 'profile_interests');
}
public function sentSwipes(): HasMany
{
return $this->hasMany(Swipe::class, 'swiper_id');
}
public function receivedSwipes(): HasMany
{
return $this->hasMany(Swipe::class, 'swiped_id');
}
public function getAgeAttribute(): int
{
return $this->date_of_birth->age;
}
public function likedBy(Profile $profile): bool
{
return $this->receivedSwipes()
->where('swiper_id', $profile->id)
->where('action', 'like')
->exists();
}
}
Membuat Model Interest
php artisan make:model Interest
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
class Interest extends Model
{
protected $fillable = ['name', 'icon', 'category', 'is_active'];
protected $casts = [
'is_active' => 'boolean',
];
public function profiles(): BelongsToMany
{
return $this->belongsToMany(Profile::class, 'profile_interests');
}
}
Membuat Model Swipe
php artisan make:model Swipe
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Swipe extends Model
{
protected $fillable = ['swiper_id', 'swiped_id', 'action'];
public function swiper(): BelongsTo
{
return $this->belongsTo(Profile::class, 'swiper_id');
}
public function swiped(): BelongsTo
{
return $this->belongsTo(Profile::class, 'swiped_id');
}
}
Membuat Model Match
php artisan make:model Match
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
class Match extends Model
{
protected $fillable = ['profile_one_id', 'profile_two_id', 'matched_at', 'is_active'];
protected $casts = [
'matched_at' => 'datetime',
'is_active' => 'boolean',
];
public function profileOne(): BelongsTo
{
return $this->belongsTo(Profile::class, 'profile_one_id');
}
public function profileTwo(): BelongsTo
{
return $this->belongsTo(Profile::class, 'profile_two_id');
}
public function conversation(): HasOne
{
return $this->hasOne(Conversation::class);
}
}
Membuat Model Conversation dan Message
php artisan make:model Conversation
php artisan make:model Message
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Conversation extends Model
{
protected $fillable = ['match_id', 'last_message_at'];
protected $casts = [
'last_message_at' => 'datetime',
];
public function match(): BelongsTo
{
return $this->belongsTo(Match::class);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
}
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Message extends Model
{
protected $fillable = ['conversation_id', 'sender_id', 'body', 'attachment', 'read_at'];
protected $casts = [
'read_at' => 'datetime',
];
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function sender(): BelongsTo
{
return $this->belongsTo(Profile::class, 'sender_id');
}
}
Membuat Model SubscriptionPlan dan Subscription
php artisan make:model SubscriptionPlan
php artisan make:model Subscription
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class SubscriptionPlan extends Model
{
protected $fillable = [
'name', 'slug', 'description', 'price', 'duration_days',
'daily_likes_limit', 'daily_super_likes', 'can_see_who_likes',
'can_boost_profile', 'unlimited_likes', 'no_ads', 'is_active',
];
protected $casts = [
'price' => 'decimal:2',
'can_see_who_likes' => 'boolean',
'can_boost_profile' => 'boolean',
'unlimited_likes' => 'boolean',
'no_ads' => 'boolean',
'is_active' => 'boolean',
];
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
}
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Subscription extends Model
{
protected $fillable = [
'user_id', 'subscription_plan_id', 'starts_at', 'ends_at',
'status', 'payment_method', 'transaction_id',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id');
}
public function isActive(): bool
{
return $this->status === 'active' && $this->ends_at->isFuture();
}
}
Membuat Model Report
php artisan make:model Report
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Report extends Model
{
protected $fillable = [
'reporter_id', 'reported_id', 'reason', 'description',
'status', 'reviewed_by', 'admin_notes', 'reviewed_at',
];
protected $casts = [
'reviewed_at' => 'datetime',
];
public function reporter(): BelongsTo
{
return $this->belongsTo(Profile::class, 'reporter_id');
}
public function reported(): BelongsTo
{
return $this->belongsTo(Profile::class, 'reported_id');
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
}
Update Model User
Saya juga perlu update model User untuk menambahkan relationship:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
class User extends Authenticatable
{
use Notifiable;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
public function activeSubscription()
{
return $this->subscriptions()
->where('status', 'active')
->where('ends_at', '>', now())
->latest()
->first();
}
public function isPremium(): bool
{
return $this->activeSubscription() !== null;
}
}
Sampai di sini saya sudah selesai membuat semua migration dan model yang dibutuhkan. Di bagian selanjutnya, saya akan menginstall Filament dan membuat akun admin.
Bagian 4: Menginstall Filament dan Membuat Akun Admin
Sekarang saya akan menginstall Filament sebagai admin panel untuk platform mencari jodoh ini. Dengan Filament, saya bisa membangun dashboard yang powerful untuk mengelola user, moderasi konten, dan memonitor aktivitas platform.
Menginstall Filament
Saya jalankan perintah Composer untuk install Filament:
composer require filament/filament:"^3.2" -W
Setelah selesai, saya jalankan perintah instalasi:
php artisan filament:install --panels
Saya akan diminta memasukkan ID panel, saya ketik admin dan tekan Enter.
Konfigurasi Admin Panel
Saya buka file app/Providers/Filament/AdminPanelProvider.php dan modifikasi sesuai kebutuhan dating app:
<?php
namespace App\\Providers\\Filament;
use Filament\\Http\\Middleware\\Authenticate;
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\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('Jodoh App Admin')
->favicon(asset('images/favicon.ico'))
->colors([
'primary' => Color::Rose,
'danger' => Color::Red,
'warning' => Color::Amber,
'success' => Color::Green,
'info' => Color::Blue,
])
->font('Poppins')
->sidebarCollapsibleOnDesktop()
->navigationGroups([
'User Management',
'Matchmaking',
'Subscriptions',
'Moderation',
'Settings',
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
->pages([
Pages\\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
->widgets([
Widgets\\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Saya menggunakan warna Rose sebagai primary color karena cocok dengan tema dating app yang romantic.
Membuat Akun Admin
Saya jalankan perintah untuk membuat user admin:
php artisan make:filament-user
Saya isi dengan data berikut:
Name: Super Admin
Email: [email protected]
Password: ********
Membuat Widget Dashboard
Saya bikin widget untuk menampilkan statistik penting di dashboard:
php artisan make:filament-widget StatsOverview --stats-overview
Saya buka file app/Filament/Widgets/StatsOverview.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Match;
use App\\Models\\Profile;
use App\\Models\\Report;
use App\\Models\\Subscription;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class StatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$totalUsers = Profile::count();
$verifiedUsers = Profile::where('is_verified', true)->count();
$activeToday = Profile::where('last_active_at', '>=', now()->subDay())->count();
$totalMatches = Match::where('is_active', true)->count();
$matchesToday = Match::whereDate('matched_at', today())->count();
$pendingReports = Report::where('status', 'pending')->count();
$activeSubscriptions = Subscription::where('status', 'active')
->where('ends_at', '>', now())->count();
$monthlyRevenue = Subscription::where('status', 'active')
->whereMonth('created_at', now()->month)
->join('subscription_plans', 'subscriptions.subscription_plan_id', '=', 'subscription_plans.id')
->sum('subscription_plans.price');
return [
Stat::make('Total Users', number_format($totalUsers))
->description($verifiedUsers . ' verified')
->descriptionIcon('heroicon-m-check-badge')
->color('success'),
Stat::make('Active Today', number_format($activeToday))
->description('Online dalam 24 jam')
->descriptionIcon('heroicon-m-signal')
->color('info'),
Stat::make('Total Matches', number_format($totalMatches))
->description($matchesToday . ' matches hari ini')
->descriptionIcon('heroicon-m-heart')
->color('danger'),
Stat::make('Premium Users', number_format($activeSubscriptions))
->description('Langganan aktif')
->descriptionIcon('heroicon-m-star')
->color('warning'),
Stat::make('Revenue Bulan Ini', 'Rp ' . number_format($monthlyRevenue, 0, ',', '.'))
->description('Dari subscriptions')
->descriptionIcon('heroicon-m-banknotes')
->color('success'),
Stat::make('Pending Reports', $pendingReports)
->description('Perlu direview')
->descriptionIcon('heroicon-m-flag')
->color($pendingReports > 10 ? 'danger' : 'warning'),
];
}
}
Membuat Widget Chart
Saya bikin chart untuk visualisasi pertumbuhan user:
php artisan make:filament-widget UserGrowthChart --chart
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Profile;
use Carbon\\Carbon;
use Filament\\Widgets\\ChartWidget;
class UserGrowthChart extends ChartWidget
{
protected static ?string $heading = 'Pertumbuhan User (30 Hari)';
protected static ?int $sort = 2;
protected static string $color = 'success';
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 29; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i);
$labels[] = $date->format('d M');
$data[] = Profile::whereDate('created_at', $date)->count();
}
return [
'datasets' => [
[
'label' => 'User Baru',
'data' => $data,
'backgroundColor' => 'rgba(244, 63, 94, 0.5)',
'borderColor' => 'rgb(244, 63, 94)',
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'line';
}
}
Membuat Widget Matches Chart
php artisan make:filament-widget MatchesChart --chart
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Match;
use Carbon\\Carbon;
use Filament\\Widgets\\ChartWidget;
class MatchesChart extends ChartWidget
{
protected static ?string $heading = 'Matches per Hari (7 Hari)';
protected static ?int $sort = 3;
protected static string $color = 'danger';
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 6; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i);
$labels[] = $date->format('D');
$data[] = Match::whereDate('matched_at', $date)->count();
}
return [
'datasets' => [
[
'label' => 'Matches',
'data' => $data,
'backgroundColor' => 'rgba(244, 63, 94, 0.8)',
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'bar';
}
}
Membuat Widget Pending Reports
php artisan make:filament-widget PendingReports
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Report;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;
class PendingReports extends BaseWidget
{
protected static ?string $heading = 'Laporan Terbaru';
protected static ?int $sort = 4;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Report::query()
->where('status', 'pending')
->with(['reporter', 'reported'])
->latest()
->limit(5)
)
->columns([
Tables\\Columns\\TextColumn::make('reporter.display_name')
->label('Pelapor'),
Tables\\Columns\\TextColumn::make('reported.display_name')
->label('Dilaporkan'),
Tables\\Columns\\TextColumn::make('reason')
->label('Alasan')
->badge()
->color(fn (string $state): string => match ($state) {
'fake_profile' => 'warning',
'harassment' => 'danger',
'scam' => 'danger',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('created_at')
->label('Waktu')
->since(),
])
->paginated(false);
}
}
Testing Dashboard
Saya jalankan server dan akses http://localhost:8000/admin:
php artisan serve
Login dengan akun admin yang sudah dibuat, dan saya akan melihat dashboard dengan widget-widget yang menampilkan statistik platform.
Sampai di sini Filament sudah terinstall dengan dashboard yang informatif. Di bagian selanjutnya, saya akan membuat Resource untuk CRUD semua tabel yang sudah dibuat.
Bagian 5: Membuat Resource untuk CRUD Seluruh Tabel
Sekarang saya akan membuat Resource Filament untuk mengelola semua data di platform mencari jodoh ini. Saya akan fokus pada resource yang paling penting yaitu Profile, Interest, Match, SubscriptionPlan, Subscription, dan Report.
Membuat ProfileResource
Saya mulai dengan resource untuk mengelola profil pengguna:
php artisan make:filament-resource Profile --generate
Saya buka file app/Filament/Resources/ProfileResource.php dan modifikasi:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ProfileResource\\Pages;
use App\\Models\\Profile;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ProfileResource extends Resource
{
protected static ?string $model = Profile::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = 'Profiles';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Dasar')
->schema([
Forms\\Components\\Select::make('user_id')
->relationship('user', 'email')
->required()
->searchable(),
Forms\\Components\\TextInput::make('display_name')
->label('Nama Tampilan')
->required()
->maxLength(255),
Forms\\Components\\Textarea::make('bio')
->rows(3)
->maxLength(500),
Forms\\Components\\DatePicker::make('date_of_birth')
->label('Tanggal Lahir')
->required()
->maxDate(now()->subYears(18)),
Forms\\Components\\Select::make('gender')
->options([
'male' => 'Laki-laki',
'female' => 'Perempuan',
])
->required(),
Forms\\Components\\Select::make('looking_for')
->label('Mencari')
->options([
'male' => 'Laki-laki',
'female' => 'Perempuan',
'both' => 'Keduanya',
])
->required(),
])->columns(2),
Forms\\Components\\Section::make('Detail Profil')
->schema([
Forms\\Components\\TextInput::make('location')
->label('Lokasi'),
Forms\\Components\\TextInput::make('occupation')
->label('Pekerjaan'),
Forms\\Components\\TextInput::make('education')
->label('Pendidikan'),
Forms\\Components\\TextInput::make('height_cm')
->label('Tinggi (cm)')
->numeric()
->minValue(100)
->maxValue(250),
Forms\\Components\\Select::make('relationship_goal')
->label('Tujuan')
->options([
'serious' => 'Hubungan Serius',
'casual' => 'Casual Dating',
'friendship' => 'Pertemanan',
'unsure' => 'Belum Tahu',
]),
Forms\\Components\\FileUpload::make('profile_photo')
->label('Foto Profil')
->image()
->directory('profiles')
->imageEditor(),
])->columns(2),
Forms\\Components\\Section::make('Preferensi Matching')
->schema([
Forms\\Components\\TextInput::make('age_min_preference')
->label('Usia Minimum')
->numeric()
->default(18)
->minValue(18),
Forms\\Components\\TextInput::make('age_max_preference')
->label('Usia Maksimum')
->numeric()
->default(50)
->maxValue(100),
Forms\\Components\\TextInput::make('distance_preference_km')
->label('Jarak Maksimum (km)')
->numeric()
->default(50),
])->columns(3),
Forms\\Components\\Section::make('Status')
->schema([
Forms\\Components\\Toggle::make('is_verified')
->label('Terverifikasi'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
Forms\\Components\\Toggle::make('is_profile_complete')
->label('Profil Lengkap'),
])->columns(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('profile_photo')
->label('')
->circular()
->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->display_name)),
Tables\\Columns\\TextColumn::make('display_name')
->label('Nama')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('user.email')
->label('Email')
->searchable(),
Tables\\Columns\\TextColumn::make('age')
->label('Usia')
->suffix(' tahun')
->sortable(query: function ($query, $direction) {
return $query->orderBy('date_of_birth', $direction === 'asc' ? 'desc' : 'asc');
}),
Tables\\Columns\\TextColumn::make('gender')
->badge()
->color(fn (string $state): string => match ($state) {
'male' => 'info',
'female' => 'danger',
})
->formatStateUsing(fn (string $state): string => $state === 'male' ? 'L' : 'P'),
Tables\\Columns\\TextColumn::make('location')
->label('Lokasi')
->limit(20)
->toggleable(),
Tables\\Columns\\IconColumn::make('is_verified')
->label('Verified')
->boolean()
->trueIcon('heroicon-o-check-badge')
->trueColor('success'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
Tables\\Columns\\TextColumn::make('last_active_at')
->label('Terakhir Aktif')
->since()
->sortable(),
])
->filters([
Tables\\Filters\\SelectFilter::make('gender')
->options([
'male' => 'Laki-laki',
'female' => 'Perempuan',
]),
Tables\\Filters\\TernaryFilter::make('is_verified')
->label('Status Verifikasi'),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\Action::make('verify')
->label('Verifikasi')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (Profile $record) => !$record->is_verified)
->action(fn (Profile $record) => $record->update(['is_verified' => true]))
->requiresConfirmation(),
Tables\\Actions\\Action::make('suspend')
->label('Suspend')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (Profile $record) => $record->is_active)
->action(fn (Profile $record) => $record->update(['is_active' => false]))
->requiresConfirmation(),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('verifyAll')
->label('Verifikasi Semua')
->icon('heroicon-o-check-badge')
->action(fn ($records) => $records->each->update(['is_verified' => true]))
->requiresConfirmation(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListProfiles::route('/'),
'create' => Pages\\CreateProfile::route('/create'),
'edit' => Pages\\EditProfile::route('/{record}/edit'),
];
}
}
Membuat InterestResource
php artisan make:filament-resource Interest --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\InterestResource\\Pages;
use App\\Models\\Interest;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class InterestResource extends Resource
{
protected static ?string $model = Interest::class;
protected static ?string $navigationIcon = 'heroicon-o-heart';
protected static ?string $navigationLabel = 'Interests';
protected static ?string $navigationGroup = 'Settings';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Interest')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('icon')
->label('Emoji/Icon')
->maxLength(10)
->placeholder('🎵'),
Forms\\Components\\Select::make('category')
->label('Kategori')
->options([
'music' => '🎵 Musik',
'sports' => '⚽ Olahraga',
'food' => '🍕 Makanan',
'travel' => '✈️ Travel',
'movies' => '🎬 Film',
'books' => '📚 Buku',
'gaming' => '🎮 Gaming',
'art' => '🎨 Seni',
'fitness' => '💪 Fitness',
'other' => '📌 Lainnya',
]),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('icon')
->label(''),
Tables\\Columns\\TextColumn::make('name')
->label('Interest')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('category')
->label('Kategori')
->badge(),
Tables\\Columns\\TextColumn::make('profiles_count')
->label('Digunakan')
->counts('profiles')
->suffix(' users')
->sortable(),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
])
->filters([
Tables\\Filters\\SelectFilter::make('category')
->label('Kategori')
->options([
'music' => 'Musik',
'sports' => 'Olahraga',
'food' => 'Makanan',
'travel' => 'Travel',
'movies' => 'Film',
'other' => 'Lainnya',
]),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListInterests::route('/'),
'create' => Pages\\CreateInterest::route('/create'),
'edit' => Pages\\EditInterest::route('/{record}/edit'),
];
}
}
Membuat MatchResource
php artisan make:filament-resource Match --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\MatchResource\\Pages;
use App\\Models\\Match;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class MatchResource extends Resource
{
protected static ?string $model = Match::class;
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $navigationLabel = 'Matches';
protected static ?string $navigationGroup = 'Matchmaking';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Select::make('profile_one_id')
->label('User 1')
->relationship('profileOne', 'display_name')
->required()
->searchable(),
Forms\\Components\\Select::make('profile_two_id')
->label('User 2')
->relationship('profileTwo', 'display_name')
->required()
->searchable(),
Forms\\Components\\DateTimePicker::make('matched_at')
->label('Waktu Match')
->required()
->default(now()),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('profileOne.profile_photo')
->label('')
->circular()
->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->profileOne->display_name ?? 'U')),
Tables\\Columns\\TextColumn::make('profileOne.display_name')
->label('User 1')
->searchable(),
Tables\\Columns\\TextColumn::make('heart')
->label('')
->default('❤️')
->alignCenter(),
Tables\\Columns\\ImageColumn::make('profileTwo.profile_photo')
->label('')
->circular()
->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->profileTwo->display_name ?? 'U')),
Tables\\Columns\\TextColumn::make('profileTwo.display_name')
->label('User 2')
->searchable(),
Tables\\Columns\\TextColumn::make('matched_at')
->label('Matched')
->dateTime('d M Y H:i')
->sortable(),
Tables\\Columns\\TextColumn::make('conversation.messages_count')
->label('Pesan')
->counts('conversation.messages')
->default(0)
->badge()
->color('info'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
])
->defaultSort('matched_at', 'desc')
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status'),
Tables\\Filters\\Filter::make('today')
->label('Hari Ini')
->query(fn ($query) => $query->whereDate('matched_at', today()))
->toggle(),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\Action::make('unmatch')
->label('Unmatch')
->icon('heroicon-o-x-circle')
->color('danger')
->action(fn (Match $record) => $record->update(['is_active' => false]))
->requiresConfirmation(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListMatches::route('/'),
'create' => Pages\\CreateMatch::route('/create'),
];
}
}
Membuat SubscriptionPlanResource
php artisan make:filament-resource SubscriptionPlan --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\SubscriptionPlanResource\\Pages;
use App\\Models\\SubscriptionPlan;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class SubscriptionPlanResource extends Resource
{
protected static ?string $model = SubscriptionPlan::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationLabel = 'Paket Langganan';
protected static ?string $navigationGroup = 'Subscriptions';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Paket')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Paket')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('slug')
->required()
->unique(ignoreRecord: true),
Forms\\Components\\Textarea::make('description')
->label('Deskripsi')
->rows(2),
Forms\\Components\\TextInput::make('price')
->label('Harga')
->numeric()
->prefix('Rp')
->required(),
Forms\\Components\\TextInput::make('duration_days')
->label('Durasi (hari)')
->numeric()
->required()
->default(30),
])->columns(2),
Forms\\Components\\Section::make('Fitur & Benefit')
->schema([
Forms\\Components\\TextInput::make('daily_likes_limit')
->label('Limit Like/Hari')
->numeric()
->helperText('Kosongkan jika unlimited'),
Forms\\Components\\TextInput::make('daily_super_likes')
->label('Super Like/Hari')
->numeric()
->default(0),
Forms\\Components\\Toggle::make('unlimited_likes')
->label('Unlimited Likes'),
Forms\\Components\\Toggle::make('can_see_who_likes')
->label('Lihat Siapa yang Like'),
Forms\\Components\\Toggle::make('can_boost_profile')
->label('Boost Profile'),
Forms\\Components\\Toggle::make('no_ads')
->label('Tanpa Iklan'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])->columns(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->label('Paket')
->weight('bold')
->searchable(),
Tables\\Columns\\TextColumn::make('price')
->label('Harga')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('duration_days')
->label('Durasi')
->suffix(' hari'),
Tables\\Columns\\IconColumn::make('unlimited_likes')
->label('∞ Likes')
->boolean(),
Tables\\Columns\\IconColumn::make('can_see_who_likes')
->label('See Likes')
->boolean(),
Tables\\Columns\\IconColumn::make('can_boost_profile')
->label('Boost')
->boolean(),
Tables\\Columns\\TextColumn::make('subscriptions_count')
->label('Subscribers')
->counts('subscriptions')
->badge()
->color('success'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean(),
])
->actions([
Tables\\Actions\\EditAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListSubscriptionPlans::route('/'),
'create' => Pages\\CreateSubscriptionPlan::route('/create'),
'edit' => Pages\\EditSubscriptionPlan::route('/{record}/edit'),
];
}
}
Membuat ReportResource
php artisan make:filament-resource Report --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ReportResource\\Pages;
use App\\Models\\Report;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-flag';
protected static ?string $navigationLabel = 'Reports';
protected static ?string $navigationGroup = 'Moderation';
protected static ?int $navigationSort = 1;
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('status', 'pending')->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Detail Laporan')
->schema([
Forms\\Components\\Select::make('reporter_id')
->label('Pelapor')
->relationship('reporter', 'display_name')
->disabled(),
Forms\\Components\\Select::make('reported_id')
->label('Dilaporkan')
->relationship('reported', 'display_name')
->disabled(),
Forms\\Components\\TextInput::make('reason')
->label('Alasan')
->disabled(),
Forms\\Components\\Textarea::make('description')
->label('Deskripsi')
->disabled()
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Review')
->schema([
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'pending' => 'Pending',
'reviewed' => 'Reviewed',
'resolved' => 'Resolved',
'dismissed' => 'Dismissed',
])
->required(),
Forms\\Components\\Textarea::make('admin_notes')
->label('Catatan Admin')
->rows(3),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('reporter.display_name')
->label('Pelapor')
->searchable(),
Tables\\Columns\\TextColumn::make('reported.display_name')
->label('Dilaporkan')
->searchable(),
Tables\\Columns\\TextColumn::make('reason')
->label('Alasan')
->badge()
->color(fn (string $state): string => match ($state) {
'fake_profile' => 'warning',
'harassment' => 'danger',
'scam' => 'danger',
'spam' => 'warning',
'inappropriate_content' => 'warning',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'reviewed' => 'info',
'resolved' => 'success',
'dismissed' => 'gray',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dilaporkan')
->since()
->sortable(),
Tables\\Columns\\TextColumn::make('reviewer.name')
->label('Reviewer')
->placeholder('-'),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\\Filters\\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'reviewed' => 'Reviewed',
'resolved' => 'Resolved',
'dismissed' => 'Dismissed',
]),
Tables\\Filters\\SelectFilter::make('reason')
->options([
'fake_profile' => 'Fake Profile',
'harassment' => 'Harassment',
'scam' => 'Scam',
'spam' => 'Spam',
'inappropriate_content' => 'Inappropriate Content',
'other' => 'Other',
]),
])
->actions([
Tables\\Actions\\Action::make('review')
->label('Review')
->icon('heroicon-o-eye')
->color('info')
->url(fn (Report $record) => static::getUrl('edit', ['record' => $record])),
Tables\\Actions\\Action::make('resolve')
->label('Resolve')
->icon('heroicon-o-check')
->color('success')
->visible(fn (Report $record) => $record->status !== 'resolved')
->action(function (Report $record) {
$record->update([
'status' => 'resolved',
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
]);
})
->requiresConfirmation(),
Tables\\Actions\\Action::make('suspendUser')
->label('Suspend User')
->icon('heroicon-o-no-symbol')
->color('danger')
->action(function (Report $record) {
$record->reported->update(['is_active' => false]);
$record->update([
'status' => 'resolved',
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
'admin_notes' => 'User suspended',
]);
})
->requiresConfirmation()
->modalHeading('Suspend User?')
->modalDescription('User yang dilaporkan akan di-suspend dan tidak bisa mengakses platform.'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListReports::route('/'),
'edit' => Pages\\EditReport::route('/{record}/edit'),
];
}
}
Ringkasan Resource yang Dibuat
Saya sudah membuat 5 resource utama dengan fitur-fitur berikut:
ProfileResource - Mengelola profil user dengan fitur verifikasi dan suspend InterestResource - Mengelola daftar minat/hobi dengan kategori MatchResource - Melihat semua matches dengan jumlah pesan SubscriptionPlanResource - Mengatur paket langganan premium ReportResource - Moderasi laporan dengan badge notifikasi
Setiap resource sudah dilengkapi dengan form, table, filter, dan action yang sesuai kebutuhan platform dating. Di bagian selanjutnya, saya akan membuat fitur upload foto profil dan galeri.
Bagian 6: Membuat Fitur Upload Foto Profil dan Galeri
Sekarang saya akan membuat fitur upload foto yang sangat penting untuk platform dating. User perlu bisa mengupload foto profil utama dan beberapa foto tambahan untuk galeri. Saya juga akan menambahkan validasi keamanan dan fitur thumbnail otomatis.
Membuat Migration untuk Photo Gallery
Pertama saya bikin tabel untuk menyimpan multiple foto:
php artisan make:migration create_profile_photos_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('profile_photos', function (Blueprint $table) {
$table->id();
$table->foreignId('profile_id')->constrained()->onDelete('cascade');
$table->string('path');
$table->string('thumbnail_path')->nullable();
$table->integer('order')->default(0);
$table->boolean('is_primary')->default(false);
$table->boolean('is_approved')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('profile_photos');
}
};
php artisan migrate
Membuat Model ProfilePhoto
php artisan make:model ProfilePhoto
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Support\\Facades\\Storage;
class ProfilePhoto extends Model
{
protected $fillable = [
'profile_id',
'path',
'thumbnail_path',
'order',
'is_primary',
'is_approved',
];
protected $casts = [
'is_primary' => 'boolean',
'is_approved' => 'boolean',
];
public function profile(): BelongsTo
{
return $this->belongsTo(Profile::class);
}
public function getUrlAttribute(): string
{
return Storage::url($this->path);
}
public function getThumbnailUrlAttribute(): string
{
return $this->thumbnail_path
? Storage::url($this->thumbnail_path)
: $this->url;
}
}
Update Model Profile
Saya tambahkan relationship ke ProfilePhoto di model Profile:
// Tambahkan di app/Models/Profile.php
public function photos(): HasMany
{
return $this->hasMany(ProfilePhoto::class)->orderBy('order');
}
public function primaryPhoto()
{
return $this->hasOne(ProfilePhoto::class)->where('is_primary', true);
}
public function approvedPhotos(): HasMany
{
return $this->hasMany(ProfilePhoto::class)
->where('is_approved', true)
->orderBy('order');
}
Menginstall Intervention Image
Saya install package untuk manipulasi gambar:
composer require intervention/image-laravel
Publish config:
php artisan vendor:publish --provider="Intervention\\Image\\Laravel\\ServiceProvider"
Membuat Service untuk Upload Foto
Saya bikin service class untuk handle upload foto:
<?php
namespace App\\Services;
use App\\Models\\Profile;
use App\\Models\\ProfilePhoto;
use Illuminate\\Http\\UploadedFile;
use Illuminate\\Support\\Facades\\Storage;
use Intervention\\Image\\Laravel\\Facades\\Image;
class PhotoUploadService
{
protected int $maxWidth = 1200;
protected int $maxHeight = 1200;
protected int $thumbnailSize = 300;
protected int $quality = 85;
public function upload(Profile $profile, UploadedFile $file, bool $isPrimary = false): ProfilePhoto
{
$filename = uniqid() . '_' . time() . '.jpg';
$path = "profiles/{$profile->id}/{$filename}";
$thumbnailPath = "profiles/{$profile->id}/thumb_{$filename}";
$image = Image::read($file);
$image->scaleDown(width: $this->maxWidth, height: $this->maxHeight);
Storage::disk('public')->put($path, $image->toJpeg($this->quality));
$thumbnail = Image::read($file);
$thumbnail->cover($this->thumbnailSize, $this->thumbnailSize);
Storage::disk('public')->put($thumbnailPath, $thumbnail->toJpeg($this->quality));
if ($isPrimary) {
$profile->photos()->update(['is_primary' => false]);
}
$order = $profile->photos()->max('order') + 1;
return ProfilePhoto::create([
'profile_id' => $profile->id,
'path' => $path,
'thumbnail_path' => $thumbnailPath,
'order' => $order,
'is_primary' => $isPrimary,
'is_approved' => false,
]);
}
public function delete(ProfilePhoto $photo): bool
{
Storage::disk('public')->delete($photo->path);
if ($photo->thumbnail_path) {
Storage::disk('public')->delete($photo->thumbnail_path);
}
return $photo->delete();
}
public function setPrimary(ProfilePhoto $photo): void
{
$photo->profile->photos()->update(['is_primary' => false]);
$photo->update(['is_primary' => true]);
$photo->profile->update(['profile_photo' => $photo->path]);
}
public function reorder(Profile $profile, array $photoIds): void
{
foreach ($photoIds as $order => $photoId) {
ProfilePhoto::where('id', $photoId)
->where('profile_id', $profile->id)
->update(['order' => $order]);
}
}
}
Membuat Controller untuk Upload Foto
php artisan make:controller User/PhotoController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\ProfilePhoto;
use App\\Services\\PhotoUploadService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
class PhotoController extends Controller
{
public function __construct(
protected PhotoUploadService $photoService
) {}
public function store(Request $request): JsonResponse
{
$request->validate([
'photo' => 'required|image|mimes:jpeg,png,jpg|max:5120',
'is_primary' => 'boolean',
]);
$profile = $request->user()->profile;
if ($profile->photos()->count() >= 6) {
return response()->json([
'message' => 'Maksimal 6 foto'
], 422);
}
$photo = $this->photoService->upload(
$profile,
$request->file('photo'),
$request->boolean('is_primary')
);
return response()->json([
'message' => 'Foto berhasil diupload',
'photo' => [
'id' => $photo->id,
'url' => $photo->url,
'thumbnail_url' => $photo->thumbnail_url,
'is_primary' => $photo->is_primary,
]
]);
}
public function destroy(ProfilePhoto $photo): JsonResponse
{
$this->authorize('delete', $photo);
$this->photoService->delete($photo);
return response()->json([
'message' => 'Foto berhasil dihapus'
]);
}
public function setPrimary(ProfilePhoto $photo): JsonResponse
{
$this->authorize('update', $photo);
$this->photoService->setPrimary($photo);
return response()->json([
'message' => 'Foto utama berhasil diubah'
]);
}
public function reorder(Request $request): JsonResponse
{
$request->validate([
'photo_ids' => 'required|array',
'photo_ids.*' => 'exists:profile_photos,id',
]);
$profile = $request->user()->profile;
$this->photoService->reorder($profile, $request->photo_ids);
return response()->json([
'message' => 'Urutan foto berhasil diubah'
]);
}
}
Membuat Filament Resource untuk Moderasi Foto
php artisan make:filament-resource ProfilePhoto --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ProfilePhotoResource\\Pages;
use App\\Models\\ProfilePhoto;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ProfilePhotoResource extends Resource
{
protected static ?string $model = ProfilePhoto::class;
protected static ?string $navigationIcon = 'heroicon-o-photo';
protected static ?string $navigationLabel = 'Photo Moderation';
protected static ?string $navigationGroup = 'Moderation';
protected static ?int $navigationSort = 2;
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('is_approved', false)->count() ?: null;
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('path')
->label('Foto')
->disk('public')
->height(80)
->width(80),
Tables\\Columns\\TextColumn::make('profile.display_name')
->label('User')
->searchable(),
Tables\\Columns\\IconColumn::make('is_primary')
->label('Primary')
->boolean(),
Tables\\Columns\\IconColumn::make('is_approved')
->label('Approved')
->boolean()
->trueColor('success')
->falseColor('warning'),
Tables\\Columns\\TextColumn::make('created_at')
->label('Uploaded')
->since()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\\Filters\\TernaryFilter::make('is_approved')
->label('Status Approval'),
])
->actions([
Tables\\Actions\\Action::make('approve')
->label('Approve')
->icon('heroicon-o-check')
->color('success')
->visible(fn (ProfilePhoto $record) => !$record->is_approved)
->action(fn (ProfilePhoto $record) => $record->update(['is_approved' => true])),
Tables\\Actions\\Action::make('reject')
->label('Reject')
->icon('heroicon-o-x-mark')
->color('danger')
->action(function (ProfilePhoto $record) {
app(PhotoUploadService::class)->delete($record);
})
->requiresConfirmation(),
])
->bulkActions([
Tables\\Actions\\BulkAction::make('approveAll')
->label('Approve All')
->icon('heroicon-o-check')
->action(fn ($records) => $records->each->update(['is_approved' => true])),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListProfilePhotos::route('/'),
];
}
}
Menambahkan Route
// routes/web.php
Route::middleware(['auth', 'verified'])->prefix('user')->name('user.')->group(function () {
Route::post('/photos', [PhotoController::class, 'store'])->name('photos.store');
Route::delete('/photos/{photo}', [PhotoController::class, 'destroy'])->name('photos.destroy');
Route::post('/photos/{photo}/primary', [PhotoController::class, 'setPrimary'])->name('photos.primary');
Route::post('/photos/reorder', [PhotoController::class, 'reorder'])->name('photos.reorder');
});
Membuat Policy untuk Photo
php artisan make:policy ProfilePhotoPolicy --model=ProfilePhoto
<?php
namespace App\\Policies;
use App\\Models\\ProfilePhoto;
use App\\Models\\User;
class ProfilePhotoPolicy
{
public function update(User $user, ProfilePhoto $photo): bool
{
return $user->profile->id === $photo->profile_id;
}
public function delete(User $user, ProfilePhoto $photo): bool
{
return $user->profile->id === $photo->profile_id;
}
}
Daftarkan policy di AppServiceProvider:
use App\\Models\\ProfilePhoto;
use App\\Policies\\ProfilePhotoPolicy;
use Illuminate\\Support\\Facades\\Gate;
public function boot(): void
{
Gate::policy(ProfilePhoto::class, ProfilePhotoPolicy::class);
}
Ringkasan Fitur Foto
Saya sudah membuat sistem foto lengkap dengan fitur:
- Upload foto dengan resize otomatis (max 1200px)
- Thumbnail otomatis (300x300px)
- Maksimal 6 foto per profil
- Set foto primary
- Reorder foto dengan drag & drop
- Moderasi foto oleh admin dengan approve/reject
- Validasi format dan ukuran file (max 5MB)
Di bagian selanjutnya, saya akan membuat sistem matching dan discovery untuk menampilkan profil yang sesuai preferensi.
Bagian 7: Membuat Sistem Matching dan Discovery
Sekarang saya akan membuat fitur inti dari platform dating yaitu sistem matching dan discovery. Fitur ini memungkinkan user untuk melihat profil orang lain yang sesuai preferensi, melakukan like atau pass, dan terbentuk match ketika dua orang saling like.
Membuat DiscoveryService
Saya bikin service untuk menampilkan profil yang sesuai preferensi user:
<?php
namespace App\\Services;
use App\\Models\\Profile;
use App\\Models\\Swipe;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Collection;
class DiscoveryService
{
public function getDiscoverProfiles(Profile $profile, int $limit = 10): Collection
{
$swipedIds = Swipe::where('swiper_id', $profile->id)
->pluck('swiped_id')
->toArray();
$swipedIds[] = $profile->id;
$query = Profile::query()
->where('is_active', true)
->where('is_profile_complete', true)
->whereNotIn('id', $swipedIds)
->with(['photos' => fn($q) => $q->where('is_approved', true)->orderBy('order')]);
$this->applyGenderFilter($query, $profile);
$this->applyAgeFilter($query, $profile);
if ($profile->latitude && $profile->longitude) {
$this->applyDistanceFilter($query, $profile);
}
$profiles = $query->inRandomOrder()->limit($limit)->get();
return $profiles->map(function ($p) use ($profile) {
$p->distance_km = $this->calculateDistance($profile, $p);
$p->common_interests = $this->getCommonInterests($profile, $p);
return $p;
});
}
protected function applyGenderFilter(Builder $query, Profile $profile): void
{
if ($profile->looking_for !== 'both') {
$query->where('gender', $profile->looking_for);
}
$query->where(function ($q) use ($profile) {
$q->where('looking_for', $profile->gender)
->orWhere('looking_for', 'both');
});
}
protected function applyAgeFilter(Builder $query, Profile $profile): void
{
$minDate = now()->subYears($profile->age_max_preference)->toDateString();
$maxDate = now()->subYears($profile->age_min_preference)->toDateString();
$query->whereBetween('date_of_birth', [$minDate, $maxDate]);
}
protected function applyDistanceFilter(Builder $query, Profile $profile): void
{
$lat = $profile->latitude;
$lng = $profile->longitude;
$radius = $profile->distance_preference_km;
$query->selectRaw("
profiles.*,
(6371 * acos(
cos(radians(?)) * cos(radians(latitude)) *
cos(radians(longitude) - radians(?)) +
sin(radians(?)) * sin(radians(latitude))
)) AS distance
", [$lat, $lng, $lat])
->having('distance', '<=', $radius)
->orderBy('distance');
}
protected function calculateDistance(Profile $from, Profile $to): ?float
{
if (!$from->latitude || !$to->latitude) {
return null;
}
$earthRadius = 6371;
$latDiff = deg2rad($to->latitude - $from->latitude);
$lngDiff = deg2rad($to->longitude - $from->longitude);
$a = sin($latDiff / 2) * sin($latDiff / 2) +
cos(deg2rad($from->latitude)) * cos(deg2rad($to->latitude)) *
sin($lngDiff / 2) * sin($lngDiff / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return round($earthRadius * $c, 1);
}
protected function getCommonInterests(Profile $profile1, Profile $profile2): Collection
{
$interests1 = $profile1->interests->pluck('id');
$interests2 = $profile2->interests->pluck('id');
return $profile1->interests->whereIn('id', $interests2->intersect($interests1));
}
}
Membuat MatchingService
<?php
namespace App\\Services;
use App\\Models\\Conversation;
use App\\Models\\Match;
use App\\Models\\Profile;
use App\\Models\\Swipe;
use App\\Events\\NewMatch;
use Exception;
class MatchingService
{
public function swipe(Profile $swiper, Profile $swiped, string $action): array
{
if ($swiper->id === $swiped->id) {
throw new Exception('Tidak bisa swipe diri sendiri');
}
$existingSwipe = Swipe::where('swiper_id', $swiper->id)
->where('swiped_id', $swiped->id)
->first();
if ($existingSwipe) {
throw new Exception('Sudah pernah swipe user ini');
}
Swipe::create([
'swiper_id' => $swiper->id,
'swiped_id' => $swiped->id,
'action' => $action,
]);
$result = [
'action' => $action,
'is_match' => false,
'match' => null,
];
if (in_array($action, ['like', 'super_like'])) {
$mutualLike = Swipe::where('swiper_id', $swiped->id)
->where('swiped_id', $swiper->id)
->whereIn('action', ['like', 'super_like'])
->exists();
if ($mutualLike) {
$match = $this->createMatch($swiper, $swiped);
$result['is_match'] = true;
$result['match'] = $match;
}
}
return $result;
}
protected function createMatch(Profile $profile1, Profile $profile2): Match
{
$ids = [$profile1->id, $profile2->id];
sort($ids);
$match = Match::create([
'profile_one_id' => $ids[0],
'profile_two_id' => $ids[1],
'matched_at' => now(),
'is_active' => true,
]);
Conversation::create([
'match_id' => $match->id,
]);
event(new NewMatch($match));
return $match->load(['profileOne', 'profileTwo']);
}
public function unmatch(Match $match): void
{
$match->update(['is_active' => false]);
$match->conversation?->delete();
}
public function getMatches(Profile $profile): Collection
{
return Match::where('is_active', true)
->where(function ($q) use ($profile) {
$q->where('profile_one_id', $profile->id)
->orWhere('profile_two_id', $profile->id);
})
->with(['profileOne.photos', 'profileTwo.photos', 'conversation'])
->latest('matched_at')
->get()
->map(function ($match) use ($profile) {
$match->other_profile = $match->profile_one_id === $profile->id
? $match->profileTwo
: $match->profileOne;
return $match;
});
}
public function getLikedBy(Profile $profile): Collection
{
$likerIds = Swipe::where('swiped_id', $profile->id)
->whereIn('action', ['like', 'super_like'])
->pluck('swiper_id');
$alreadySwipedIds = Swipe::where('swiper_id', $profile->id)
->pluck('swiped_id');
return Profile::whereIn('id', $likerIds)
->whereNotIn('id', $alreadySwipedIds)
->with('photos')
->get();
}
}
Membuat Event NewMatch
php artisan make:event NewMatch
<?php
namespace App\\Events;
use App\\Models\\Match;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class NewMatch implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Match $match
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('user.' . $this->match->profileOne->user_id),
new PrivateChannel('user.' . $this->match->profileTwo->user_id),
];
}
public function broadcastWith(): array
{
return [
'match_id' => $this->match->id,
'matched_at' => $this->match->matched_at,
];
}
}
Membuat Controller untuk Discovery
php artisan make:controller User/DiscoveryController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Profile;
use App\\Services\\DiscoveryService;
use App\\Services\\MatchingService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class DiscoveryController extends Controller
{
public function __construct(
protected DiscoveryService $discoveryService,
protected MatchingService $matchingService
) {}
public function index(Request $request): View
{
$profile = $request->user()->profile;
$profiles = $this->discoveryService->getDiscoverProfiles($profile, 10);
return view('user.discovery.index', [
'profiles' => $profiles,
]);
}
public function getProfiles(Request $request): JsonResponse
{
$profile = $request->user()->profile;
$profiles = $this->discoveryService->getDiscoverProfiles($profile, 10);
return response()->json([
'profiles' => $profiles->map(fn ($p) => [
'id' => $p->id,
'display_name' => $p->display_name,
'age' => $p->age,
'bio' => $p->bio,
'location' => $p->location,
'distance_km' => $p->distance_km,
'occupation' => $p->occupation,
'photos' => $p->photos->map(fn ($photo) => $photo->url),
'interests' => $p->interests->pluck('name'),
'common_interests' => $p->common_interests->pluck('name'),
]),
]);
}
public function swipe(Request $request, Profile $profile): JsonResponse
{
$request->validate([
'action' => 'required|in:like,pass,super_like',
]);
$myProfile = $request->user()->profile;
$action = $request->action;
if ($action === 'super_like') {
$dailyLimit = $this->getSuperLikeLimit($request->user());
$todayCount = Swipe::where('swiper_id', $myProfile->id)
->where('action', 'super_like')
->whereDate('created_at', today())
->count();
if ($todayCount >= $dailyLimit) {
return response()->json([
'message' => 'Batas super like hari ini sudah habis'
], 422);
}
}
if ($action === 'like' && !$request->user()->isPremium()) {
$dailyLimit = 20;
$todayCount = Swipe::where('swiper_id', $myProfile->id)
->whereIn('action', ['like', 'super_like'])
->whereDate('created_at', today())
->count();
if ($todayCount >= $dailyLimit) {
return response()->json([
'message' => 'Batas like hari ini sudah habis. Upgrade ke premium untuk unlimited likes!',
'upgrade_required' => true,
], 422);
}
}
try {
$result = $this->matchingService->swipe($myProfile, $profile, $action);
return response()->json([
'success' => true,
'action' => $result['action'],
'is_match' => $result['is_match'],
'match' => $result['is_match'] ? [
'id' => $result['match']->id,
'other_profile' => [
'display_name' => $profile->display_name,
'photo' => $profile->photos->first()?->url,
],
] : null,
]);
} catch (\\Exception $e) {
return response()->json([
'message' => $e->getMessage()
], 422);
}
}
protected function getSuperLikeLimit($user): int
{
$subscription = $user->activeSubscription();
if ($subscription) {
return $subscription->plan->daily_super_likes;
}
return 1;
}
}
Membuat Controller untuk Matches
php artisan make:controller User/MatchController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Match;
use App\\Services\\MatchingService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class MatchController extends Controller
{
public function __construct(
protected MatchingService $matchingService
) {}
public function index(Request $request): View
{
$profile = $request->user()->profile;
$matches = $this->matchingService->getMatches($profile);
return view('user.matches.index', [
'matches' => $matches,
]);
}
public function likedBy(Request $request): View|JsonResponse
{
$user = $request->user();
if (!$user->isPremium()) {
if ($request->wantsJson()) {
return response()->json([
'message' => 'Upgrade ke premium untuk melihat siapa yang like kamu',
'upgrade_required' => true,
], 403);
}
return view('user.matches.upgrade-required');
}
$profile = $user->profile;
$likers = $this->matchingService->getLikedBy($profile);
if ($request->wantsJson()) {
return response()->json(['likers' => $likers]);
}
return view('user.matches.liked-by', [
'likers' => $likers,
]);
}
public function unmatch(Request $request, Match $match): JsonResponse
{
$profile = $request->user()->profile;
if ($match->profile_one_id !== $profile->id && $match->profile_two_id !== $profile->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$this->matchingService->unmatch($match);
return response()->json([
'message' => 'Berhasil unmatch'
]);
}
}
Membuat View Discovery
{{-- resources/views/user/discovery/index.blade.php --}}
<x-app-layout>
<div class="max-w-lg mx-auto py-8 px-4">
<div id="discovery-container" class="relative">
@forelse($profiles as $profile)
<div class="profile-card bg-white rounded-2xl shadow-xl overflow-hidden mb-4" data-profile-id="{{ $profile->id }}">
<div class="relative">
<div class="aspect-[3/4] bg-gray-200">
@if($profile->photos->count() > 0)
<img src="{{ $profile->photos->first()->url }}"
alt="{{ $profile->display_name }}"
class="w-full h-full object-cover">
@endif
</div>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-6">
<h2 class="text-2xl font-bold text-white">
{{ $profile->display_name }}, {{ $profile->age }}
</h2>
@if($profile->distance_km)
<p class="text-white/80 text-sm">📍 {{ $profile->distance_km }} km</p>
@endif
</div>
</div>
<div class="p-4">
@if($profile->bio)
<p class="text-gray-600 mb-3">{{ Str::limit($profile->bio, 100) }}</p>
@endif
@if($profile->common_interests->count() > 0)
<div class="flex flex-wrap gap-2 mb-4">
@foreach($profile->common_interests as $interest)
<span class="px-3 py-1 bg-rose-100 text-rose-600 rounded-full text-sm">
{{ $interest->icon }} {{ $interest->name }}
</span>
@endforeach
</div>
@endif
<div class="flex justify-center gap-4 pt-4">
<button onclick="swipe({{ $profile->id }}, 'pass')"
class="w-14 h-14 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-2xl transition">
✕
</button>
<button onclick="swipe({{ $profile->id }}, 'super_like')"
class="w-14 h-14 rounded-full bg-blue-100 hover:bg-blue-200 flex items-center justify-center text-2xl transition">
⭐
</button>
<button onclick="swipe({{ $profile->id }}, 'like')"
class="w-14 h-14 rounded-full bg-rose-100 hover:bg-rose-200 flex items-center justify-center text-2xl transition">
❤️
</button>
</div>
</div>
</div>
@empty
<div class="text-center py-20">
<p class="text-6xl mb-4">🔍</p>
<h3 class="text-xl font-semibold text-gray-700 mb-2">Tidak ada profil</h3>
<p class="text-gray-500">Coba perluas preferensi pencarian kamu</p>
</div>
@endforelse
</div>
</div>
<div id="match-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-2xl p-8 max-w-sm mx-4 text-center">
<p class="text-6xl mb-4">🎉</p>
<h2 class="text-2xl font-bold text-gray-800 mb-2">It's a Match!</h2>
<p class="text-gray-600 mb-6">Kamu dan <span id="match-name"></span> saling menyukai</p>
<div class="flex gap-3">
<button onclick="closeMatchModal()" class="flex-1 px-4 py-2 bg-gray-200 rounded-lg">
Lanjut Swipe
</button>
<a id="chat-link" href="#" class="flex-1 px-4 py-2 bg-rose-500 text-white rounded-lg">
Kirim Pesan
</a>
</div>
</div>
</div>
@push('scripts')
<script>
function swipe(profileId, action) {
fetch(`/user/discovery/${profileId}/swipe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ action })
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.querySelector(`[data-profile-id="${profileId}"]`).remove();
if (data.is_match) {
showMatchModal(data.match);
}
}
})
.catch(err => {
alert(err.message || 'Terjadi kesalahan');
});
}
function showMatchModal(match) {
document.getElementById('match-name').textContent = match.other_profile.display_name;
document.getElementById('chat-link').href = `/user/chat/${match.id}`;
document.getElementById('match-modal').classList.remove('hidden');
document.getElementById('match-modal').classList.add('flex');
}
function closeMatchModal() {
document.getElementById('match-modal').classList.add('hidden');
document.getElementById('match-modal').classList.remove('flex');
}
</script>
@endpush
</x-app-layout>
Menambahkan Route
// routes/web.php
Route::middleware(['auth', 'verified'])->prefix('user')->name('user.')->group(function () {
Route::get('/discover', [DiscoveryController::class, 'index'])->name('discover');
Route::get('/discover/profiles', [DiscoveryController::class, 'getProfiles'])->name('discover.profiles');
Route::post('/discovery/{profile}/swipe', [DiscoveryController::class, 'swipe'])->name('discover.swipe');
Route::get('/matches', [MatchController::class, 'index'])->name('matches');
Route::get('/matches/liked-by', [MatchController::class, 'likedBy'])->name('matches.liked-by');
Route::delete('/matches/{match}', [MatchController::class, 'unmatch'])->name('matches.unmatch');
});
Ringkasan Sistem Matching
Saya sudah membuat sistem matching lengkap dengan fitur:
- Discovery berdasarkan preferensi (gender, usia, jarak)
- Perhitungan jarak menggunakan formula Haversine
- Common interests untuk menampilkan kesamaan
- Like, Pass, dan Super Like
- Daily limit untuk free user (20 likes, 1 super like)
- Match terbentuk ketika saling like
- Fitur "See who likes you" untuk premium
- Real-time notification dengan event broadcasting
Di bagian selanjutnya, saya akan membuat fitur real-time chat untuk komunikasi antara matched users.
Bagian 8: Menambahkan Fitur Real-Time Chat
Sekarang saya akan membuat fitur chat real-time agar user yang sudah match bisa berkomunikasi. Saya akan menggunakan Laravel Reverb untuk WebSocket dan membuat fitur typing indicator serta read receipt.
Menginstall Laravel Reverb
php artisan install:broadcasting
Pilih Reverb saat ditanya. Kemudian install package:
composer require laravel/reverb
php artisan reverb:install
Update file .env:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Membuat Event untuk Chat
php artisan make:event MessageSent
php artisan make:event UserTyping
<?php
namespace App\\Events;
use App\\Models\\Message;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Message $message
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('conversation.' . $this->message->conversation_id),
];
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'body' => $this->message->body,
'sender_id' => $this->message->sender_id,
'sender_name' => $this->message->sender->display_name,
'created_at' => $this->message->created_at->toISOString(),
];
}
}
<?php
namespace App\\Events;
use App\\Models\\Profile;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class UserTyping implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $conversationId,
public Profile $profile,
public bool $isTyping = true
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('conversation.' . $this->conversationId),
];
}
public function broadcastWith(): array
{
return [
'profile_id' => $this->profile->id,
'display_name' => $this->profile->display_name,
'is_typing' => $this->isTyping,
];
}
}
Membuat ChatService
<?php
namespace App\\Services;
use App\\Events\\MessageSent;
use App\\Models\\Conversation;
use App\\Models\\Message;
use App\\Models\\Profile;
use Illuminate\\Support\\Collection;
class ChatService
{
public function getConversations(Profile $profile): Collection
{
return Conversation::whereHas('match', function ($q) use ($profile) {
$q->where('is_active', true)
->where(function ($q2) use ($profile) {
$q2->where('profile_one_id', $profile->id)
->orWhere('profile_two_id', $profile->id);
});
})
->with(['match.profileOne.photos', 'match.profileTwo.photos', 'messages' => fn($q) => $q->latest()->limit(1)])
->get()
->map(function ($conversation) use ($profile) {
$match = $conversation->match;
$conversation->other_profile = $match->profile_one_id === $profile->id
? $match->profileTwo
: $match->profileOne;
$conversation->last_message = $conversation->messages->first();
$conversation->unread_count = $conversation->messages()
->where('sender_id', '!=', $profile->id)
->whereNull('read_at')
->count();
return $conversation;
})
->sortByDesc(fn($c) => $c->last_message?->created_at);
}
public function getMessages(Conversation $conversation, int $limit = 50): Collection
{
return $conversation->messages()
->with('sender')
->latest()
->limit($limit)
->get()
->reverse()
->values();
}
public function sendMessage(Conversation $conversation, Profile $sender, string $body, ?string $attachment = null): Message
{
$message = Message::create([
'conversation_id' => $conversation->id,
'sender_id' => $sender->id,
'body' => $body,
'attachment' => $attachment,
]);
$conversation->update(['last_message_at' => now()]);
broadcast(new MessageSent($message))->toOthers();
return $message;
}
public function markAsRead(Conversation $conversation, Profile $reader): int
{
return Message::where('conversation_id', $conversation->id)
->where('sender_id', '!=', $reader->id)
->whereNull('read_at')
->update(['read_at' => now()]);
}
}
Membuat ChatController
php artisan make:controller User/ChatController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Events\\UserTyping;
use App\\Models\\Conversation;
use App\\Services\\ChatService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class ChatController extends Controller
{
public function __construct(
protected ChatService $chatService
) {}
public function index(Request $request): View
{
$profile = $request->user()->profile;
$conversations = $this->chatService->getConversations($profile);
return view('user.chat.index', [
'conversations' => $conversations,
]);
}
public function show(Request $request, Conversation $conversation): View|JsonResponse
{
$profile = $request->user()->profile;
if (!$this->canAccessConversation($conversation, $profile)) {
abort(403);
}
$this->chatService->markAsRead($conversation, $profile);
$messages = $this->chatService->getMessages($conversation);
$otherProfile = $conversation->match->profile_one_id === $profile->id
? $conversation->match->profileTwo
: $conversation->match->profileOne;
if ($request->wantsJson()) {
return response()->json([
'messages' => $messages,
'other_profile' => $otherProfile,
]);
}
return view('user.chat.show', [
'conversation' => $conversation,
'messages' => $messages,
'otherProfile' => $otherProfile,
]);
}
public function sendMessage(Request $request, Conversation $conversation): JsonResponse
{
$request->validate([
'body' => 'required|string|max:1000',
]);
$profile = $request->user()->profile;
if (!$this->canAccessConversation($conversation, $profile)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$message = $this->chatService->sendMessage(
$conversation,
$profile,
$request->body
);
return response()->json([
'message' => [
'id' => $message->id,
'body' => $message->body,
'sender_id' => $message->sender_id,
'created_at' => $message->created_at->toISOString(),
]
]);
}
public function typing(Request $request, Conversation $conversation): JsonResponse
{
$profile = $request->user()->profile;
if (!$this->canAccessConversation($conversation, $profile)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
broadcast(new UserTyping(
$conversation->id,
$profile,
$request->boolean('is_typing', true)
))->toOthers();
return response()->json(['success' => true]);
}
public function markRead(Request $request, Conversation $conversation): JsonResponse
{
$profile = $request->user()->profile;
if (!$this->canAccessConversation($conversation, $profile)) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$count = $this->chatService->markAsRead($conversation, $profile);
return response()->json(['marked_count' => $count]);
}
protected function canAccessConversation(Conversation $conversation, $profile): bool
{
$match = $conversation->match;
return $match->is_active &&
($match->profile_one_id === $profile->id || $match->profile_two_id === $profile->id);
}
}
Mengkonfigurasi Broadcasting Channels
// routes/channels.php
<?php
use App\\Models\\Conversation;
use Illuminate\\Support\\Facades\\Broadcast;
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
$conversation = Conversation::find($conversationId);
if (!$conversation) {
return false;
}
$match = $conversation->match;
$profileId = $user->profile?->id;
return $match->profile_one_id === $profileId || $match->profile_two_id === $profileId;
});
Broadcast::channel('user.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
Membuat View Chat
{{-- resources/views/user/chat/index.blade.php --}}
<x-app-layout>
<div class="max-w-2xl mx-auto py-6 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Messages</h1>
<div class="bg-white rounded-xl shadow-sm divide-y">
@forelse($conversations as $conversation)
<a href="{{ route('user.chat.show', $conversation) }}"
class="flex items-center gap-4 p-4 hover:bg-gray-50 transition">
<div class="relative">
<img src="{{ $conversation->other_profile->photos->first()?->thumbnail_url ?? '<https://ui-avatars.com/api/?name=>' . urlencode($conversation->other_profile->display_name) }}"
alt="{{ $conversation->other_profile->display_name }}"
class="w-14 h-14 rounded-full object-cover">
@if($conversation->unread_count > 0)
<span class="absolute -top-1 -right-1 w-5 h-5 bg-rose-500 text-white text-xs rounded-full flex items-center justify-center">
{{ $conversation->unread_count }}
</span>
@endif
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-800">{{ $conversation->other_profile->display_name }}</h3>
@if($conversation->last_message)
<p class="text-sm text-gray-500 truncate">
{{ $conversation->last_message->body }}
</p>
@else
<p class="text-sm text-gray-400 italic">Belum ada pesan</p>
@endif
</div>
@if($conversation->last_message)
<span class="text-xs text-gray-400">
{{ $conversation->last_message->created_at->shortRelativeDiffForHumans() }}
</span>
@endif
</a>
@empty
<div class="p-8 text-center">
<p class="text-4xl mb-3">💬</p>
<p class="text-gray-500">Belum ada percakapan</p>
<a href="{{ route('user.discover') }}" class="text-rose-500 hover:underline">
Mulai discover
</a>
</div>
@endforelse
</div>
</div>
</x-app-layout>
{{-- resources/views/user/chat/show.blade.php --}}
<x-app-layout>
<div class="flex flex-col h-screen max-w-2xl mx-auto">
{{-- Header --}}
<div class="bg-white border-b px-4 py-3 flex items-center gap-3">
<a href="{{ route('user.chat.index') }}" class="text-gray-500 hover:text-gray-700">
←
</a>
<img src="{{ $otherProfile->photos->first()?->thumbnail_url ?? '<https://ui-avatars.com/api/?name=>' . urlencode($otherProfile->display_name) }}"
alt="{{ $otherProfile->display_name }}"
class="w-10 h-10 rounded-full object-cover">
<div>
<h2 class="font-semibold text-gray-800">{{ $otherProfile->display_name }}</h2>
<p id="typing-indicator" class="text-xs text-gray-500 hidden">sedang mengetik...</p>
</div>
</div>
{{-- Messages --}}
<div id="messages-container" class="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
@foreach($messages as $message)
<div class="flex {{ $message->sender_id === auth()->user()->profile->id ? 'justify-end' : 'justify-start' }}">
<div class="max-w-[75%] px-4 py-2 rounded-2xl {{ $message->sender_id === auth()->user()->profile->id ? 'bg-rose-500 text-white' : 'bg-white text-gray-800' }}">
<p>{{ $message->body }}</p>
<p class="text-xs mt-1 {{ $message->sender_id === auth()->user()->profile->id ? 'text-rose-200' : 'text-gray-400' }}">
{{ $message->created_at->format('H:i') }}
</p>
</div>
</div>
@endforeach
</div>
{{-- Input --}}
<div class="bg-white border-t p-4">
<form id="message-form" class="flex gap-3">
<input type="text"
id="message-input"
placeholder="Tulis pesan..."
class="flex-1 px-4 py-2 border rounded-full focus:outline-none focus:border-rose-500"
autocomplete="off">
<button type="submit"
class="px-6 py-2 bg-rose-500 text-white rounded-full hover:bg-rose-600 transition">
Kirim
</button>
</form>
</div>
</div>
@push('scripts')
@vite(['resources/js/app.js'])
<script type="module">
const conversationId = {{ $conversation->id }};
const myProfileId = {{ auth()->user()->profile->id }};
const container = document.getElementById('messages-container');
const form = document.getElementById('message-form');
const input = document.getElementById('message-input');
const typingIndicator = document.getElementById('typing-indicator');
let typingTimeout;
container.scrollTop = container.scrollHeight;
window.Echo.private(`conversation.${conversationId}`)
.listen('MessageSent', (e) => {
appendMessage(e, false);
markAsRead();
})
.listen('UserTyping', (e) => {
if (e.profile_id !== myProfileId) {
if (e.is_typing) {
typingIndicator.classList.remove('hidden');
} else {
typingIndicator.classList.add('hidden');
}
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = input.value.trim();
if (!body) return;
input.value = '';
sendTypingStatus(false);
try {
const res = await fetch(`/user/chat/${conversationId}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ body })
});
const data = await res.json();
appendMessage(data.message, true);
} catch (err) {
console.error(err);
}
});
input.addEventListener('input', () => {
sendTypingStatus(true);
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => sendTypingStatus(false), 2000);
});
function appendMessage(message, isMine) {
const div = document.createElement('div');
div.className = `flex ${isMine ? 'justify-end' : 'justify-start'}`;
div.innerHTML = `
<div class="max-w-[75%] px-4 py-2 rounded-2xl ${isMine ? 'bg-rose-500 text-white' : 'bg-white text-gray-800'}">
<p>${message.body}</p>
<p class="text-xs mt-1 ${isMine ? 'text-rose-200' : 'text-gray-400'}">
${new Date(message.created_at).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
async function sendTypingStatus(isTyping) {
await fetch(`/user/chat/${conversationId}/typing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ is_typing: isTyping })
});
}
async function markAsRead() {
await fetch(`/user/chat/${conversationId}/read`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
}
markAsRead();
</script>
@endpush
</x-app-layout>
Menambahkan Route
// routes/web.php
Route::middleware(['auth', 'verified'])->prefix('user')->name('user.')->group(function () {
Route::get('/chat', [ChatController::class, 'index'])->name('chat.index');
Route::get('/chat/{conversation}', [ChatController::class, 'show'])->name('chat.show');
Route::post('/chat/{conversation}/send', [ChatController::class, 'sendMessage'])->name('chat.send');
Route::post('/chat/{conversation}/typing', [ChatController::class, 'typing'])->name('chat.typing');
Route::post('/chat/{conversation}/read', [ChatController::class, 'markRead'])->name('chat.read');
});
Setup Echo di Frontend
// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
Menjalankan Reverb
php artisan reverb:start
Ringkasan Fitur Chat
Saya sudah membuat sistem chat real-time dengan fitur:
- List conversations dengan unread count
- Real-time messaging dengan WebSocket
- Typing indicator
- Read receipt (mark as read)
- Auto scroll ke pesan terbaru
- UI chat bubble seperti WhatsApp
Di bagian selanjutnya, saya akan membuat fitur authentication dengan verifikasi email dan nomor telepon.
Bagian 9: Menambahkan Fitur Authentication dengan Verifikasi Email dan Telepon
Sekarang saya akan membuat sistem authentication yang aman untuk platform dating. Verifikasi email dan nomor telepon sangat penting untuk memastikan keaslian pengguna dan mengurangi fake account.
Menginstall Laravel Breeze
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run build
Menambahkan Kolom Phone di Tabel Users
php artisan make:migration add_phone_to_users_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone')->nullable()->after('email');
$table->timestamp('phone_verified_at')->nullable()->after('email_verified_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['phone', 'phone_verified_at']);
});
}
};
php artisan migrate
Membuat Migration untuk OTP
php artisan make:migration create_otp_codes_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('otp_codes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('code', 6);
$table->enum('type', ['email', 'phone']);
$table->timestamp('expires_at');
$table->timestamp('verified_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('otp_codes');
}
};
Membuat Model OtpCode
php artisan make:model OtpCode
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class OtpCode extends Model
{
protected $fillable = ['user_id', 'code', 'type', 'expires_at', 'verified_at'];
protected $casts = [
'expires_at' => 'datetime',
'verified_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isVerified(): bool
{
return $this->verified_at !== null;
}
}
Update Model User
<?php
namespace App\\Models;
use Illuminate\\Contracts\\Auth\\MustVerifyEmail;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
class User extends Authenticatable implements MustVerifyEmail
{
use Notifiable;
protected $fillable = ['name', 'email', 'phone', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'phone_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
public function otpCodes(): HasMany
{
return $this->hasMany(OtpCode::class);
}
public function isPhoneVerified(): bool
{
return $this->phone_verified_at !== null;
}
public function isFullyVerified(): bool
{
return $this->hasVerifiedEmail() && $this->isPhoneVerified();
}
}
Membuat OtpService
<?php
namespace App\\Services;
use App\\Models\\OtpCode;
use App\\Models\\User;
use App\\Notifications\\OtpNotification;
use Exception;
class OtpService
{
public function generate(User $user, string $type): OtpCode
{
$user->otpCodes()->where('type', $type)->whereNull('verified_at')->delete();
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
return OtpCode::create([
'user_id' => $user->id,
'code' => $code,
'type' => $type,
'expires_at' => now()->addMinutes(10),
]);
}
public function sendEmailOtp(User $user): void
{
$otp = $this->generate($user, 'email');
$user->notify(new OtpNotification($otp->code, 'email'));
}
public function sendPhoneOtp(User $user): void
{
if (!$user->phone) {
throw new Exception('Nomor telepon belum diisi');
}
$otp = $this->generate($user, 'phone');
// Integrasi dengan SMS gateway seperti Twilio atau lokal seperti Zenziva
$this->sendSms($user->phone, "Kode OTP Jodoh App: {$otp->code}. Berlaku 10 menit.");
}
public function verify(User $user, string $code, string $type): bool
{
$otp = $user->otpCodes()
->where('type', $type)
->where('code', $code)
->whereNull('verified_at')
->where('expires_at', '>', now())
->first();
if (!$otp) {
return false;
}
$otp->update(['verified_at' => now()]);
if ($type === 'email') {
$user->markEmailAsVerified();
} else {
$user->update(['phone_verified_at' => now()]);
}
return true;
}
protected function sendSms(string $phone, string $message): void
{
// Contoh integrasi dengan Zenziva
// $client = new \\GuzzleHttp\\Client();
// $client->post('<https://console.zenziva.net/wareguler/api/sendWA/>', [
// 'form_params' => [
// 'userkey' => config('services.zenziva.userkey'),
// 'passkey' => config('services.zenziva.passkey'),
// 'to' => $phone,
// 'message' => $message,
// ]
// ]);
// Untuk development, log saja
logger()->info("SMS to {$phone}: {$message}");
}
}
Membuat OTP Notification
php artisan make:notification OtpNotification
<?php
namespace App\\Notifications;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;
class OtpNotification extends Notification
{
use Queueable;
public function __construct(
protected string $code,
protected string $type
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Kode Verifikasi - Jodoh App')
->greeting('Halo!')
->line('Kode verifikasi Anda adalah:')
->line("**{$this->code}**")
->line('Kode ini berlaku selama 10 menit.')
->line('Jika Anda tidak meminta kode ini, abaikan email ini.')
->salutation('Salam, Tim Jodoh App');
}
}
Membuat VerificationController
php artisan make:controller Auth/VerificationController
<?php
namespace App\\Http\\Controllers\\Auth;
use App\\Http\\Controllers\\Controller;
use App\\Services\\OtpService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\View\\View;
class VerificationController extends Controller
{
public function __construct(
protected OtpService $otpService
) {}
public function showEmailVerification(Request $request): View|RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->route('verification.phone');
}
return view('auth.verify-email');
}
public function sendEmailOtp(Request $request): RedirectResponse
{
$this->otpService->sendEmailOtp($request->user());
return back()->with('status', 'Kode OTP telah dikirim ke email Anda');
}
public function verifyEmail(Request $request): RedirectResponse
{
$request->validate([
'code' => 'required|string|size:6',
]);
$verified = $this->otpService->verify($request->user(), $request->code, 'email');
if (!$verified) {
return back()->withErrors(['code' => 'Kode OTP tidak valid atau sudah expired']);
}
return redirect()->route('verification.phone')->with('status', 'Email berhasil diverifikasi!');
}
public function showPhoneVerification(Request $request): View|RedirectResponse
{
if (!$request->user()->hasVerifiedEmail()) {
return redirect()->route('verification.email');
}
if ($request->user()->isPhoneVerified()) {
return redirect()->route('user.profile.setup');
}
return view('auth.verify-phone');
}
public function updatePhone(Request $request): RedirectResponse
{
$request->validate([
'phone' => 'required|string|regex:/^(\\+62|62|0)8[1-9][0-9]{6,10}$/',
]);
$phone = $this->normalizePhone($request->phone);
$request->user()->update(['phone' => $phone]);
$this->otpService->sendPhoneOtp($request->user());
return back()->with('status', 'Kode OTP telah dikirim ke WhatsApp Anda');
}
public function verifyPhone(Request $request): RedirectResponse
{
$request->validate([
'code' => 'required|string|size:6',
]);
$verified = $this->otpService->verify($request->user(), $request->code, 'phone');
if (!$verified) {
return back()->withErrors(['code' => 'Kode OTP tidak valid atau sudah expired']);
}
return redirect()->route('user.profile.setup')->with('status', 'Nomor telepon berhasil diverifikasi!');
}
protected function normalizePhone(string $phone): string
{
$phone = preg_replace('/[^0-9]/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '62' . substr($phone, 1);
}
if (!str_starts_with($phone, '62')) {
$phone = '62' . $phone;
}
return $phone;
}
}
Membuat Middleware untuk Verifikasi
php artisan make:middleware EnsureFullyVerified
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class EnsureFullyVerified
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user->hasVerifiedEmail()) {
return redirect()->route('verification.email');
}
if (!$user->isPhoneVerified()) {
return redirect()->route('verification.phone');
}
return $next($request);
}
}
Daftarkan middleware di bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'verified.full' => \\App\\Http\\Middleware\\EnsureFullyVerified::class,
]);
})
Membuat View Verifikasi Email
{{-- resources/views/auth/verify-email.blade.php --}}
<x-guest-layout>
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Verifikasi Email</h1>
<p class="text-gray-600 mt-2">Masukkan kode OTP yang dikirim ke {{ auth()->user()->email }}</p>
</div>
@if (session('status'))
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg">
{{ session('status') }}
</div>
@endif
<form method="POST" action="{{ route('verification.email.verify') }}" class="space-y-4">
@csrf
<div>
<x-input-label for="code" value="Kode OTP" />
<x-text-input id="code" name="code" type="text" maxlength="6"
class="mt-1 block w-full text-center text-2xl tracking-widest"
placeholder="000000" required autofocus />
<x-input-error :messages="$errors->get('code')" class="mt-2" />
</div>
<x-primary-button class="w-full justify-center">
Verifikasi
</x-primary-button>
</form>
<div class="mt-6 text-center">
<p class="text-gray-600">Tidak menerima kode?</p>
<form method="POST" action="{{ route('verification.email.send') }}" class="mt-2">
@csrf
<button type="submit" class="text-rose-500 hover:underline">
Kirim ulang kode
</button>
</form>
</div>
</x-guest-layout>
Membuat View Verifikasi Telepon
{{-- resources/views/auth/verify-phone.blade.php --}}
<x-guest-layout>
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Verifikasi Nomor Telepon</h1>
<p class="text-gray-600 mt-2">Kami akan mengirim kode OTP via WhatsApp</p>
</div>
@if (session('status'))
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg">
{{ session('status') }}
</div>
@endif
@if (!auth()->user()->phone)
<form method="POST" action="{{ route('verification.phone.update') }}" class="space-y-4">
@csrf
<div>
<x-input-label for="phone" value="Nomor WhatsApp" />
<x-text-input id="phone" name="phone" type="tel"
class="mt-1 block w-full"
placeholder="08123456789" required />
<x-input-error :messages="$errors->get('phone')" class="mt-2" />
</div>
<x-primary-button class="w-full justify-center">
Kirim Kode OTP
</x-primary-button>
</form>
@else
<div class="mb-4 p-4 bg-gray-100 rounded-lg text-center">
<p class="text-gray-600">Kode dikirim ke</p>
<p class="font-semibold text-gray-800">{{ auth()->user()->phone }}</p>
</div>
<form method="POST" action="{{ route('verification.phone.verify') }}" class="space-y-4">
@csrf
<div>
<x-input-label for="code" value="Kode OTP" />
<x-text-input id="code" name="code" type="text" maxlength="6"
class="mt-1 block w-full text-center text-2xl tracking-widest"
placeholder="000000" required autofocus />
<x-input-error :messages="$errors->get('code')" class="mt-2" />
</div>
<x-primary-button class="w-full justify-center">
Verifikasi
</x-primary-button>
</form>
<div class="mt-6 text-center">
<form method="POST" action="{{ route('verification.phone.update') }}" class="inline">
@csrf
<input type="hidden" name="phone" value="{{ auth()->user()->phone }}">
<button type="submit" class="text-rose-500 hover:underline">
Kirim ulang kode
</button>
</form>
<span class="text-gray-400 mx-2">|</span>
<button onclick="document.getElementById('change-phone').classList.toggle('hidden')"
class="text-gray-500 hover:underline">
Ubah nomor
</button>
</div>
<form id="change-phone" method="POST" action="{{ route('verification.phone.update') }}" class="mt-4 hidden">
@csrf
<div class="flex gap-2">
<x-text-input name="phone" type="tel" class="flex-1" placeholder="Nomor baru" required />
<x-primary-button>Kirim</x-primary-button>
</div>
</form>
@endif
</x-guest-layout>
Menambahkan Route Verifikasi
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/verify-email', [VerificationController::class, 'showEmailVerification'])
->name('verification.email');
Route::post('/verify-email/send', [VerificationController::class, 'sendEmailOtp'])
->name('verification.email.send');
Route::post('/verify-email', [VerificationController::class, 'verifyEmail'])
->name('verification.email.verify');
Route::get('/verify-phone', [VerificationController::class, 'showPhoneVerification'])
->name('verification.phone');
Route::post('/verify-phone/update', [VerificationController::class, 'updatePhone'])
->name('verification.phone.update');
Route::post('/verify-phone', [VerificationController::class, 'verifyPhone'])
->name('verification.phone.verify');
});
// Gunakan middleware verified.full untuk route yang butuh verifikasi lengkap
Route::middleware(['auth', 'verified.full'])->prefix('user')->name('user.')->group(function () {
Route::get('/discover', [DiscoveryController::class, 'index'])->name('discover');
// ... route lainnya
});
Update RegisteredUserController
// app/Http/Controllers/Auth/RegisteredUserController.php
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
// Kirim OTP email
app(OtpService::class)->sendEmailOtp($user);
return redirect()->route('verification.email');
}
Ringkasan Fitur Authentication
Saya sudah membuat sistem authentication lengkap dengan fitur:
- Registrasi dengan email
- Verifikasi email menggunakan OTP 6 digit
- Verifikasi nomor telepon/WhatsApp dengan OTP
- Normalisasi format nomor telepon Indonesia
- OTP expired setelah 10 menit
- Middleware untuk memastikan user fully verified
- Flow: Register → Verify Email → Verify Phone → Setup Profile
Di bagian selanjutnya, saya akan membuat sistem role dan permission menggunakan Spatie.
Bagian 10: Mengatur Role dan Permission dengan Spatie Laravel Permission
Sekarang saya akan mengimplementasikan sistem role dan permission agar admin bisa memberikan akses berbeda kepada tim seperti moderator, customer support, dan content manager. Ini penting untuk platform dating karena butuh banyak orang untuk mengelola konten dan menjaga keamanan.
Menginstall Spatie Laravel Permission
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"
php artisan migrate
Update Model User
<?php
namespace App\\Models;
use Illuminate\\Contracts\\Auth\\MustVerifyEmail;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Spatie\\Permission\\Traits\\HasRoles;
class User extends Authenticatable implements MustVerifyEmail
{
use Notifiable, HasRoles;
protected $fillable = ['name', 'email', 'phone', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'phone_verified_at' => 'datetime',
'password' => 'hashed',
];
}
// ... relationship dan method lainnya
}
Membuat Seeder untuk Role dan Permission
php artisan make:seeder RolePermissionSeeder
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Permission;
use Spatie\\Permission\\Models\\Role;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[\\Spatie\\Permission\\PermissionCache::class]->forgetCachedPermissions();
// Permissions untuk Profiles
Permission::create(['name' => 'view_profiles']);
Permission::create(['name' => 'edit_profiles']);
Permission::create(['name' => 'delete_profiles']);
Permission::create(['name' => 'verify_profiles']);
Permission::create(['name' => 'suspend_profiles']);
// Permissions untuk Photos
Permission::create(['name' => 'view_photos']);
Permission::create(['name' => 'approve_photos']);
Permission::create(['name' => 'reject_photos']);
// Permissions untuk Reports
Permission::create(['name' => 'view_reports']);
Permission::create(['name' => 'review_reports']);
Permission::create(['name' => 'resolve_reports']);
// Permissions untuk Matches
Permission::create(['name' => 'view_matches']);
Permission::create(['name' => 'unmatch_users']);
// Permissions untuk Conversations
Permission::create(['name' => 'view_conversations']);
Permission::create(['name' => 'delete_messages']);
// Permissions untuk Subscriptions
Permission::create(['name' => 'view_subscriptions']);
Permission::create(['name' => 'create_subscriptions']);
Permission::create(['name' => 'edit_subscriptions']);
Permission::create(['name' => 'cancel_subscriptions']);
Permission::create(['name' => 'refund_subscriptions']);
// Permissions untuk Subscription Plans
Permission::create(['name' => 'view_plans']);
Permission::create(['name' => 'create_plans']);
Permission::create(['name' => 'edit_plans']);
Permission::create(['name' => 'delete_plans']);
// Permissions untuk Interests
Permission::create(['name' => 'view_interests']);
Permission::create(['name' => 'create_interests']);
Permission::create(['name' => 'edit_interests']);
Permission::create(['name' => 'delete_interests']);
// Permissions untuk Users & Roles
Permission::create(['name' => 'view_users']);
Permission::create(['name' => 'create_users']);
Permission::create(['name' => 'edit_users']);
Permission::create(['name' => 'delete_users']);
Permission::create(['name' => 'manage_roles']);
// Permissions untuk Analytics
Permission::create(['name' => 'view_analytics']);
Permission::create(['name' => 'export_reports']);
// Permissions untuk Settings
Permission::create(['name' => 'manage_settings']);
// Create Roles
$admin = Role::create(['name' => 'admin']);
$moderator = Role::create(['name' => 'moderator']);
$customerSupport = Role::create(['name' => 'customer_support']);
$contentManager = Role::create(['name' => 'content_manager']);
// Admin - Full Access
$admin->givePermissionTo(Permission::all());
// Moderator - Fokus moderasi konten dan user
$moderator->givePermissionTo([
'view_profiles',
'verify_profiles',
'suspend_profiles',
'view_photos',
'approve_photos',
'reject_photos',
'view_reports',
'review_reports',
'resolve_reports',
'view_matches',
'unmatch_users',
'view_conversations',
'delete_messages',
]);
// Customer Support - Handle keluhan dan subscription
$customerSupport->givePermissionTo([
'view_profiles',
'view_reports',
'review_reports',
'view_subscriptions',
'cancel_subscriptions',
'refund_subscriptions',
'view_conversations',
]);
// Content Manager - Kelola konten dan interest tags
$contentManager->givePermissionTo([
'view_profiles',
'view_interests',
'create_interests',
'edit_interests',
'delete_interests',
'view_plans',
'create_plans',
'edit_plans',
'view_analytics',
]);
}
}
Membuat Seeder untuk Admin Users
php artisan make:seeder AdminUserSeeder
<?php
namespace Database\\Seeders;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Facades\\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$admin = User::create([
'name' => 'Super Admin',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
'phone_verified_at' => now(),
]);
$admin->assignRole('admin');
$moderator = User::create([
'name' => 'Moderator',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
'phone_verified_at' => now(),
]);
$moderator->assignRole('moderator');
$support = User::create([
'name' => 'Customer Support',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
'phone_verified_at' => now(),
]);
$support->assignRole('customer_support');
$content = User::create([
'name' => 'Content Manager',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
'phone_verified_at' => now(),
]);
$content->assignRole('content_manager');
}
}
Jalankan seeder:
php artisan db:seed --class=RolePermissionSeeder
php artisan db:seed --class=AdminUserSeeder
Membuat Middleware untuk Admin Panel
php artisan make:middleware CheckAdminAccess
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class CheckAdminAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || !$user->hasAnyRole(['admin', 'moderator', 'customer_support', 'content_manager'])) {
abort(403, 'Akses ditolak');
}
return $next($request);
}
}
Update Filament Panel Provider
// app/Providers/Filament/AdminPanelProvider.php
use App\\Http\\Middleware\\CheckAdminAccess;
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('Jodoh App Admin')
->colors([
'primary' => Color::Rose,
])
->authMiddleware([
Authenticate::class,
CheckAdminAccess::class,
])
// ... konfigurasi lainnya
}
Update ProfileResource dengan Permission
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ProfileResource\\Pages;
use App\\Models\\Profile;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ProfileResource extends Resource
{
protected static ?string $model = Profile::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'User Management';
public static function canViewAny(): bool
{
return auth()->user()->can('view_profiles');
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can('edit_profiles');
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can('delete_profiles');
}
public static function table(Table $table): Table
{
return $table
->columns([
// ... kolom
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make()
->visible(fn () => auth()->user()->can('edit_profiles')),
Tables\\Actions\\Action::make('verify')
->label('Verifikasi')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (Profile $record) =>
!$record->is_verified && auth()->user()->can('verify_profiles'))
->action(fn (Profile $record) => $record->update(['is_verified' => true]))
->requiresConfirmation(),
Tables\\Actions\\Action::make('suspend')
->label('Suspend')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn (Profile $record) =>
$record->is_active && auth()->user()->can('suspend_profiles'))
->action(fn (Profile $record) => $record->update(['is_active' => false]))
->requiresConfirmation(),
]);
}
}
Update ReportResource dengan Permission
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ReportResource\\Pages;
use App\\Models\\Report;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-flag';
protected static ?string $navigationGroup = 'Moderation';
public static function canViewAny(): bool
{
return auth()->user()->can('view_reports');
}
public static function table(Table $table): Table
{
return $table
->columns([
// ... kolom
])
->actions([
Tables\\Actions\\Action::make('review')
->visible(fn () => auth()->user()->can('review_reports')),
Tables\\Actions\\Action::make('resolve')
->visible(fn () => auth()->user()->can('resolve_reports')),
Tables\\Actions\\Action::make('suspendUser')
->visible(fn () => auth()->user()->can('suspend_profiles')),
]);
}
}
Update SubscriptionPlanResource dengan Permission
<?php
namespace App\\Filament\\Resources;
use App\\Models\\SubscriptionPlan;
use Filament\\Resources\\Resource;
class SubscriptionPlanResource extends Resource
{
protected static ?string $model = SubscriptionPlan::class;
protected static ?string $navigationGroup = 'Subscriptions';
public static function canViewAny(): bool
{
return auth()->user()->can('view_plans');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_plans');
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can('edit_plans');
}
public static function canDelete(Model $record): bool
{
return auth()->user()->can('delete_plans');
}
}
Membuat UserResource untuk Manage Admin Users
php artisan make:filament-resource User --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\UserResource\\Pages;
use App\\Models\\User;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Facades\\Hash;
use Spatie\\Permission\\Models\\Role;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = 'Admin Users';
protected static ?string $navigationGroup = 'Settings';
public static function canViewAny(): bool
{
return auth()->user()->can('view_users');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_users');
}
public static function canEdit(Model $record): bool
{
return auth()->user()->can('edit_users');
}
public static function canDelete(Model $record): bool
{
if ($record->id === auth()->id()) {
return false;
}
return auth()->user()->can('delete_users');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi User')
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->email()
->required()
->unique(ignoreRecord: true),
Forms\\Components\\TextInput::make('password')
->password()
->dehydrateStateUsing(fn ($state) =>
filled($state) ? Hash::make($state) : null)
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context) => $context === 'create'),
])->columns(2),
Forms\\Components\\Section::make('Roles')
->schema([
Forms\\Components\\CheckboxList::make('roles')
->relationship('roles', 'name')
->options(
Role::whereIn('name', ['admin', 'moderator', 'customer_support', 'content_manager'])
->pluck('name', 'id')
)
->columns(2),
])
->visible(fn () => auth()->user()->can('manage_roles')),
]);
}
public static function table(Table $table): Table
{
return $table
->query(
User::query()->whereHas('roles', fn ($q) =>
$q->whereIn('name', ['admin', 'moderator', 'customer_support', 'content_manager']))
)
->columns([
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('email')
->searchable(),
Tables\\Columns\\TextColumn::make('roles.name')
->badge()
->color(fn (string $state): string => match ($state) {
'admin' => 'danger',
'moderator' => 'warning',
'customer_support' => 'info',
'content_manager' => 'success',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('created_at')
->dateTime('d M Y')
->sortable(),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->visible(fn (User $record) => $record->id !== auth()->id()),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListUsers::route('/'),
'create' => Pages\\CreateUser::route('/create'),
'edit' => Pages\\EditUser::route('/{record}/edit'),
];
}
}
Membuat RoleResource
php artisan make:filament-resource Role --generate
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\RoleResource\\Pages;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Spatie\\Permission\\Models\\Permission;
use Spatie\\Permission\\Models\\Role;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationGroup = 'Settings';
public static function canViewAny(): bool
{
return auth()->user()->can('manage_roles');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->unique(ignoreRecord: true),
Forms\\Components\\Section::make('Permissions')
->schema([
Forms\\Components\\CheckboxList::make('permissions')
->relationship('permissions', 'name')
->options(Permission::pluck('name', 'id'))
->columns(3)
->searchable(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->badge()
->searchable(),
Tables\\Columns\\TextColumn::make('permissions_count')
->counts('permissions')
->label('Permissions'),
Tables\\Columns\\TextColumn::make('users_count')
->counts('users')
->label('Users'),
])
->actions([
Tables\\Actions\\EditAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListRoles::route('/'),
'create' => Pages\\CreateRole::route('/create'),
'edit' => Pages\\EditRole::route('/{record}/edit'),
];
}
}
Menambahkan Navigation Badge Based on Permission
// app/Filament/Resources/ReportResource.php
public static function getNavigationBadge(): ?string
{
if (!auth()->user()->can('view_reports')) {
return null;
}
return static::getModel()::where('status', 'pending')->count() ?: null;
}
// app/Filament/Resources/ProfilePhotoResource.php
public static function getNavigationBadge(): ?string
{
if (!auth()->user()->can('view_photos')) {
return null;
}
return static::getModel()::where('is_approved', false)->count() ?: null;
}
Ringkasan Role dan Permission
| Role | Akses |
|---|---|
| Admin | Full access ke semua fitur |
| Moderator | Verifikasi profil, approve foto, review laporan, unmatch, delete messages |
| Customer Support | View profil & laporan, kelola subscription (cancel/refund) |
| Content Manager | Kelola interests, subscription plans, view analytics |
Saya sudah membuat sistem role dan permission yang fleksibel. Admin bisa menambah role baru dan mengatur permission sesuai kebutuhan melalui panel admin.
Di bagian selanjutnya, saya akan membuat sistem subscription dan paket premium dengan recurring payment.
Bagian 11: Menambahkan Fitur Subscription dan Paket Premium
Sekarang saya akan membuat sistem subscription untuk monetisasi platform dating. User bisa upgrade ke paket premium untuk mendapatkan fitur eksklusif seperti unlimited likes, melihat siapa yang like mereka, dan boost profile.
Membuat Seeder untuk Subscription Plans
php artisan make:seeder SubscriptionPlanSeeder
<?php
namespace Database\\Seeders;
use App\\Models\\SubscriptionPlan;
use Illuminate\\Database\\Seeder;
class SubscriptionPlanSeeder extends Seeder
{
public function run(): void
{
SubscriptionPlan::create([
'name' => 'Free',
'slug' => 'free',
'description' => 'Paket gratis dengan fitur terbatas',
'price' => 0,
'duration_days' => 0,
'daily_likes_limit' => 20,
'daily_super_likes' => 1,
'can_see_who_likes' => false,
'can_boost_profile' => false,
'unlimited_likes' => false,
'no_ads' => false,
'is_active' => true,
]);
SubscriptionPlan::create([
'name' => 'Basic',
'slug' => 'basic',
'description' => 'Lebih banyak likes dan tanpa iklan',
'price' => 49000,
'duration_days' => 30,
'daily_likes_limit' => 50,
'daily_super_likes' => 3,
'can_see_who_likes' => false,
'can_boost_profile' => false,
'unlimited_likes' => false,
'no_ads' => true,
'is_active' => true,
]);
SubscriptionPlan::create([
'name' => 'Gold',
'slug' => 'gold',
'description' => 'Unlimited likes dan lihat siapa yang suka kamu',
'price' => 99000,
'duration_days' => 30,
'daily_likes_limit' => null,
'daily_super_likes' => 5,
'can_see_who_likes' => true,
'can_boost_profile' => false,
'unlimited_likes' => true,
'no_ads' => true,
'is_active' => true,
]);
SubscriptionPlan::create([
'name' => 'Platinum',
'slug' => 'platinum',
'description' => 'Semua fitur premium termasuk boost profile',
'price' => 149000,
'duration_days' => 30,
'daily_likes_limit' => null,
'daily_super_likes' => 10,
'can_see_who_likes' => true,
'can_boost_profile' => true,
'unlimited_likes' => true,
'no_ads' => true,
'is_active' => true,
]);
}
}
php artisan db:seed --class=SubscriptionPlanSeeder
Membuat SubscriptionService
<?php
namespace App\\Services;
use App\\Models\\Subscription;
use App\\Models\\SubscriptionPlan;
use App\\Models\\User;
use Exception;
class SubscriptionService
{
public function getCurrentPlan(User $user): SubscriptionPlan
{
$subscription = $user->activeSubscription();
if ($subscription) {
return $subscription->plan;
}
return SubscriptionPlan::where('slug', 'free')->first();
}
public function subscribe(User $user, SubscriptionPlan $plan, string $paymentMethod, string $transactionId): Subscription
{
$existingSubscription = $user->activeSubscription();
if ($existingSubscription) {
$existingSubscription->update(['status' => 'cancelled']);
}
return Subscription::create([
'user_id' => $user->id,
'subscription_plan_id' => $plan->id,
'starts_at' => now(),
'ends_at' => now()->addDays($plan->duration_days),
'status' => 'active',
'payment_method' => $paymentMethod,
'transaction_id' => $transactionId,
]);
}
public function cancel(Subscription $subscription, string $reason = null): void
{
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
}
public function canUseFeatue(User $user, string $feature): bool
{
$plan = $this->getCurrentPlan($user);
return match ($feature) {
'unlimited_likes' => $plan->unlimited_likes,
'see_who_likes' => $plan->can_see_who_likes,
'boost_profile' => $plan->can_boost_profile,
'no_ads' => $plan->no_ads,
default => false,
};
}
public function getDailyLikesRemaining(User $user): int|null
{
$plan = $this->getCurrentPlan($user);
if ($plan->unlimited_likes) {
return null;
}
$usedToday = $user->profile->sentSwipes()
->whereIn('action', ['like', 'super_like'])
->whereDate('created_at', today())
->count();
return max(0, $plan->daily_likes_limit - $usedToday);
}
public function getDailySuperLikesRemaining(User $user): int
{
$plan = $this->getCurrentPlan($user);
$usedToday = $user->profile->sentSwipes()
->where('action', 'super_like')
->whereDate('created_at', today())
->count();
return max(0, $plan->daily_super_likes - $usedToday);
}
}
Membuat Controller untuk Subscription
php artisan make:controller User/SubscriptionController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\SubscriptionPlan;
use App\\Services\\MidtransService;
use App\\Services\\SubscriptionService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class SubscriptionController extends Controller
{
public function __construct(
protected SubscriptionService $subscriptionService,
protected MidtransService $midtransService
) {}
public function index(Request $request): View
{
$user = $request->user();
$currentPlan = $this->subscriptionService->getCurrentPlan($user);
$plans = SubscriptionPlan::where('is_active', true)
->where('price', '>', 0)
->orderBy('price')
->get();
$activeSubscription = $user->activeSubscription();
return view('user.subscription.index', [
'currentPlan' => $currentPlan,
'plans' => $plans,
'activeSubscription' => $activeSubscription,
'likesRemaining' => $this->subscriptionService->getDailyLikesRemaining($user),
'superLikesRemaining' => $this->subscriptionService->getDailySuperLikesRemaining($user),
]);
}
public function checkout(Request $request, SubscriptionPlan $plan): View|JsonResponse
{
if ($plan->price <= 0) {
return redirect()->route('user.subscription.index')
->with('error', 'Paket ini tidak bisa dibeli');
}
$user = $request->user();
$snapToken = $this->midtransService->createSubscriptionToken($user, $plan);
if ($request->wantsJson()) {
return response()->json(['snap_token' => $snapToken]);
}
return view('user.subscription.checkout', [
'plan' => $plan,
'snapToken' => $snapToken,
'clientKey' => config('midtrans.client_key'),
]);
}
public function history(Request $request): View
{
$subscriptions = $request->user()
->subscriptions()
->with('plan')
->latest()
->paginate(10);
return view('user.subscription.history', [
'subscriptions' => $subscriptions,
]);
}
public function cancel(Request $request): JsonResponse
{
$subscription = $request->user()->activeSubscription();
if (!$subscription) {
return response()->json(['message' => 'Tidak ada subscription aktif'], 404);
}
$this->subscriptionService->cancel($subscription, $request->reason);
return response()->json(['message' => 'Subscription berhasil dibatalkan']);
}
}
Update MidtransService untuk Subscription
// Tambahkan method ini di app/Services/MidtransService.php
public function createSubscriptionToken(User $user, SubscriptionPlan $plan): string
{
$orderId = 'SUB-' . $user->id . '-' . time();
$transactionDetails = [
'order_id' => $orderId,
'gross_amount' => (int) $plan->price,
];
$itemDetails = [
[
'id' => $plan->slug,
'price' => (int) $plan->price,
'quantity' => 1,
'name' => 'Subscription ' . $plan->name . ' (' . $plan->duration_days . ' hari)',
],
];
$customerDetails = [
'first_name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
];
$params = [
'transaction_details' => $transactionDetails,
'item_details' => $itemDetails,
'customer_details' => $customerDetails,
'callbacks' => [
'finish' => route('user.subscription.callback'),
],
'custom_field1' => $user->id,
'custom_field2' => $plan->id,
];
return Snap::getSnapToken($params);
}
public function handleSubscriptionNotification(): array
{
$notification = new Notification();
$transactionStatus = $notification->transaction_status;
$orderId = $notification->order_id;
$userId = $notification->custom_field1;
$planId = $notification->custom_field2;
$paymentType = $notification->payment_type;
$transactionId = $notification->transaction_id;
if (!str_starts_with($orderId, 'SUB-')) {
return ['success' => false, 'message' => 'Bukan transaksi subscription'];
}
$user = User::find($userId);
$plan = SubscriptionPlan::find($planId);
if (!$user || !$plan) {
return ['success' => false, 'message' => 'Data tidak ditemukan'];
}
if (in_array($transactionStatus, ['capture', 'settlement'])) {
$subscription = app(SubscriptionService::class)->subscribe(
$user,
$plan,
$paymentType,
$transactionId
);
return [
'success' => true,
'message' => 'Subscription berhasil diaktifkan',
'subscription' => $subscription,
];
}
return [
'success' => false,
'message' => 'Status transaksi: ' . $transactionStatus,
];
}
Membuat View untuk Subscription
{{-- resources/views/user/subscription/index.blade.php --}}
<x-app-layout>
<div class="max-w-4xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-2">Upgrade ke Premium</h1>
<p class="text-gray-600 mb-8">Dapatkan lebih banyak matches dengan fitur premium</p>
{{-- Current Status --}}
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Paket saat ini</p>
<p class="text-xl font-bold text-gray-800">{{ $currentPlan->name }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-500">Sisa hari ini</p>
<p class="text-lg">
❤️ {{ $likesRemaining ?? '∞' }} likes
<span class="mx-2">•</span>
⭐ {{ $superLikesRemaining }} super likes
</p>
</div>
</div>
@if($activeSubscription)
<div class="mt-4 pt-4 border-t">
<p class="text-sm text-gray-600">
Berakhir pada {{ $activeSubscription->ends_at->format('d M Y') }}
({{ $activeSubscription->ends_at->diffForHumans() }})
</p>
</div>
@endif
</div>
{{-- Plans --}}
<div class="grid md:grid-cols-3 gap-6">
@foreach($plans as $plan)
<div class="bg-white rounded-xl shadow-sm overflow-hidden {{ $plan->slug === 'gold' ? 'ring-2 ring-rose-500' : '' }}">
@if($plan->slug === 'gold')
<div class="bg-rose-500 text-white text-center py-1 text-sm font-medium">
Paling Populer
</div>
@endif
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800">{{ $plan->name }}</h3>
<p class="text-3xl font-bold text-gray-800 mt-2">
Rp {{ number_format($plan->price, 0, ',', '.') }}
<span class="text-sm font-normal text-gray-500">/bulan</span>
</p>
<p class="text-gray-600 text-sm mt-2">{{ $plan->description }}</p>
<ul class="mt-6 space-y-3">
<li class="flex items-center gap-2">
<span class="{{ $plan->unlimited_likes ? 'text-green-500' : 'text-gray-400' }}">
{{ $plan->unlimited_likes ? '✓' : '✗' }}
</span>
<span class="{{ $plan->unlimited_likes ? 'text-gray-800' : 'text-gray-400' }}">
{{ $plan->unlimited_likes ? 'Unlimited Likes' : $plan->daily_likes_limit . ' likes/hari' }}
</span>
</li>
<li class="flex items-center gap-2">
<span class="text-green-500">✓</span>
<span class="text-gray-800">{{ $plan->daily_super_likes }} super likes/hari</span>
</li>
<li class="flex items-center gap-2">
<span class="{{ $plan->can_see_who_likes ? 'text-green-500' : 'text-gray-400' }}">
{{ $plan->can_see_who_likes ? '✓' : '✗' }}
</span>
<span class="{{ $plan->can_see_who_likes ? 'text-gray-800' : 'text-gray-400' }}">
Lihat siapa yang like
</span>
</li>
<li class="flex items-center gap-2">
<span class="{{ $plan->can_boost_profile ? 'text-green-500' : 'text-gray-400' }}">
{{ $plan->can_boost_profile ? '✓' : '✗' }}
</span>
<span class="{{ $plan->can_boost_profile ? 'text-gray-800' : 'text-gray-400' }}">
Boost Profile
</span>
</li>
<li class="flex items-center gap-2">
<span class="{{ $plan->no_ads ? 'text-green-500' : 'text-gray-400' }}">
{{ $plan->no_ads ? '✓' : '✗' }}
</span>
<span class="{{ $plan->no_ads ? 'text-gray-800' : 'text-gray-400' }}">
Tanpa Iklan
</span>
</li>
</ul>
<a href="{{ route('user.subscription.checkout', $plan) }}"
class="mt-6 block w-full py-3 text-center rounded-lg font-semibold transition
{{ $plan->slug === 'gold'
? 'bg-rose-500 text-white hover:bg-rose-600'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200' }}">
Pilih {{ $plan->name }}
</a>
</div>
</div>
@endforeach
</div>
{{-- FAQ --}}
<div class="mt-12">
<h2 class="text-xl font-bold text-gray-800 mb-4">Pertanyaan Umum</h2>
<div class="bg-white rounded-xl shadow-sm divide-y">
<div class="p-4">
<h3 class="font-medium text-gray-800">Bagaimana cara membatalkan subscription?</h3>
<p class="text-gray-600 text-sm mt-1">Kamu bisa membatalkan kapan saja di halaman ini. Akses premium akan tetap aktif sampai periode berakhir.</p>
</div>
<div class="p-4">
<h3 class="font-medium text-gray-800">Metode pembayaran apa saja yang tersedia?</h3>
<p class="text-gray-600 text-sm mt-1">Kami menerima transfer bank, kartu kredit/debit, GoPay, ShopeePay, dan QRIS.</p>
</div>
<div class="p-4">
<h3 class="font-medium text-gray-800">Apakah ada jaminan uang kembali?</h3>
<p class="text-gray-600 text-sm mt-1">Ya, jika tidak puas dalam 7 hari pertama, hubungi support untuk refund.</p>
</div>
</div>
</div>
</div>
</x-app-layout>
Membuat View Checkout
{{-- resources/views/user/subscription/checkout.blade.php --}}
<x-app-layout>
<div class="max-w-lg mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Checkout</h1>
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="font-semibold text-gray-800 mb-4">Ringkasan Pembelian</h2>
<div class="flex justify-between py-3 border-b">
<span class="text-gray-600">Paket</span>
<span class="font-medium text-gray-800">{{ $plan->name }}</span>
</div>
<div class="flex justify-between py-3 border-b">
<span class="text-gray-600">Durasi</span>
<span class="font-medium text-gray-800">{{ $plan->duration_days }} hari</span>
</div>
<div class="flex justify-between py-3">
<span class="text-gray-600">Total</span>
<span class="text-xl font-bold text-rose-500">
Rp {{ number_format($plan->price, 0, ',', '.') }}
</span>
</div>
</div>
<button id="pay-button" class="w-full py-3 bg-rose-500 text-white font-semibold rounded-xl hover:bg-rose-600 transition">
Bayar Sekarang
</button>
<p class="text-center text-sm text-gray-500 mt-4">
Pembayaran diproses secara aman oleh Midtrans
</p>
</div>
@push('scripts')
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ $clientKey }}"></script>
<script>
document.getElementById('pay-button').addEventListener('click', function() {
snap.pay('{{ $snapToken }}', {
onSuccess: function(result) {
window.location.href = '{{ route('user.subscription.index') }}?status=success';
},
onPending: function(result) {
window.location.href = '{{ route('user.subscription.index') }}?status=pending';
},
onError: function(result) {
window.location.href = '{{ route('user.subscription.index') }}?status=error';
},
});
});
</script>
@endpush
</x-app-layout>
Menambahkan Route Subscription
// routes/web.php
Route::middleware(['auth', 'verified.full'])->prefix('user')->name('user.')->group(function () {
Route::get('/subscription', [SubscriptionController::class, 'index'])->name('subscription.index');
Route::get('/subscription/checkout/{plan}', [SubscriptionController::class, 'checkout'])->name('subscription.checkout');
Route::get('/subscription/history', [SubscriptionController::class, 'history'])->name('subscription.history');
Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel');
Route::get('/subscription/callback', [SubscriptionController::class, 'callback'])->name('subscription.callback');
});
Update Webhook Controller
// app/Http/Controllers/Webhook/MidtransController.php
public function handleNotification(Request $request): JsonResponse
{
$orderId = $request->order_id;
if (str_starts_with($orderId, 'SUB-')) {
$result = $this->midtransService->handleSubscriptionNotification();
} else {
$result = $this->midtransService->handleNotification();
}
return response()->json($result);
}
Ringkasan Fitur Subscription
| Paket | Harga | Fitur |
|---|---|---|
| Free | Rp 0 | 20 likes/hari, 1 super like, dengan iklan |
| Basic | Rp 49.000 | 50 likes/hari, 3 super likes, tanpa iklan |
| Gold | Rp 99.000 | Unlimited likes, 5 super likes, lihat siapa yang like |
| Platinum | Rp 149.000 | Semua fitur Gold + boost profile, 10 super likes |
Saya sudah membuat sistem subscription lengkap dengan:
- 4 tier paket dengan benefit berbeda
- Integrasi Midtrans untuk pembayaran
- Daily limit tracking untuk likes dan super likes
- Halaman checkout dengan Snap payment
- Riwayat subscription
Di bagian selanjutnya, saya akan membuat integrasi lengkap dengan Midtrans payment gateway.
Bagian 12: Integrasi Payment Gateway Midtrans
Sekarang saya akan membuat integrasi lengkap dengan Midtrans untuk memproses pembayaran subscription. Saya akan setup Snap payment, handle callback notification, dan kelola riwayat transaksi.
Menginstall Package Midtrans
composer require midtrans/midtrans-php
Konfigurasi Midtrans
Tambahkan konfigurasi di file .env:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
Buat file config/midtrans.php:
<?php
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
'is_3ds' => env('MIDTRANS_IS_3DS', true),
];
Membuat Migration untuk Payment Transactions
php artisan make:migration create_payment_transactions_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('subscription_id')->nullable()->constrained()->onDelete('set null');
$table->string('order_id')->unique();
$table->decimal('amount', 10, 2);
$table->string('payment_type')->nullable();
$table->string('transaction_id')->nullable();
$table->enum('status', ['pending', 'success', 'failed', 'expired', 'refunded'])->default('pending');
$table->string('snap_token')->nullable();
$table->json('midtrans_response')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_transactions');
}
};
Membuat Model PaymentTransaction
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class PaymentTransaction extends Model
{
protected $fillable = [
'user_id', 'subscription_id', 'order_id', 'amount',
'payment_type', 'transaction_id', 'status',
'snap_token', 'midtrans_response', 'paid_at',
];
protected $casts = [
'amount' => 'decimal:2',
'midtrans_response' => 'array',
'paid_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}
Membuat MidtransService
<?php
namespace App\\Services;
use App\\Models\\PaymentTransaction;
use App\\Models\\SubscriptionPlan;
use App\\Models\\User;
use Midtrans\\Config;
use Midtrans\\Notification;
use Midtrans\\Snap;
use Midtrans\\Transaction;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
public function createPayment(User $user, SubscriptionPlan $plan): PaymentTransaction
{
$orderId = 'SUB-' . $user->id . '-' . time();
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => (int) $plan->price,
],
'item_details' => [[
'id' => $plan->slug,
'price' => (int) $plan->price,
'quantity' => 1,
'name' => 'Subscription ' . $plan->name,
]],
'customer_details' => [
'first_name' => $user->name,
'email' => $user->email,
'phone' => $user->phone ?? '',
],
'callbacks' => [
'finish' => route('user.subscription.finish'),
],
'custom_field1' => (string) $user->id,
'custom_field2' => (string) $plan->id,
];
$snapToken = Snap::getSnapToken($params);
return PaymentTransaction::create([
'user_id' => $user->id,
'order_id' => $orderId,
'amount' => $plan->price,
'status' => 'pending',
'snap_token' => $snapToken,
]);
}
public function handleNotification(): array
{
$notification = new Notification();
$orderId = $notification->order_id;
$status = $notification->transaction_status;
$paymentType = $notification->payment_type;
$transaction = PaymentTransaction::where('order_id', $orderId)->first();
if (!$transaction) {
return ['success' => false, 'message' => 'Not found'];
}
$transaction->update([
'payment_type' => $paymentType,
'transaction_id' => $notification->transaction_id,
'midtrans_response' => (array) $notification,
]);
if (in_array($status, ['capture', 'settlement'])) {
$transaction->update(['status' => 'success', 'paid_at' => now()]);
$user = User::find($notification->custom_field1);
$plan = SubscriptionPlan::find($notification->custom_field2);
if ($user && $plan) {
$subscription = app(SubscriptionService::class)->subscribe($user, $plan, $paymentType, $notification->transaction_id);
$transaction->update(['subscription_id' => $subscription->id]);
}
} elseif ($status === 'pending') {
$transaction->update(['status' => 'pending']);
} elseif (in_array($status, ['deny', 'cancel', 'failure'])) {
$transaction->update(['status' => 'failed']);
} elseif ($status === 'expire') {
$transaction->update(['status' => 'expired']);
}
return ['success' => true];
}
public function refund(string $orderId, int $amount, string $reason): array
{
try {
$refund = Transaction::refund($orderId, [
'refund_key' => 'refund-' . $orderId . '-' . time(),
'amount' => $amount,
'reason' => $reason,
]);
PaymentTransaction::where('order_id', $orderId)->update(['status' => 'refunded']);
return ['success' => true, 'data' => $refund];
} catch (\\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
}
Membuat Webhook Controller
<?php
namespace App\\Http\\Controllers\\Webhook;
use App\\Http\\Controllers\\Controller;
use App\\Services\\MidtransService;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Support\\Facades\\Log;
class MidtransController extends Controller
{
public function handle(MidtransService $midtrans): JsonResponse
{
Log::info('Midtrans Webhook', request()->all());
$result = $midtrans->handleNotification();
return response()->json($result);
}
}
View Checkout
{{-- resources/views/user/subscription/checkout.blade.php --}}
<x-app-layout>
<div class="max-w-lg mx-auto py-8 px-4">
<div class="bg-white rounded-xl shadow-sm p-6">
<h1 class="text-xl font-bold mb-4">Checkout</h1>
<div class="border-b pb-4 mb-4">
<div class="flex justify-between">
<span>{{ $plan->name }}</span>
<span class="font-bold">Rp {{ number_format($plan->price, 0, ',', '.') }}</span>
</div>
</div>
<button id="pay-btn" class="w-full py-3 bg-rose-500 text-white rounded-xl font-semibold">
Bayar Sekarang
</button>
</div>
</div>
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ config('midtrans.client_key') }}"></script>
<script>
document.getElementById('pay-btn').onclick = function() {
snap.pay('{{ $transaction->snap_token }}', {
onSuccess: () => location.href = '{{ route("user.subscription.finish") }}?status=success',
onPending: () => location.href = '{{ route("user.subscription.finish") }}?status=pending',
onError: () => location.href = '{{ route("user.subscription.finish") }}?status=error',
});
};
</script>
</x-app-layout>
Route Webhook
// routes/api.php
Route::post('/webhook/midtrans', [MidtransController::class, 'handle']);
// bootstrap/app.php - exclude CSRF
$middleware->validateCsrfTokens(except: ['api/webhook/*']);
Ringkasan Integrasi Midtrans
Saya sudah membuat integrasi Midtrans dengan fitur:
- Snap payment popup untuk berbagai metode pembayaran
- Tabel payment_transactions untuk tracking
- Webhook handler untuk update status otomatis
- Auto-activate subscription setelah payment success
- Refund transaction dari admin panel
Di bagian selanjutnya, saya akan membuat sistem reporting dan blocking.
Bagian 13: Membuat Sistem Reporting dan Blocking
Sekarang saya akan membuat sistem keamanan untuk menjaga platform tetap aman. User bisa melaporkan profil yang mencurigakan dan memblock user yang tidak diinginkan. Admin dan moderator bisa mereview laporan dan mengambil tindakan.
Membuat Migration untuk Blocks
php artisan make:migration create_blocks_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('blocks', function (Blueprint $table) {
$table->id();
$table->foreignId('blocker_id')->constrained('profiles')->onDelete('cascade');
$table->foreignId('blocked_id')->constrained('profiles')->onDelete('cascade');
$table->string('reason')->nullable();
$table->timestamps();
$table->unique(['blocker_id', 'blocked_id']);
});
}
public function down(): void
{
Schema::dropIfExists('blocks');
}
};
php artisan migrate
Membuat Model Block
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Block extends Model
{
protected $fillable = ['blocker_id', 'blocked_id', 'reason'];
public function blocker(): BelongsTo
{
return $this->belongsTo(Profile::class, 'blocker_id');
}
public function blocked(): BelongsTo
{
return $this->belongsTo(Profile::class, 'blocked_id');
}
}
Update Model Profile untuk Block
// Tambahkan di app/Models/Profile.php
public function blockedProfiles(): BelongsToMany
{
return $this->belongsToMany(Profile::class, 'blocks', 'blocker_id', 'blocked_id')
->withTimestamps();
}
public function blockedByProfiles(): BelongsToMany
{
return $this->belongsToMany(Profile::class, 'blocks', 'blocked_id', 'blocker_id')
->withTimestamps();
}
public function hasBlocked(Profile $profile): bool
{
return $this->blockedProfiles()->where('blocked_id', $profile->id)->exists();
}
public function isBlockedBy(Profile $profile): bool
{
return $this->blockedByProfiles()->where('blocker_id', $profile->id)->exists();
}
public function canInteractWith(Profile $profile): bool
{
return !$this->hasBlocked($profile) && !$this->isBlockedBy($profile);
}
Membuat ReportService
<?php
namespace App\\Services;
use App\\Models\\Block;
use App\\Models\\Profile;
use App\\Models\\Report;
use App\\Events\\ProfileReported;
use Exception;
class ReportService
{
public function report(Profile $reporter, Profile $reported, string $reason, ?string $description = null): Report
{
if ($reporter->id === $reported->id) {
throw new Exception('Tidak bisa melaporkan diri sendiri');
}
$existingReport = Report::where('reporter_id', $reporter->id)
->where('reported_id', $reported->id)
->where('status', 'pending')
->first();
if ($existingReport) {
throw new Exception('Anda sudah melaporkan user ini');
}
$report = Report::create([
'reporter_id' => $reporter->id,
'reported_id' => $reported->id,
'reason' => $reason,
'description' => $description,
'status' => 'pending',
]);
event(new ProfileReported($report));
$this->checkAutoSuspend($reported);
return $report;
}
public function block(Profile $blocker, Profile $blocked, ?string $reason = null): Block
{
if ($blocker->id === $blocked->id) {
throw new Exception('Tidak bisa block diri sendiri');
}
if ($blocker->hasBlocked($blocked)) {
throw new Exception('User sudah diblock');
}
$this->removeMatch($blocker, $blocked);
return Block::create([
'blocker_id' => $blocker->id,
'blocked_id' => $blocked->id,
'reason' => $reason,
]);
}
public function unblock(Profile $blocker, Profile $blocked): bool
{
return Block::where('blocker_id', $blocker->id)
->where('blocked_id', $blocked->id)
->delete() > 0;
}
public function reportAndBlock(Profile $reporter, Profile $reported, string $reason, ?string $description = null): array
{
$report = $this->report($reporter, $reported, $reason, $description);
$block = $this->block($reporter, $reported, $reason);
return [
'report' => $report,
'block' => $block,
];
}
protected function removeMatch(Profile $profile1, Profile $profile2): void
{
$match = \\App\\Models\\Match::where('is_active', true)
->where(function ($q) use ($profile1, $profile2) {
$q->where(function ($q2) use ($profile1, $profile2) {
$q2->where('profile_one_id', $profile1->id)
->where('profile_two_id', $profile2->id);
})->orWhere(function ($q2) use ($profile1, $profile2) {
$q2->where('profile_one_id', $profile2->id)
->where('profile_two_id', $profile1->id);
});
})
->first();
if ($match) {
$match->update(['is_active' => false]);
$match->conversation?->delete();
}
}
protected function checkAutoSuspend(Profile $profile): void
{
$recentReportsCount = Report::where('reported_id', $profile->id)
->where('created_at', '>=', now()->subDays(30))
->count();
if ($recentReportsCount >= 5) {
$profile->update(['is_active' => false]);
Report::where('reported_id', $profile->id)
->where('status', 'pending')
->update([
'status' => 'resolved',
'admin_notes' => 'Auto-suspended: ' . $recentReportsCount . ' reports dalam 30 hari',
'reviewed_at' => now(),
]);
}
}
public function getReportReasons(): array
{
return [
'fake_profile' => 'Profil Palsu / Fake',
'inappropriate_content' => 'Konten Tidak Pantas',
'harassment' => 'Pelecehan / Harassment',
'spam' => 'Spam / Promosi',
'scam' => 'Penipuan / Scam',
'underage' => 'Di Bawah Umur',
'other' => 'Lainnya',
];
}
}
Membuat Event ProfileReported
php artisan make:event ProfileReported
<?php
namespace App\\Events;
use App\\Models\\Report;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class ProfileReported
{
use Dispatchable, SerializesModels;
public function __construct(
public Report $report
) {}
}
Membuat Controller untuk Report dan Block
php artisan make:controller User/ReportController
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Profile;
use App\\Services\\ReportService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class ReportController extends Controller
{
public function __construct(
protected ReportService $reportService
) {}
public function create(Profile $profile): View
{
return view('user.report.create', [
'profile' => $profile,
'reasons' => $this->reportService->getReportReasons(),
]);
}
public function store(Request $request, Profile $profile): JsonResponse
{
$request->validate([
'reason' => 'required|string|in:' . implode(',', array_keys($this->reportService->getReportReasons())),
'description' => 'nullable|string|max:500',
'block' => 'boolean',
]);
$myProfile = $request->user()->profile;
try {
if ($request->boolean('block')) {
$result = $this->reportService->reportAndBlock(
$myProfile,
$profile,
$request->reason,
$request->description
);
$message = 'User berhasil dilaporkan dan diblock';
} else {
$this->reportService->report(
$myProfile,
$profile,
$request->reason,
$request->description
);
$message = 'User berhasil dilaporkan';
}
return response()->json(['message' => $message]);
} catch (\\Exception $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function block(Request $request, Profile $profile): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:255',
]);
$myProfile = $request->user()->profile;
try {
$this->reportService->block($myProfile, $profile, $request->reason);
return response()->json(['message' => 'User berhasil diblock']);
} catch (\\Exception $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function unblock(Request $request, Profile $profile): JsonResponse
{
$myProfile = $request->user()->profile;
$this->reportService->unblock($myProfile, $profile);
return response()->json(['message' => 'User berhasil di-unblock']);
}
public function blockedList(Request $request): View
{
$blockedProfiles = $request->user()->profile
->blockedProfiles()
->with('photos')
->paginate(20);
return view('user.report.blocked', [
'blockedProfiles' => $blockedProfiles,
]);
}
}
Update DiscoveryService untuk Exclude Blocked Users
// Update di app/Services/DiscoveryService.php
public function getDiscoverProfiles(Profile $profile, int $limit = 10): Collection
{
$swipedIds = Swipe::where('swiper_id', $profile->id)->pluck('swiped_id')->toArray();
$blockedIds = $profile->blockedProfiles()->pluck('profiles.id')->toArray();
$blockedByIds = $profile->blockedByProfiles()->pluck('profiles.id')->toArray();
$excludeIds = array_unique(array_merge(
[$profile->id],
$swipedIds,
$blockedIds,
$blockedByIds
));
$query = Profile::query()
->where('is_active', true)
->where('is_profile_complete', true)
->whereNotIn('id', $excludeIds)
->with(['photos' => fn($q) => $q->where('is_approved', true)->orderBy('order')]);
// ... filter lainnya tetap sama
}
Membuat View untuk Report
{{-- resources/views/user/report/create.blade.php --}}
<x-app-layout>
<div class="max-w-lg mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Laporkan User</h1>
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center gap-4 mb-6">
<img src="{{ $profile->photos->first()?->thumbnail_url ?? '<https://ui-avatars.com/api/?name=>' . urlencode($profile->display_name) }}"
alt="{{ $profile->display_name }}"
class="w-16 h-16 rounded-full object-cover">
<div>
<h2 class="font-semibold text-gray-800">{{ $profile->display_name }}</h2>
<p class="text-gray-500">{{ $profile->age }} tahun</p>
</div>
</div>
<form id="report-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Alasan Laporan</label>
<div class="space-y-2">
@foreach($reasons as $value => $label)
<label class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
<input type="radio" name="reason" value="{{ $value }}" class="text-rose-500" required>
<span>{{ $label }}</span>
</label>
@endforeach
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Detail Tambahan (Opsional)</label>
<textarea name="description" rows="3"
class="w-full border rounded-lg p-3 focus:ring-rose-500 focus:border-rose-500"
placeholder="Ceritakan lebih detail tentang masalahnya..."></textarea>
</div>
<label class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<input type="checkbox" name="block" value="1" class="text-rose-500 rounded">
<div>
<span class="font-medium">Block user ini juga</span>
<p class="text-sm text-gray-500">User tidak akan bisa melihat atau menghubungi kamu lagi</p>
</div>
</label>
<button type="submit" class="w-full py-3 bg-rose-500 text-white font-semibold rounded-xl hover:bg-rose-600 transition">
Kirim Laporan
</button>
</form>
</div>
<a href="javascript:history.back()" class="block text-center text-gray-500 hover:text-gray-700">
← Kembali
</a>
</div>
@push('scripts')
<script>
document.getElementById('report-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
try {
const res = await fetch('{{ route("user.report.store", $profile) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
reason: formData.get('reason'),
description: formData.get('description'),
block: formData.get('block') === '1',
})
});
const data = await res.json();
if (res.ok) {
alert(data.message);
window.location.href = '{{ route("user.discover") }}';
} else {
alert(data.message || 'Terjadi kesalahan');
}
} catch (err) {
alert('Terjadi kesalahan');
}
});
</script>
@endpush
</x-app-layout>
Membuat View untuk Blocked List
{{-- resources/views/user/report/blocked.blade.php --}}
<x-app-layout>
<div class="max-w-2xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-6">User yang Diblock</h1>
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
@forelse($blockedProfiles as $profile)
<div class="flex items-center justify-between p-4 border-b last:border-b-0">
<div class="flex items-center gap-3">
<img src="{{ $profile->photos->first()?->thumbnail_url ?? '<https://ui-avatars.com/api/?name=>' . urlencode($profile->display_name) }}"
alt="{{ $profile->display_name }}"
class="w-12 h-12 rounded-full object-cover">
<div>
<h3 class="font-semibold text-gray-800">{{ $profile->display_name }}</h3>
<p class="text-sm text-gray-500">Diblock {{ $profile->pivot->created_at->diffForHumans() }}</p>
</div>
</div>
<button onclick="unblock({{ $profile->id }})"
class="px-4 py-2 text-sm text-rose-500 hover:bg-rose-50 rounded-lg transition">
Unblock
</button>
</div>
@empty
<div class="p-8 text-center text-gray-500">
Tidak ada user yang diblock
</div>
@endforelse
</div>
{{ $blockedProfiles->links() }}
</div>
@push('scripts')
<script>
async function unblock(profileId) {
if (!confirm('Yakin ingin unblock user ini?')) return;
try {
const res = await fetch(`/user/unblock/${profileId}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
if (res.ok) {
location.reload();
}
} catch (err) {
alert('Terjadi kesalahan');
}
}
</script>
@endpush
</x-app-layout>
Menambahkan Route
// routes/web.php
Route::middleware(['auth', 'verified.full'])->prefix('user')->name('user.')->group(function () {
Route::get('/report/{profile}', [ReportController::class, 'create'])->name('report.create');
Route::post('/report/{profile}', [ReportController::class, 'store'])->name('report.store');
Route::post('/block/{profile}', [ReportController::class, 'block'])->name('block');
Route::post('/unblock/{profile}', [ReportController::class, 'unblock'])->name('unblock');
Route::get('/blocked', [ReportController::class, 'blockedList'])->name('blocked');
});
Update ReportResource di Filament
// Tambahkan action di app/Filament/Resources/ReportResource.php
Tables\\Actions\\Action::make('viewReported')
->label('Lihat Profil')
->icon('heroicon-o-user')
->url(fn (Report $record) => ProfileResource::getUrl('edit', ['record' => $record->reported_id]))
->openUrlInNewTab(),
Tables\\Actions\\Action::make('warnUser')
->label('Kirim Peringatan')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->visible(fn () => auth()->user()->can('review_reports'))
->form([
Forms\\Components\\Textarea::make('message')
->label('Pesan Peringatan')
->required(),
])
->action(function (Report $record, array $data) {
// Kirim notifikasi peringatan ke user
$record->reported->user->notify(new \\App\\Notifications\\WarningNotification($data['message']));
$record->update([
'status' => 'resolved',
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
'admin_notes' => 'Peringatan dikirim: ' . $data['message'],
]);
}),
Tables\\Actions\\Action::make('suspendUser')
->label('Suspend User')
->icon('heroicon-o-no-symbol')
->color('danger')
->visible(fn () => auth()->user()->can('suspend_profiles'))
->form([
Forms\\Components\\Select::make('duration')
->label('Durasi Suspend')
->options([
'7' => '7 Hari',
'30' => '30 Hari',
'0' => 'Permanen',
])
->required(),
Forms\\Components\\Textarea::make('reason')
->label('Alasan')
->required(),
])
->action(function (Report $record, array $data) {
$record->reported->update([
'is_active' => false,
'suspended_until' => $data['duration'] > 0 ? now()->addDays($data['duration']) : null,
]);
$record->update([
'status' => 'resolved',
'reviewed_by' => auth()->id(),
'reviewed_at' => now(),
'admin_notes' => 'User suspended: ' . $data['reason'],
]);
})
->requiresConfirmation(),
Ringkasan Sistem Report dan Block
Saya sudah membuat sistem keamanan lengkap dengan fitur:
Untuk User:
- Laporkan profil dengan berbagai alasan (fake, harassment, scam, dll)
- Block user agar tidak bisa melihat/menghubungi
- Opsi report + block sekaligus
- Lihat daftar user yang diblock
- Unblock user
Untuk Admin/Moderator:
- Review semua laporan di admin panel
- Kirim peringatan ke user
- Suspend user (7 hari, 30 hari, atau permanen)
- Auto-suspend jika dapat 5+ laporan dalam 30 hari
Fitur Keamanan:
- User yang diblock tidak muncul di discovery
- Match otomatis dihapus saat block
- Conversation dihapus saat block
Di bagian selanjutnya, saya akan membuat sistem notifikasi email dan push notification.
Bagian 14: Menambahkan Fitur Notifikasi Email dan Push Notification
Sekarang saya akan membuat sistem notifikasi untuk menginformasikan user tentang aktivitas penting seperti match baru, pesan masuk, seseorang melihat profil, dan promo premium. Saya akan menggunakan Laravel Notification untuk email dan Firebase Cloud Messaging untuk push notification.
Membuat Migration untuk Device Tokens
php artisan make:migration create_device_tokens_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('device_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('token')->unique();
$table->string('device_type')->default('web');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('device_tokens');
}
};
Membuat Migration untuk Notification Settings
php artisan make:migration create_notification_settings_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->boolean('email_new_match')->default(true);
$table->boolean('email_new_message')->default(true);
$table->boolean('email_profile_view')->default(false);
$table->boolean('email_promo')->default(true);
$table->boolean('push_new_match')->default(true);
$table->boolean('push_new_message')->default(true);
$table->boolean('push_profile_view')->default(true);
$table->boolean('push_promo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notification_settings');
}
};
php artisan migrate
Membuat Model
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class DeviceToken extends Model
{
protected $fillable = ['user_id', 'token', 'device_type'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class NotificationSetting extends Model
{
protected $fillable = [
'user_id',
'email_new_match', 'email_new_message', 'email_profile_view', 'email_promo',
'push_new_match', 'push_new_message', 'push_profile_view', 'push_promo',
];
protected $casts = [
'email_new_match' => 'boolean',
'email_new_message' => 'boolean',
'email_profile_view' => 'boolean',
'email_promo' => 'boolean',
'push_new_match' => 'boolean',
'push_new_message' => 'boolean',
'push_profile_view' => 'boolean',
'push_promo' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Update Model User
// Tambahkan di app/Models/User.php
public function deviceTokens(): HasMany
{
return $this->hasMany(DeviceToken::class);
}
public function notificationSetting(): HasOne
{
return $this->hasOne(NotificationSetting::class);
}
public function getNotificationSetting(): NotificationSetting
{
return $this->notificationSetting ?? NotificationSetting::create(['user_id' => $this->id]);
}
Membuat Notification untuk New Match
php artisan make:notification NewMatchNotification
<?php
namespace App\\Notifications;
use App\\Models\\Match;
use App\\Models\\Profile;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;
class NewMatchNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Match $match,
public Profile $matchedProfile
) {}
public function via(object $notifiable): array
{
$channels = [];
$settings = $notifiable->getNotificationSetting();
if ($settings->email_new_match) {
$channels[] = 'mail';
}
if ($settings->push_new_match && $notifiable->deviceTokens()->exists()) {
$channels[] = 'fcm';
}
return $channels;
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('🎉 Kamu punya Match baru!')
->greeting('Hai ' . $notifiable->name . '!')
->line('Selamat! Kamu dan ' . $this->matchedProfile->display_name . ' saling menyukai.')
->line('Jangan malu-malu, mulai percakapan sekarang!')
->action('Kirim Pesan', route('user.chat.show', $this->match->conversation))
->line('Semoga beruntung! 💕');
}
public function toFcm(object $notifiable): array
{
return [
'title' => '🎉 Match Baru!',
'body' => 'Kamu dan ' . $this->matchedProfile->display_name . ' saling menyukai',
'data' => [
'type' => 'new_match',
'match_id' => $this->match->id,
],
];
}
}
Membuat Notification untuk New Message
php artisan make:notification NewMessageNotification
<?php
namespace App\\Notifications;
use App\\Models\\Message;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;
class NewMessageNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Message $message
) {}
public function via(object $notifiable): array
{
$channels = [];
$settings = $notifiable->getNotificationSetting();
if ($settings->email_new_message) {
$channels[] = 'mail';
}
if ($settings->push_new_message && $notifiable->deviceTokens()->exists()) {
$channels[] = 'fcm';
}
return $channels;
}
public function toMail(object $notifiable): MailMessage
{
$sender = $this->message->sender;
return (new MailMessage)
->subject('💬 Pesan baru dari ' . $sender->display_name)
->greeting('Hai ' . $notifiable->name . '!')
->line($sender->display_name . ' mengirim pesan:')
->line('"' . Str::limit($this->message->body, 100) . '"')
->action('Balas Pesan', route('user.chat.show', $this->message->conversation))
->line('Jangan buat dia menunggu! 😊');
}
public function toFcm(object $notifiable): array
{
return [
'title' => $this->message->sender->display_name,
'body' => Str::limit($this->message->body, 50),
'data' => [
'type' => 'new_message',
'conversation_id' => $this->message->conversation_id,
],
];
}
}
Membuat Notification untuk Profile View
php artisan make:notification ProfileViewedNotification
<?php
namespace App\\Notifications;
use App\\Models\\Profile;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;
class ProfileViewedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Profile $viewer
) {}
public function via(object $notifiable): array
{
$channels = [];
$settings = $notifiable->getNotificationSetting();
if ($settings->push_profile_view && $notifiable->deviceTokens()->exists()) {
$channels[] = 'fcm';
}
return $channels;
}
public function toFcm(object $notifiable): array
{
return [
'title' => '👀 Seseorang melihat profilmu',
'body' => $this->viewer->display_name . ' tertarik dengan profilmu',
'data' => [
'type' => 'profile_view',
'profile_id' => $this->viewer->id,
],
];
}
}
Membuat Notification untuk Promo
php artisan make:notification PromoNotification
<?php
namespace App\\Notifications;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Notifications\\Messages\\MailMessage;
use Illuminate\\Notifications\\Notification;
class PromoNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public string $title,
public string $message,
public ?string $actionUrl = null
) {}
public function via(object $notifiable): array
{
$channels = [];
$settings = $notifiable->getNotificationSetting();
if ($settings->email_promo) {
$channels[] = 'mail';
}
if ($settings->push_promo && $notifiable->deviceTokens()->exists()) {
$channels[] = 'fcm';
}
return $channels;
}
public function toMail(object $notifiable): MailMessage
{
$mail = (new MailMessage)
->subject('🎁 ' . $this->title)
->greeting('Hai ' . $notifiable->name . '!')
->line($this->message);
if ($this->actionUrl) {
$mail->action('Lihat Promo', $this->actionUrl);
}
return $mail;
}
public function toFcm(object $notifiable): array
{
return [
'title' => '🎁 ' . $this->title,
'body' => $this->message,
'data' => [
'type' => 'promo',
'url' => $this->actionUrl,
],
];
}
}
Membuat FCM Channel
php artisan make:provider FcmServiceProvider
<?php
namespace App\\Providers;
use Illuminate\\Notifications\\ChannelManager;
use Illuminate\\Support\\ServiceProvider;
class FcmServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->make(ChannelManager::class)->extend('fcm', function () {
return new \\App\\Channels\\FcmChannel();
});
}
}
<?php
namespace App\\Channels;
use App\\Models\\User;
use Illuminate\\Notifications\\Notification;
use Illuminate\\Support\\Facades\\Http;
class FcmChannel
{
public function send(User $notifiable, Notification $notification): void
{
$data = $notification->toFcm($notifiable);
$tokens = $notifiable->deviceTokens()->pluck('token')->toArray();
if (empty($tokens)) {
return;
}
foreach ($tokens as $token) {
$this->sendToToken($token, $data);
}
}
protected function sendToToken(string $token, array $data): void
{
Http::withHeaders([
'Authorization' => 'key=' . config('services.fcm.server_key'),
'Content-Type' => 'application/json',
])->post('<https://fcm.googleapis.com/fcm/send>', [
'to' => $token,
'notification' => [
'title' => $data['title'],
'body' => $data['body'],
],
'data' => $data['data'] ?? [],
]);
}
}
Konfigurasi FCM
// config/services.php
'fcm' => [
'server_key' => env('FCM_SERVER_KEY'),
],
FCM_SERVER_KEY=your_fcm_server_key
Membuat NotificationService
<?php
namespace App\\Services;
use App\\Models\\Match;
use App\\Models\\Message;
use App\\Models\\Profile;
use App\\Models\\User;
use App\\Notifications\\NewMatchNotification;
use App\\Notifications\\NewMessageNotification;
use App\\Notifications\\ProfileViewedNotification;
use App\\Notifications\\PromoNotification;
class NotificationService
{
public function notifyNewMatch(Match $match): void
{
$user1 = $match->profileOne->user;
$user2 = $match->profileTwo->user;
$user1->notify(new NewMatchNotification($match, $match->profileTwo));
$user2->notify(new NewMatchNotification($match, $match->profileOne));
}
public function notifyNewMessage(Message $message): void
{
$conversation = $message->conversation;
$match = $conversation->match;
$recipientProfile = $match->profile_one_id === $message->sender_id
? $match->profileTwo
: $match->profileOne;
$recipientProfile->user->notify(new NewMessageNotification($message));
}
public function notifyProfileViewed(Profile $viewed, Profile $viewer): void
{
if (!$viewed->user->isPremium()) {
return;
}
$viewed->user->notify(new ProfileViewedNotification($viewer));
}
public function sendPromoToAll(string $title, string $message, ?string $url = null): void
{
User::whereHas('notificationSetting', fn($q) => $q->where('email_promo', true)->orWhere('push_promo', true))
->chunk(100, function ($users) use ($title, $message, $url) {
foreach ($users as $user) {
$user->notify(new PromoNotification($title, $message, $url));
}
});
}
public function sendPromoToFreeUsers(string $title, string $message, ?string $url = null): void
{
User::whereDoesntHave('subscriptions', fn($q) => $q->where('status', 'active')->where('ends_at', '>', now()))
->chunk(100, function ($users) use ($title, $message, $url) {
foreach ($users as $user) {
$user->notify(new PromoNotification($title, $message, $url));
}
});
}
}
Update Event Listeners
// app/Listeners/SendNewMatchNotification.php
<?php
namespace App\\Listeners;
use App\\Events\\NewMatch;
use App\\Services\\NotificationService;
class SendNewMatchNotification
{
public function handle(NewMatch $event): void
{
app(NotificationService::class)->notifyNewMatch($event->match);
}
}
// app/Listeners/SendNewMessageNotification.php
<?php
namespace App\\Listeners;
use App\\Events\\MessageSent;
use App\\Services\\NotificationService;
class SendNewMessageNotification
{
public function handle(MessageSent $event): void
{
app(NotificationService::class)->notifyNewMessage($event->message);
}
}
Membuat Controller untuk Notification Settings
<?php
namespace App\\Http\\Controllers\\User;
use App\\Http\\Controllers\\Controller;
use App\\Models\\DeviceToken;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
class NotificationController extends Controller
{
public function settings(Request $request): View
{
$settings = $request->user()->getNotificationSetting();
return view('user.settings.notifications', [
'settings' => $settings,
]);
}
public function updateSettings(Request $request): JsonResponse
{
$validated = $request->validate([
'email_new_match' => 'boolean',
'email_new_message' => 'boolean',
'email_profile_view' => 'boolean',
'email_promo' => 'boolean',
'push_new_match' => 'boolean',
'push_new_message' => 'boolean',
'push_profile_view' => 'boolean',
'push_promo' => 'boolean',
]);
$settings = $request->user()->getNotificationSetting();
$settings->update($validated);
return response()->json(['message' => 'Pengaturan disimpan']);
}
public function registerToken(Request $request): JsonResponse
{
$request->validate([
'token' => 'required|string',
'device_type' => 'string|in:web,android,ios',
]);
DeviceToken::updateOrCreate(
['token' => $request->token],
[
'user_id' => $request->user()->id,
'device_type' => $request->device_type ?? 'web',
]
);
return response()->json(['message' => 'Token registered']);
}
public function removeToken(Request $request): JsonResponse
{
DeviceToken::where('user_id', $request->user()->id)
->where('token', $request->token)
->delete();
return response()->json(['message' => 'Token removed']);
}
}
Membuat View Notification Settings
{{-- resources/views/user/settings/notifications.blade.php --}}
<x-app-layout>
<div class="max-w-2xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Pengaturan Notifikasi</h1>
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="p-4 border-b">
<h2 class="font-semibold text-gray-800">Email</h2>
</div>
<div class="divide-y">
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Match Baru</span>
<p class="text-sm text-gray-500">Dapat email saat ada match baru</p>
</div>
<input type="checkbox" name="email_new_match" class="toggle" {{ $settings->email_new_match ? 'checked' : '' }}>
</label>
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Pesan Baru</span>
<p class="text-sm text-gray-500">Dapat email saat ada pesan baru</p>
</div>
<input type="checkbox" name="email_new_message" class="toggle" {{ $settings->email_new_message ? 'checked' : '' }}>
</label>
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Promo & Tips</span>
<p class="text-sm text-gray-500">Dapat email promo dan tips dating</p>
</div>
<input type="checkbox" name="email_promo" class="toggle" {{ $settings->email_promo ? 'checked' : '' }}>
</label>
</div>
<div class="p-4 border-t border-b bg-gray-50">
<h2 class="font-semibold text-gray-800">Push Notification</h2>
</div>
<div class="divide-y">
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Match Baru</span>
<p class="text-sm text-gray-500">Notifikasi saat ada match baru</p>
</div>
<input type="checkbox" name="push_new_match" class="toggle" {{ $settings->push_new_match ? 'checked' : '' }}>
</label>
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Pesan Baru</span>
<p class="text-sm text-gray-500">Notifikasi saat ada pesan baru</p>
</div>
<input type="checkbox" name="push_new_message" class="toggle" {{ $settings->push_new_message ? 'checked' : '' }}>
</label>
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Profile View</span>
<p class="text-sm text-gray-500">Notifikasi saat seseorang melihat profilmu</p>
</div>
<input type="checkbox" name="push_profile_view" class="toggle" {{ $settings->push_profile_view ? 'checked' : '' }}>
</label>
<label class="flex items-center justify-between p-4">
<div>
<span class="font-medium">Promo</span>
<p class="text-sm text-gray-500">Notifikasi promo dan penawaran khusus</p>
</div>
<input type="checkbox" name="push_promo" class="toggle" {{ $settings->push_promo ? 'checked' : '' }}>
</label>
</div>
</div>
</div>
@push('scripts')
<script>
document.querySelectorAll('.toggle').forEach(toggle => {
toggle.addEventListener('change', async function() {
const data = {};
document.querySelectorAll('.toggle').forEach(t => {
data[t.name] = t.checked;
});
await fetch('{{ route("user.notifications.update") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(data)
});
});
});
</script>
@endpush
</x-app-layout>
Menambahkan Route
// routes/web.php
Route::middleware(['auth', 'verified.full'])->prefix('user')->name('user.')->group(function () {
Route::get('/settings/notifications', [NotificationController::class, 'settings'])->name('notifications.settings');
Route::post('/settings/notifications', [NotificationController::class, 'updateSettings'])->name('notifications.update');
Route::post('/device-token', [NotificationController::class, 'registerToken'])->name('device.register');
Route::delete('/device-token', [NotificationController::class, 'removeToken'])->name('device.remove');
});
Ringkasan Sistem Notifikasi
Saya sudah membuat sistem notifikasi lengkap dengan fitur:
Jenis Notifikasi:
- Match baru (email + push)
- Pesan baru (email + push)
- Profile view (push only, premium)
- Promo & tips (email + push)
User Settings:
- Toggle on/off per jenis notifikasi
- Pisah setting untuk email dan push
Firebase Cloud Messaging:
- Register device token
- Kirim push notification
- Support web, Android, iOS
NotificationService:
- Helper untuk kirim berbagai notifikasi
- Bulk send promo ke semua user atau free users only
- Respect user preferences
Di bagian selanjutnya, saya akan membuat dashboard analytics untuk admin.
Bagian 15: Membuat Dashboard Analytics dan Penutup
Ini adalah bagian terakhir dari tutorial. Saya akan membuat dashboard analytics untuk admin dan menulis penutup dengan saran untuk melanjutkan pembelajaran.
Membuat Widget Analytics
php artisan make:filament-widget AdvancedStatsWidget --stats-overview
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Match;
use App\\Models\\PaymentTransaction;
use App\\Models\\Profile;
use App\\Models\\Subscription;
use App\\Models\\User;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class AdvancedStatsWidget extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$totalUsers = User::count();
$newUsersToday = User::whereDate('created_at', today())->count();
$newUsersWeek = User::where('created_at', '>=', now()->subWeek())->count();
$activeUsers = Profile::where('last_active_at', '>=', now()->subDay())->count();
$verifiedUsers = Profile::where('is_verified', true)->count();
$totalMatches = Match::where('is_active', true)->count();
$matchesToday = Match::whereDate('matched_at', today())->count();
$premiumUsers = Subscription::where('status', 'active')
->where('ends_at', '>', now())
->distinct('user_id')
->count();
$monthlyRevenue = PaymentTransaction::where('status', 'success')
->whereMonth('paid_at', now()->month)
->sum('amount');
$conversionRate = $totalUsers > 0
? round(($premiumUsers / $totalUsers) * 100, 1)
: 0;
return [
Stat::make('Total Users', number_format($totalUsers))
->description("+{$newUsersToday} hari ini, +{$newUsersWeek} minggu ini")
->color('success')
->chart($this->getUsersChart()),
Stat::make('Active Users (24h)', number_format($activeUsers))
->description(round(($activeUsers / max($totalUsers, 1)) * 100, 1) . '% dari total')
->color('info'),
Stat::make('Verified Users', number_format($verifiedUsers))
->description(round(($verifiedUsers / max($totalUsers, 1)) * 100, 1) . '% verified')
->color('warning'),
Stat::make('Total Matches', number_format($totalMatches))
->description("+{$matchesToday} hari ini")
->color('danger'),
Stat::make('Premium Users', number_format($premiumUsers))
->description("Conversion rate: {$conversionRate}%")
->color('success'),
Stat::make('Revenue (Bulan Ini)', 'Rp ' . number_format($monthlyRevenue, 0, ',', '.'))
->description('Dari subscriptions')
->color('success'),
];
}
protected function getUsersChart(): array
{
$data = [];
for ($i = 6; $i >= 0; $i--) {
$data[] = User::whereDate('created_at', now()->subDays($i))->count();
}
return $data;
}
}
Membuat Chart Widgets
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\User;
use App\\Models\\Match;
use Filament\\Widgets\\ChartWidget;
class UserGrowthChart extends ChartWidget
{
protected static ?string $heading = 'Pertumbuhan User (30 Hari)';
protected static ?int $sort = 2;
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 29; $i >= 0; $i--) {
$date = now()->subDays($i);
$labels[] = $date->format('d/m');
$data[] = User::whereDate('created_at', $date)->count();
}
return [
'datasets' => [
[
'label' => 'User Baru',
'data' => $data,
'borderColor' => '#f43f5e',
'backgroundColor' => 'rgba(244, 63, 94, 0.1)',
'fill' => true,
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'line';
}
}
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\PaymentTransaction;
use Filament\\Widgets\\ChartWidget;
class RevenueChart extends ChartWidget
{
protected static ?string $heading = 'Revenue (6 Bulan)';
protected static ?int $sort = 3;
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$labels[] = $month->format('M Y');
$data[] = PaymentTransaction::where('status', 'success')
->whereYear('paid_at', $month->year)
->whereMonth('paid_at', $month->month)
->sum('amount');
}
return [
'datasets' => [
[
'label' => 'Revenue',
'data' => $data,
'backgroundColor' => '#10b981',
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'bar';
}
}
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Profile;
use Filament\\Widgets\\ChartWidget;
class GenderDistributionChart extends ChartWidget
{
protected static ?string $heading = 'Distribusi Gender';
protected static ?int $sort = 4;
protected static ?string $maxHeight = '250px';
protected function getData(): array
{
$male = Profile::where('gender', 'male')->count();
$female = Profile::where('gender', 'female')->count();
return [
'datasets' => [
[
'data' => [$male, $female],
'backgroundColor' => ['#3b82f6', '#ec4899'],
],
],
'labels' => ['Laki-laki', 'Perempuan'],
];
}
protected function getType(): string
{
return 'doughnut';
}
}
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Profile;
use Filament\\Widgets\\ChartWidget;
class AgeDistributionChart extends ChartWidget
{
protected static ?string $heading = 'Distribusi Usia';
protected static ?int $sort = 5;
protected function getData(): array
{
$ranges = [
'18-24' => [18, 24],
'25-29' => [25, 29],
'30-34' => [30, 34],
'35-39' => [35, 39],
'40+' => [40, 100],
];
$data = [];
$labels = [];
foreach ($ranges as $label => $range) {
$labels[] = $label;
$data[] = Profile::whereRaw('TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) BETWEEN ? AND ?', $range)->count();
}
return [
'datasets' => [
[
'label' => 'Jumlah User',
'data' => $data,
'backgroundColor' => ['#f43f5e', '#f97316', '#eab308', '#22c55e', '#3b82f6'],
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'bar';
}
}
Membuat Artisan Command untuk Kirim Promo
php artisan make:command SendPromoNotification
<?php
namespace App\\Console\\Commands;
use App\\Services\\NotificationService;
use Illuminate\\Console\\Command;
class SendPromoNotification extends Command
{
protected $signature = 'promo:send {--free-only : Only send to free users}';
protected $description = 'Send promo notification to users';
public function handle(NotificationService $notificationService): int
{
$title = $this->ask('Promo title?');
$message = $this->ask('Promo message?');
$url = $this->ask('Action URL (optional)?');
if (!$this->confirm('Send promo to users?')) {
return self::FAILURE;
}
$this->info('Sending promo notifications...');
if ($this->option('free-only')) {
$notificationService->sendPromoToFreeUsers($title, $message, $url);
} else {
$notificationService->sendPromoToAll($title, $message, $url);
}
$this->info('Done!');
return self::SUCCESS;
}
}
Route Web Lengkap
// routes/web.php
<?php
use Illuminate\\Support\\Facades\\Route;
Route::get('/', function () {
return view('welcome');
});
// Auth routes
Route::middleware('auth')->group(function () {
Route::get('/verify-email', [VerificationController::class, 'showEmailVerification'])->name('verification.email');
Route::post('/verify-email/send', [VerificationController::class, 'sendEmailOtp'])->name('verification.email.send');
Route::post('/verify-email', [VerificationController::class, 'verifyEmail'])->name('verification.email.verify');
Route::get('/verify-phone', [VerificationController::class, 'showPhoneVerification'])->name('verification.phone');
Route::post('/verify-phone/update', [VerificationController::class, 'updatePhone'])->name('verification.phone.update');
Route::post('/verify-phone', [VerificationController::class, 'verifyPhone'])->name('verification.phone.verify');
});
// User routes (fully verified)
Route::middleware(['auth', 'verified.full'])->prefix('user')->name('user.')->group(function () {
// Discovery & Matching
Route::get('/discover', [DiscoveryController::class, 'index'])->name('discover');
Route::post('/discover/{profile}/swipe', [DiscoveryController::class, 'swipe'])->name('discover.swipe');
Route::get('/matches', [MatchController::class, 'index'])->name('matches');
Route::get('/matches/liked-by', [MatchController::class, 'likedBy'])->name('matches.liked-by');
Route::delete('/matches/{match}', [MatchController::class, 'unmatch'])->name('matches.unmatch');
// Chat
Route::get('/chat', [ChatController::class, 'index'])->name('chat.index');
Route::get('/chat/{conversation}', [ChatController::class, 'show'])->name('chat.show');
Route::post('/chat/{conversation}/send', [ChatController::class, 'sendMessage'])->name('chat.send');
Route::post('/chat/{conversation}/typing', [ChatController::class, 'typing'])->name('chat.typing');
Route::post('/chat/{conversation}/read', [ChatController::class, 'markRead'])->name('chat.read');
// Photos
Route::post('/photos', [PhotoController::class, 'store'])->name('photos.store');
Route::delete('/photos/{photo}', [PhotoController::class, 'destroy'])->name('photos.destroy');
Route::post('/photos/{photo}/primary', [PhotoController::class, 'setPrimary'])->name('photos.primary');
// Subscription
Route::get('/subscription', [SubscriptionController::class, 'index'])->name('subscription.index');
Route::get('/subscription/checkout/{plan}', [SubscriptionController::class, 'checkout'])->name('subscription.checkout');
Route::get('/subscription/finish', [SubscriptionController::class, 'finish'])->name('subscription.finish');
Route::get('/subscription/history', [SubscriptionController::class, 'history'])->name('subscription.history');
// Report & Block
Route::get('/report/{profile}', [ReportController::class, 'create'])->name('report.create');
Route::post('/report/{profile}', [ReportController::class, 'store'])->name('report.store');
Route::post('/block/{profile}', [ReportController::class, 'block'])->name('block');
Route::post('/unblock/{profile}', [ReportController::class, 'unblock'])->name('unblock');
Route::get('/blocked', [ReportController::class, 'blockedList'])->name('blocked');
// Notifications
Route::get('/settings/notifications', [NotificationController::class, 'settings'])->name('notifications.settings');
Route::post('/settings/notifications', [NotificationController::class, 'updateSettings'])->name('notifications.update');
Route::post('/device-token', [NotificationController::class, 'registerToken'])->name('device.register');
});
require __DIR__.'/auth.php';
Penutup
Selamat! Kita sudah berhasil membangun website mencari jodoh yang lengkap menggunakan Laravel 12, Filament, Spatie Permission, dan Midtrans. Saya sangat senang bisa berbagi pengalaman ini dengan teman-teman semua.
Apa yang Sudah Kita Bangun
Mari kita recap semua fitur yang sudah kita implementasikan:
User Features:
- Registrasi dengan verifikasi email dan nomor telepon
- Upload foto profil dengan moderasi admin
- Discovery berdasarkan preferensi (gender, usia, jarak)
- Like, Pass, dan Super Like dengan daily limits
- Match sistem ketika saling like
- Real-time chat dengan typing indicator
- Subscription premium dengan berbagai benefit
- Report dan block user yang mengganggu
- Pengaturan notifikasi personal
Admin Features:
- Dashboard dengan statistik lengkap
- Manajemen user dengan verifikasi dan suspend
- Moderasi foto sebelum tampil
- Review laporan dari user
- Manajemen paket subscription
- Monitoring transaksi pembayaran
- Role-based access control
Technical Features:
- Payment gateway Midtrans
- Real-time chat dengan Laravel Reverb
- Push notification dengan Firebase
- Email notification
- Event-driven architecture
- Queue untuk proses background
Saran Pengembangan Lebih Lanjut
Aplikasi ini masih bisa dikembangkan dengan fitur-fitur seperti:
- Video call untuk kencan virtual
- AI matching untuk rekomendasi yang lebih akurat
- Verifikasi foto dengan selfie
- Stories seperti Instagram
- Event dan meetup komunitas
- Gamification dengan badges dan rewards
Tingkatkan Skill Bersama BuildWithAngga
Setelah menyelesaikan tutorial ini, saya sangat merekomendasikan teman-teman untuk melanjutkan pembelajaran di BuildWithAngga. Berikut adalah benefit yang akan teman-teman dapatkan:
Portfolio Berkualitas - Setiap kelas menghasilkan project nyata yang bisa langsung dijadikan portfolio untuk melamar kerja atau mendapatkan client freelance.
Akses Selamanya - Sekali bayar, akses selamanya. Materi terus diupdate mengikuti perkembangan teknologi terbaru tanpa biaya tambahan.
Bimbingan Mentor Berpengalaman - Belajar langsung dari praktisi industri yang sudah berpengalaman membangun berbagai aplikasi production.
Kurikulum Terstruktur - Learning path yang jelas dari pemula hingga mahir, tidak perlu bingung harus belajar apa.
Project-Based Learning - Fokus membangun project nyata, bukan hanya teori yang membosankan.
Komunitas Supportive - Bergabung dengan ribuan developer lain untuk saling sharing dan networking.
Sertifikat Completion - Tingkatkan kredibilitas dengan sertifikat yang bisa ditampilkan di LinkedIn atau CV.
Update Materi Berkala - Teknologi terus berkembang, materi juga terus diupdate agar tetap relevan.
Studi Kasus Industri - Belajar dari project nyata yang digunakan di industri, bukan contoh sederhana.
Persiapan Kerja Remote - Siap bekerja remote sebagai freelancer atau karyawan perusahaan global.
Harga Terjangkau - Investasi yang sangat worth it dibanding bootcamp lain yang harganya puluhan juta.
Support Karir - Guidance untuk membuat CV menarik, tips interview, dan cara mencari project freelance.
Kelas Rekomendasi di BuildWithAngga
Untuk melanjutkan dari tutorial ini, saya sarankan mengambil kelas:
- Laravel Advanced untuk best practices dan scalability
- Vue.js atau React untuk frontend modern
- Flutter untuk mobile app development
- DevOps untuk deployment dan CI/CD
- UI/UX Design untuk membuat tampilan lebih menarik
Kata Penutup
Terima kasih sudah mengikuti tutorial ini sampai selesai. Perjalanan menjadi developer profesional memang tidak mudah, tapi dengan konsistensi dan mentor yang tepat, pasti bisa tercapai.
Ingat, setiap expert dulunya adalah pemula. Yang membedakan adalah mereka tidak berhenti belajar dan terus mengembangkan skill.
Jangan ragu untuk bergabung dengan komunitas BuildWithAngga dan mulai perjalanan karir sebagai web developer profesional. Siapa tahu, project dating app ini bisa menjadi startup sukses di tangan teman-teman!
Sampai jumpa di tutorial selanjutnya! 🚀
"The best time to start was yesterday. The second best time is now."
Sumber Daya:
- Laravel: https://laravel.com/docs
- Filament: https://filamentphp.com/docs
- Spatie Permission: https://spatie.be/docs/laravel-permission
- Midtrans: https://docs.midtrans.com
- Firebase: https://firebase.google.com/docs
- BuildWithAngga: https://buildwithangga.com
Salam sukses! 👋