Tutorial Laravel 12, Reverb, Filament, Spatie, Midtrans: Membangun Website Mencari Jodoh

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

RoleAkses
AdminFull access ke semua fitur
ModeratorVerifikasi profil, approve foto, review laporan, unmatch, delete messages
Customer SupportView profil & laporan, kelola subscription (cancel/refund)
Content ManagerKelola 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

PaketHargaFitur
FreeRp 020 likes/hari, 1 super like, dengan iklan
BasicRp 49.00050 likes/hari, 3 super likes, tanpa iklan
GoldRp 99.000Unlimited likes, 5 super likes, lihat siapa yang like
PlatinumRp 149.000Semua 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:

Salam sukses! 👋